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.

Files changed (103) hide show
  1. alita_sdk/cli/agent/__init__.py +5 -0
  2. alita_sdk/cli/agent/default.py +83 -1
  3. alita_sdk/cli/agent_loader.py +22 -4
  4. alita_sdk/cli/agent_ui.py +13 -3
  5. alita_sdk/cli/agents.py +1876 -186
  6. alita_sdk/cli/callbacks.py +96 -25
  7. alita_sdk/cli/cli.py +10 -1
  8. alita_sdk/cli/config.py +151 -9
  9. alita_sdk/cli/context/__init__.py +30 -0
  10. alita_sdk/cli/context/cleanup.py +198 -0
  11. alita_sdk/cli/context/manager.py +731 -0
  12. alita_sdk/cli/context/message.py +285 -0
  13. alita_sdk/cli/context/strategies.py +289 -0
  14. alita_sdk/cli/context/token_estimation.py +127 -0
  15. alita_sdk/cli/input_handler.py +167 -4
  16. alita_sdk/cli/inventory.py +1256 -0
  17. alita_sdk/cli/toolkit.py +14 -17
  18. alita_sdk/cli/toolkit_loader.py +35 -5
  19. alita_sdk/cli/tools/__init__.py +8 -1
  20. alita_sdk/cli/tools/filesystem.py +910 -64
  21. alita_sdk/cli/tools/planning.py +143 -157
  22. alita_sdk/cli/tools/terminal.py +154 -20
  23. alita_sdk/community/__init__.py +64 -8
  24. alita_sdk/community/inventory/__init__.py +224 -0
  25. alita_sdk/community/inventory/config.py +257 -0
  26. alita_sdk/community/inventory/enrichment.py +2137 -0
  27. alita_sdk/community/inventory/extractors.py +1469 -0
  28. alita_sdk/community/inventory/ingestion.py +3172 -0
  29. alita_sdk/community/inventory/knowledge_graph.py +1457 -0
  30. alita_sdk/community/inventory/parsers/__init__.py +218 -0
  31. alita_sdk/community/inventory/parsers/base.py +295 -0
  32. alita_sdk/community/inventory/parsers/csharp_parser.py +907 -0
  33. alita_sdk/community/inventory/parsers/go_parser.py +851 -0
  34. alita_sdk/community/inventory/parsers/html_parser.py +389 -0
  35. alita_sdk/community/inventory/parsers/java_parser.py +593 -0
  36. alita_sdk/community/inventory/parsers/javascript_parser.py +629 -0
  37. alita_sdk/community/inventory/parsers/kotlin_parser.py +768 -0
  38. alita_sdk/community/inventory/parsers/markdown_parser.py +362 -0
  39. alita_sdk/community/inventory/parsers/python_parser.py +604 -0
  40. alita_sdk/community/inventory/parsers/rust_parser.py +858 -0
  41. alita_sdk/community/inventory/parsers/swift_parser.py +832 -0
  42. alita_sdk/community/inventory/parsers/text_parser.py +322 -0
  43. alita_sdk/community/inventory/parsers/yaml_parser.py +370 -0
  44. alita_sdk/community/inventory/patterns/__init__.py +61 -0
  45. alita_sdk/community/inventory/patterns/ast_adapter.py +380 -0
  46. alita_sdk/community/inventory/patterns/loader.py +348 -0
  47. alita_sdk/community/inventory/patterns/registry.py +198 -0
  48. alita_sdk/community/inventory/presets.py +535 -0
  49. alita_sdk/community/inventory/retrieval.py +1403 -0
  50. alita_sdk/community/inventory/toolkit.py +169 -0
  51. alita_sdk/community/inventory/visualize.py +1370 -0
  52. alita_sdk/configurations/bitbucket.py +0 -3
  53. alita_sdk/runtime/clients/client.py +108 -31
  54. alita_sdk/runtime/langchain/assistant.py +4 -2
  55. alita_sdk/runtime/langchain/constants.py +3 -1
  56. alita_sdk/runtime/langchain/document_loaders/AlitaExcelLoader.py +103 -60
  57. alita_sdk/runtime/langchain/document_loaders/constants.py +10 -6
  58. alita_sdk/runtime/langchain/langraph_agent.py +123 -31
  59. alita_sdk/runtime/llms/preloaded.py +2 -6
  60. alita_sdk/runtime/toolkits/__init__.py +2 -0
  61. alita_sdk/runtime/toolkits/application.py +1 -1
  62. alita_sdk/runtime/toolkits/mcp.py +107 -91
  63. alita_sdk/runtime/toolkits/planning.py +173 -0
  64. alita_sdk/runtime/toolkits/tools.py +59 -7
  65. alita_sdk/runtime/tools/artifact.py +46 -17
  66. alita_sdk/runtime/tools/function.py +2 -1
  67. alita_sdk/runtime/tools/llm.py +320 -32
  68. alita_sdk/runtime/tools/mcp_remote_tool.py +23 -7
  69. alita_sdk/runtime/tools/planning/__init__.py +36 -0
  70. alita_sdk/runtime/tools/planning/models.py +246 -0
  71. alita_sdk/runtime/tools/planning/wrapper.py +607 -0
  72. alita_sdk/runtime/tools/vectorstore_base.py +44 -9
  73. alita_sdk/runtime/utils/AlitaCallback.py +106 -20
  74. alita_sdk/runtime/utils/mcp_client.py +465 -0
  75. alita_sdk/runtime/utils/mcp_oauth.py +80 -0
  76. alita_sdk/runtime/utils/mcp_tools_discovery.py +124 -0
  77. alita_sdk/runtime/utils/streamlit.py +6 -10
  78. alita_sdk/runtime/utils/toolkit_utils.py +14 -5
  79. alita_sdk/tools/__init__.py +54 -27
  80. alita_sdk/tools/ado/repos/repos_wrapper.py +1 -2
  81. alita_sdk/tools/base_indexer_toolkit.py +99 -20
  82. alita_sdk/tools/bitbucket/__init__.py +2 -2
  83. alita_sdk/tools/chunkers/__init__.py +3 -1
  84. alita_sdk/tools/chunkers/sematic/json_chunker.py +1 -0
  85. alita_sdk/tools/chunkers/sematic/markdown_chunker.py +97 -6
  86. alita_sdk/tools/chunkers/universal_chunker.py +270 -0
  87. alita_sdk/tools/code/loaders/codesearcher.py +3 -2
  88. alita_sdk/tools/code_indexer_toolkit.py +55 -22
  89. alita_sdk/tools/confluence/api_wrapper.py +63 -14
  90. alita_sdk/tools/elitea_base.py +86 -21
  91. alita_sdk/tools/jira/__init__.py +1 -1
  92. alita_sdk/tools/jira/api_wrapper.py +91 -40
  93. alita_sdk/tools/non_code_indexer_toolkit.py +1 -0
  94. alita_sdk/tools/qtest/__init__.py +1 -1
  95. alita_sdk/tools/sharepoint/api_wrapper.py +2 -2
  96. alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +17 -13
  97. alita_sdk/tools/zephyr_essential/api_wrapper.py +12 -13
  98. {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/METADATA +2 -1
  99. {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/RECORD +103 -61
  100. {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/WHEEL +0 -0
  101. {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/entry_points.txt +0 -0
  102. {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/licenses/LICENSE +0 -0
  103. {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/top_level.txt +0 -0
@@ -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 not func_args.get('system') or not func_args.get('task'):
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
- loop = asyncio.get_running_loop()
207
- # Already in async context - run in thread with new loop
208
- import threading
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
- result_container.append(new_loop.run_until_complete(coro))
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
- thread = threading.Thread(target=run_in_thread)
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
- except RuntimeError:
226
- # No event loop running - use/create persistent loop
227
- # This loop is shared with MCP session creation for stateful tools
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
- asyncio.set_event_loop(loop)
236
- return loop.run_until_complete(coro)
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
- try:
286
- # Try async invocation first
287
- tool_result = await tool_to_execute.ainvoke(tool_args, config=config)
288
- except NotImplementedError:
289
- # Tool doesn't support async, use sync invoke
290
- logger.debug(f"Tool '{tool_name}' doesn't support async, using sync invoke")
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
- logger.error(f"Error executing tool '{tool_name}': {e}\n{error_details}")
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
- logger.error(f"Error in LLM call during iteration {iteration}: {e}")
349
- # Add error message and break the loop
350
- error_msg = f"Error processing tool results in iteration {iteration}: {str(e)}"
351
- new_messages.append(AIMessage(content=error_msg))
352
- break
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
- # Log completion status
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
- # Add a warning message to the chat
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.mcp_sse_client import McpSseClient
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 SSE] Executing tool '{tool_name_for_server}' with session {self.session_id}")
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 SSE client
110
- client = McpSseClient(
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 via SSE
118
- result = await client.call_tool(tool_name_for_server, kwargs)
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 SSE] Tool execution failed: {e}", exc_info=True)
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
+ ]