lfx-nightly 0.2.1.dev7__py3-none-any.whl → 0.3.0.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. lfx/__main__.py +137 -6
  2. lfx/_assets/component_index.json +1 -1
  3. lfx/base/agents/agent.py +9 -5
  4. lfx/base/agents/altk_base_agent.py +5 -3
  5. lfx/base/agents/events.py +1 -1
  6. lfx/base/models/unified_models.py +1 -1
  7. lfx/base/models/watsonx_constants.py +10 -7
  8. lfx/base/prompts/api_utils.py +40 -5
  9. lfx/cli/__init__.py +10 -2
  10. lfx/cli/script_loader.py +5 -4
  11. lfx/cli/validation.py +6 -3
  12. lfx/components/datastax/astradb_assistant_manager.py +4 -2
  13. lfx/components/docling/docling_remote.py +1 -0
  14. lfx/components/langchain_utilities/ibm_granite_handler.py +211 -0
  15. lfx/components/langchain_utilities/tool_calling.py +24 -1
  16. lfx/components/llm_operations/lambda_filter.py +182 -97
  17. lfx/components/models_and_agents/mcp_component.py +38 -1
  18. lfx/components/models_and_agents/prompt.py +105 -18
  19. lfx/components/ollama/ollama_embeddings.py +109 -28
  20. lfx/components/processing/text_operations.py +580 -0
  21. lfx/custom/custom_component/component.py +65 -10
  22. lfx/events/observability/__init__.py +0 -0
  23. lfx/events/observability/lifecycle_events.py +111 -0
  24. lfx/field_typing/__init__.py +57 -58
  25. lfx/graph/graph/base.py +36 -0
  26. lfx/graph/utils.py +45 -12
  27. lfx/graph/vertex/base.py +71 -22
  28. lfx/graph/vertex/vertex_types.py +0 -5
  29. lfx/inputs/input_mixin.py +1 -0
  30. lfx/inputs/inputs.py +5 -0
  31. lfx/interface/components.py +24 -7
  32. lfx/run/base.py +47 -77
  33. lfx/schema/__init__.py +50 -0
  34. lfx/schema/message.py +85 -8
  35. lfx/schema/workflow.py +171 -0
  36. lfx/services/deps.py +12 -0
  37. lfx/services/interfaces.py +43 -1
  38. lfx/services/schema.py +1 -0
  39. lfx/services/settings/auth.py +95 -4
  40. lfx/services/settings/base.py +4 -0
  41. lfx/services/settings/utils.py +82 -0
  42. lfx/services/transaction/__init__.py +5 -0
  43. lfx/services/transaction/service.py +35 -0
  44. lfx/tests/unit/components/__init__.py +0 -0
  45. lfx/utils/constants.py +1 -0
  46. lfx/utils/mustache_security.py +79 -0
  47. lfx/utils/validate_cloud.py +67 -0
  48. {lfx_nightly-0.2.1.dev7.dist-info → lfx_nightly-0.3.0.dev3.dist-info}/METADATA +3 -1
  49. {lfx_nightly-0.2.1.dev7.dist-info → lfx_nightly-0.3.0.dev3.dist-info}/RECORD +51 -42
  50. {lfx_nightly-0.2.1.dev7.dist-info → lfx_nightly-0.3.0.dev3.dist-info}/WHEEL +0 -0
  51. {lfx_nightly-0.2.1.dev7.dist-info → lfx_nightly-0.3.0.dev3.dist-info}/entry_points.txt +0 -0
lfx/base/agents/agent.py CHANGED
@@ -185,8 +185,10 @@ class LCAgentComponent(Component):
185
185
  if "input" not in input_dict:
186
186
  input_dict = {"input": self.input_value}
187
187
 
188
- if hasattr(self, "system_prompt") and self.system_prompt and self.system_prompt.strip():
189
- input_dict["system_prompt"] = self.system_prompt
188
+ # Use enhanced prompt if available (set by IBM Granite handler), otherwise use original
189
+ system_prompt_to_use = getattr(self, "_effective_system_prompt", None) or self.system_prompt
190
+ if system_prompt_to_use and system_prompt_to_use.strip():
191
+ input_dict["system_prompt"] = system_prompt_to_use
190
192
 
191
193
  if hasattr(self, "chat_history") and self.chat_history:
192
194
  if isinstance(self.chat_history, Data):
@@ -272,9 +274,11 @@ class LCAgentComponent(Component):
272
274
  on_token_callback,
273
275
  )
274
276
  except ExceptionWithMessageError as e:
275
- if hasattr(e, "agent_message") and hasattr(e.agent_message, "id"):
276
- msg_id = e.agent_message.id
277
- await delete_message(id_=msg_id)
277
+ # Only delete message from database if it has an ID (was stored)
278
+ if hasattr(e, "agent_message"):
279
+ msg_id = e.agent_message.get_id()
280
+ if msg_id:
281
+ await delete_message(id_=msg_id)
278
282
  await self._send_message_event(e.agent_message, category="remove_message")
279
283
  logger.error(f"ExceptionWithMessageError: {e}")
280
284
  raise
@@ -378,9 +378,11 @@ class ALTKBaseAgentComponent(AgentComponent):
378
378
  cast("SendMessageFunctionType", self.send_message),
379
379
  )
380
380
  except ExceptionWithMessageError as e:
381
- if hasattr(e, "agent_message") and hasattr(e.agent_message, "id"):
382
- msg_id = e.agent_message.id
383
- await delete_message(id_=msg_id)
381
+ # Only delete message from database if it has an ID (was stored)
382
+ if hasattr(e, "agent_message"):
383
+ msg_id = e.agent_message.get_id()
384
+ if msg_id:
385
+ await delete_message(id_=msg_id)
384
386
  await self._send_message_event(e.agent_message, category="remove_message")
385
387
  logger.error(f"ExceptionWithMessageError: {e}")
386
388
  raise
lfx/base/agents/events.py CHANGED
@@ -388,7 +388,7 @@ async def process_agent_events(
388
388
  agent_message = await send_message_callback(message=agent_message)
389
389
  # Capture the original message id - this must stay consistent throughout if streaming
390
390
  # Message may not contain id if the Agent is not connected to a Chat Output (_should_skip_message is True)
391
- initial_message_id = agent_message.id if hasattr(agent_message, "id") else None
391
+ initial_message_id = agent_message.get_id()
392
392
  try:
393
393
  # Create a mapping of run_ids to tool contents
394
394
  tool_blocks_map: dict[str, ToolContent] = {}
@@ -75,7 +75,7 @@ def get_model_provider_metadata():
75
75
  "icon": "Ollama",
76
76
  "variable_name": "OLLAMA_BASE_URL", # Ollama is local but can have custom URL
77
77
  },
78
- "IBM Watsonx": {
78
+ "IBM WatsonX": {
79
79
  "icon": "WatsonxAI",
80
80
  "variable_name": "WATSONX_APIKEY",
81
81
  },
@@ -2,52 +2,55 @@ from .model_metadata import create_model_metadata
2
2
 
3
3
  WATSONX_DEFAULT_LLM_MODELS = [
4
4
  create_model_metadata(
5
- provider="IBM Watsonx",
5
+ provider="IBM WatsonX",
6
6
  name="ibm/granite-3-2b-instruct",
7
7
  icon="WatsonxAI",
8
8
  model_type="llm",
9
+ tool_calling=False,
9
10
  default=True,
10
11
  ),
11
12
  create_model_metadata(
12
- provider="IBM Watsonx",
13
+ provider="IBM WatsonX",
13
14
  name="ibm/granite-3-8b-instruct",
14
15
  icon="WatsonxAI",
15
16
  model_type="llm",
17
+ tool_calling=True,
16
18
  default=True,
17
19
  ),
18
20
  create_model_metadata(
19
- provider="IBM Watsonx",
21
+ provider="IBM WatsonX",
20
22
  name="ibm/granite-13b-instruct-v2",
21
23
  icon="WatsonxAI",
22
24
  model_type="llm",
25
+ tool_calling=False,
23
26
  default=True,
24
27
  ),
25
28
  ]
26
29
 
27
30
  WATSONX_DEFAULT_EMBEDDING_MODELS = [
28
31
  create_model_metadata(
29
- provider="IBM Watsonx",
32
+ provider="IBM WatsonX",
30
33
  name="sentence-transformers/all-minilm-l12-v2",
31
34
  icon="WatsonxAI",
32
35
  model_type="embeddings",
33
36
  default=True,
34
37
  ),
35
38
  create_model_metadata(
36
- provider="IBM Watsonx",
39
+ provider="IBM WatsonX",
37
40
  name="ibm/slate-125m-english-rtrvr-v2",
38
41
  icon="WatsonxAI",
39
42
  model_type="embeddings",
40
43
  default=True,
41
44
  ),
42
45
  create_model_metadata(
43
- provider="IBM Watsonx",
46
+ provider="IBM WatsonX",
44
47
  name="ibm/slate-30m-english-rtrvr-v2",
45
48
  icon="WatsonxAI",
46
49
  model_type="embeddings",
47
50
  default=True,
48
51
  ),
49
52
  create_model_metadata(
50
- provider="IBM Watsonx",
53
+ provider="IBM WatsonX",
51
54
  name="intfloat/multilingual-e5-large",
52
55
  icon="WatsonxAI",
53
56
  model_type="embeddings",
@@ -3,6 +3,7 @@ from typing import Any
3
3
 
4
4
  from fastapi import HTTPException
5
5
  from langchain_core.prompts import PromptTemplate
6
+ from langchain_core.prompts.string import mustache_template_vars
6
7
 
7
8
  from lfx.inputs.inputs import DefaultPromptField
8
9
  from lfx.interface.utils import extract_input_variables_from_prompt
@@ -90,7 +91,7 @@ def _check_variable(var, invalid_chars, wrong_variables, empty_variables):
90
91
  def _check_for_errors(input_variables, fixed_variables, wrong_variables, empty_variables) -> None:
91
92
  if any(var for var in input_variables if var not in fixed_variables):
92
93
  error_message = (
93
- f"Error: Input variables contain invalid characters or formats. \n"
94
+ f"Input variables contain invalid characters or formats. \n"
94
95
  f"Invalid variables: {', '.join(wrong_variables)}.\n"
95
96
  f"Empty variables: {', '.join(empty_variables)}. \n"
96
97
  f"Fixed variables: {', '.join(fixed_variables)}."
@@ -122,8 +123,37 @@ def _check_input_variables(input_variables):
122
123
  return fixed_variables
123
124
 
124
125
 
125
- def validate_prompt(prompt_template: str, *, silent_errors: bool = False) -> list[str]:
126
- input_variables = extract_input_variables_from_prompt(prompt_template)
126
+ def validate_prompt(prompt_template: str, *, silent_errors: bool = False, is_mustache: bool = False) -> list[str]:
127
+ if is_mustache:
128
+ # Extract only mustache variables
129
+ try:
130
+ input_variables = mustache_template_vars(prompt_template)
131
+ except Exception as exc:
132
+ # Mustache parser errors are often cryptic (e.g., "unclosed tag at line 1")
133
+ # Provide a more helpful error message
134
+ error_str = str(exc).lower()
135
+ if "unclosed" in error_str or "tag" in error_str:
136
+ msg = "Invalid template syntax. Check that all {{variables}} have matching opening and closing braces."
137
+ else:
138
+ msg = f"Invalid mustache template: {exc}"
139
+ raise ValueError(msg) from exc
140
+
141
+ # Also get f-string variables to filter them out
142
+ fstring_vars = extract_input_variables_from_prompt(prompt_template)
143
+
144
+ # Only keep variables that are actually in mustache syntax (not in f-string syntax)
145
+ # This handles cases where template has both {var} and {{var}}
146
+ input_variables = [v for v in input_variables if v not in fstring_vars or f"{{{{{v}}}}}" in prompt_template]
147
+ else:
148
+ # Extract f-string variables
149
+ input_variables = extract_input_variables_from_prompt(prompt_template)
150
+
151
+ # Also get mustache variables to filter them out
152
+ mustache_vars = mustache_template_vars(prompt_template)
153
+
154
+ # Only keep variables that are NOT in mustache syntax
155
+ # This handles cases where template has both {var} and {{var}}
156
+ input_variables = [v for v in input_variables if v not in mustache_vars]
127
157
 
128
158
  # Check if there are invalid characters in the input_variables
129
159
  input_variables = _check_input_variables(input_variables)
@@ -199,11 +229,16 @@ def update_input_variables_field(input_variables, template) -> None:
199
229
 
200
230
 
201
231
  def process_prompt_template(
202
- template: str, name: str, custom_fields: dict[str, list[str]] | None, frontend_node_template: dict[str, Any]
232
+ template: str,
233
+ name: str,
234
+ custom_fields: dict[str, list[str]] | None,
235
+ frontend_node_template: dict[str, Any],
236
+ *,
237
+ is_mustache: bool = False,
203
238
  ):
204
239
  """Process and validate prompt template, update template and custom fields."""
205
240
  # Validate the prompt template and extract input variables
206
- input_variables = validate_prompt(template)
241
+ input_variables = validate_prompt(template, is_mustache=is_mustache)
207
242
 
208
243
  # Initialize custom_fields if None
209
244
  if custom_fields is None:
lfx/cli/__init__.py CHANGED
@@ -1,5 +1,13 @@
1
1
  """LFX CLI module for serving flows."""
2
2
 
3
- from lfx.cli.commands import serve_command
4
-
5
3
  __all__ = ["serve_command"]
4
+
5
+
6
+ def __getattr__(name: str):
7
+ """Lazy import for serve_command."""
8
+ if name == "serve_command":
9
+ from lfx.cli.commands import serve_command
10
+
11
+ return serve_command
12
+ msg = f"module {__name__!r} has no attribute {name!r}"
13
+ raise AttributeError(msg)
lfx/cli/script_loader.py CHANGED
@@ -15,9 +15,8 @@ from typing import TYPE_CHECKING, Any
15
15
 
16
16
  import typer
17
17
 
18
- from lfx.graph import Graph
19
-
20
18
  if TYPE_CHECKING:
19
+ from lfx.graph import Graph
21
20
  from lfx.schema.message import Message
22
21
 
23
22
 
@@ -59,8 +58,10 @@ def _load_module_from_script(script_path: Path) -> Any:
59
58
  return module
60
59
 
61
60
 
62
- def _validate_graph_instance(graph_obj: Any) -> Graph:
61
+ def _validate_graph_instance(graph_obj: Any) -> "Graph":
63
62
  """Extract information from a graph object."""
63
+ from lfx.graph import Graph
64
+
64
65
  if not isinstance(graph_obj, Graph):
65
66
  msg = f"Graph object is not a LFX Graph instance: {type(graph_obj)}"
66
67
  raise TypeError(msg)
@@ -82,7 +83,7 @@ def _validate_graph_instance(graph_obj: Any) -> Graph:
82
83
  return graph_obj
83
84
 
84
85
 
85
- async def load_graph_from_script(script_path: Path) -> Graph:
86
+ async def load_graph_from_script(script_path: Path) -> "Graph":
86
87
  """Load and execute a Python script to extract the 'graph' variable or call 'get_graph' function.
87
88
 
88
89
  Args:
lfx/cli/validation.py CHANGED
@@ -1,9 +1,10 @@
1
1
  """Validation utilities for CLI commands."""
2
2
 
3
3
  import re
4
+ from typing import TYPE_CHECKING
4
5
 
5
- from lfx.graph.graph.base import Graph
6
- from lfx.services.deps import get_settings_service
6
+ if TYPE_CHECKING:
7
+ from lfx.graph.graph.base import Graph
7
8
 
8
9
 
9
10
  def is_valid_env_var_name(name: str) -> bool:
@@ -26,7 +27,7 @@ def is_valid_env_var_name(name: str) -> bool:
26
27
  return bool(re.match(pattern, name))
27
28
 
28
29
 
29
- def validate_global_variables_for_env(graph: Graph) -> list[str]:
30
+ def validate_global_variables_for_env(graph: "Graph") -> list[str]:
30
31
  """Validate that all global variables with load_from_db=True can be used as environment variables.
31
32
 
32
33
  When the database is not available (noop mode), global variables with load_from_db=True
@@ -39,6 +40,8 @@ def validate_global_variables_for_env(graph: Graph) -> list[str]:
39
40
  Returns:
40
41
  list[str]: List of error messages for invalid variable names
41
42
  """
43
+ from lfx.services.deps import get_settings_service
44
+
42
45
  errors = []
43
46
  settings_service = get_settings_service()
44
47
 
@@ -299,8 +299,10 @@ class AstraAssistantManager(ComponentWithCache):
299
299
  )
300
300
  self.status = processed_result
301
301
  except ExceptionWithMessageError as e:
302
- msg_id = e.agent_message.id
303
- await delete_message(id_=msg_id)
302
+ # Only delete message from database if it has an ID (was stored)
303
+ msg_id = e.agent_message.get_id()
304
+ if msg_id:
305
+ await delete_message(id_=msg_id)
304
306
  await self._send_message_event(e.agent_message, category="remove_message")
305
307
  raise
306
308
  except Exception:
@@ -39,6 +39,7 @@ class DoclingRemoteComponent(BaseFileComponent):
39
39
  "htm",
40
40
  "html",
41
41
  "jpeg",
42
+ "jpg",
42
43
  "json",
43
44
  "md",
44
45
  "pdf",
@@ -0,0 +1,211 @@
1
+ """IBM WatsonX-specific tool calling logic.
2
+
3
+ This module contains all the specialized handling for IBM WatsonX models
4
+ which have different tool calling behavior compared to other LLMs.
5
+
6
+ The tool calling issues affect ALL models on the WatsonX platform,
7
+ not just Granite models. This includes:
8
+ - meta-llama models
9
+ - mistral models
10
+ - granite models
11
+ - any other model running through WatsonX
12
+ """
13
+
14
+ import re
15
+
16
+ from langchain.agents.format_scratchpad.tools import format_to_tool_messages
17
+ from langchain.agents.output_parsers.tools import ToolsAgentOutputParser
18
+ from langchain_core.prompts import ChatPromptTemplate
19
+ from langchain_core.runnables import RunnableLambda
20
+
21
+ from lfx.log.logger import logger
22
+
23
+ # Pattern to detect placeholder usage in tool arguments
24
+ PLACEHOLDER_PATTERN = re.compile(
25
+ r"<[^>]*(?:result|value|output|response|data|from|extract|previous|current|date|input|query|search|tool)[^>]*>",
26
+ re.IGNORECASE,
27
+ )
28
+
29
+
30
+ def is_watsonx_model(llm) -> bool:
31
+ """Check if the LLM is an IBM WatsonX model (any model, not just Granite).
32
+
33
+ This detects the provider (WatsonX) rather than a specific model,
34
+ since tool calling issues affect all models on the WatsonX platform.
35
+ """
36
+ # Check class name for WatsonX (e.g., ChatWatsonx)
37
+ class_name = type(llm).__name__.lower()
38
+ if "watsonx" in class_name:
39
+ return True
40
+
41
+ # Fallback: check module name (e.g., langchain_ibm)
42
+ module_name = getattr(type(llm), "__module__", "").lower()
43
+ return "watsonx" in module_name or "langchain_ibm" in module_name
44
+
45
+
46
+ def is_granite_model(llm) -> bool:
47
+ """Check if the LLM is an IBM Granite model.
48
+
49
+ DEPRECATED: Use is_watsonx_model() instead.
50
+ Kept for backwards compatibility.
51
+ """
52
+ model_id = getattr(llm, "model_id", getattr(llm, "model_name", ""))
53
+ return "granite" in str(model_id).lower()
54
+
55
+
56
+ def _get_tool_schema_description(tool) -> str:
57
+ """Extract a brief description of the tool's expected parameters.
58
+
59
+ Returns empty string if schema extraction fails (graceful degradation).
60
+ """
61
+ if not hasattr(tool, "args_schema") or not tool.args_schema:
62
+ return ""
63
+
64
+ schema = tool.args_schema
65
+ if not hasattr(schema, "model_fields"):
66
+ return ""
67
+
68
+ try:
69
+ fields = schema.model_fields
70
+ params = []
71
+ for name, field in fields.items():
72
+ required = field.is_required() if hasattr(field, "is_required") else True
73
+ req_str = "(required)" if required else "(optional)"
74
+ params.append(f"{name} {req_str}")
75
+ return f"Parameters: {', '.join(params)}" if params else ""
76
+ except (AttributeError, TypeError) as e:
77
+ logger.debug(f"Could not extract schema for tool {getattr(tool, 'name', 'unknown')}: {e}")
78
+ return ""
79
+
80
+
81
+ def get_enhanced_system_prompt(base_prompt: str, tools: list) -> str:
82
+ """Enhance system prompt for WatsonX models with tool usage instructions."""
83
+ if not tools or len(tools) <= 1:
84
+ return base_prompt
85
+
86
+ # Build detailed tool descriptions with their parameters
87
+ tool_descriptions = []
88
+ for t in tools:
89
+ schema_desc = _get_tool_schema_description(t)
90
+ if schema_desc:
91
+ tool_descriptions.append(f"- {t.name}: {schema_desc}")
92
+ else:
93
+ tool_descriptions.append(f"- {t.name}")
94
+
95
+ tools_section = "\n".join(tool_descriptions)
96
+
97
+ # Note: "one tool at a time" is a WatsonX platform limitation, not a design choice.
98
+ # WatsonX models don't reliably support parallel tool calls.
99
+ enhancement = f"""
100
+
101
+ TOOL USAGE GUIDELINES:
102
+
103
+ 1. ALWAYS call tools when you need information - never say "I cannot" or "I don't have access".
104
+ 2. Call one tool at a time, then use its result before calling another tool.
105
+ 3. Use ACTUAL values in tool arguments - never use placeholder syntax like <result-from-...>.
106
+ 4. Each tool has specific parameters - use the correct ones for each tool.
107
+
108
+ AVAILABLE TOOLS:
109
+ {tools_section}"""
110
+
111
+ return base_prompt + enhancement
112
+
113
+
114
+ def detect_placeholder_in_args(tool_calls: list) -> tuple[bool, str | None]:
115
+ """Detect if any tool call contains placeholder syntax in its arguments."""
116
+ if not tool_calls:
117
+ return False, None
118
+
119
+ for tool_call in tool_calls:
120
+ args = tool_call.get("args", {})
121
+ if isinstance(args, dict):
122
+ for key, value in args.items():
123
+ if isinstance(value, str) and PLACEHOLDER_PATTERN.search(value):
124
+ tool_name = tool_call.get("name", "unknown")
125
+ logger.warning(f"[IBM WatsonX] Detected placeholder: {tool_name}.{key}={value}")
126
+ return True, value
127
+ elif isinstance(args, str) and PLACEHOLDER_PATTERN.search(args):
128
+ logger.warning(f"[IBM WatsonX] Detected placeholder in args: {args}")
129
+ return True, args
130
+ return False, None
131
+
132
+
133
+ def _limit_to_single_tool_call(llm_response):
134
+ """Limit response to single tool call (WatsonX platform limitation)."""
135
+ if not hasattr(llm_response, "tool_calls") or not llm_response.tool_calls:
136
+ return llm_response
137
+
138
+ if len(llm_response.tool_calls) > 1:
139
+ logger.debug(f"[WatsonX] Limiting {len(llm_response.tool_calls)} tool calls to 1")
140
+ llm_response.tool_calls = [llm_response.tool_calls[0]]
141
+
142
+ return llm_response
143
+
144
+
145
+ def _handle_placeholder_in_response(llm_response, messages, llm_auto):
146
+ """Re-invoke with corrective message if placeholder syntax detected."""
147
+ if not hasattr(llm_response, "tool_calls") or not llm_response.tool_calls:
148
+ return llm_response
149
+
150
+ has_placeholder, _ = detect_placeholder_in_args(llm_response.tool_calls)
151
+ if not has_placeholder:
152
+ return llm_response
153
+
154
+ logger.warning("[WatsonX] Placeholder detected, requesting actual values")
155
+ from langchain_core.messages import SystemMessage
156
+
157
+ corrective_msg = SystemMessage(
158
+ content="Provide your final answer using the actual values from previous tool results."
159
+ )
160
+ messages_list = list(messages.messages) if hasattr(messages, "messages") else list(messages)
161
+ messages_list.append(corrective_msg)
162
+ return llm_auto.invoke(messages_list)
163
+
164
+
165
+ def create_granite_agent(llm, tools: list, prompt: ChatPromptTemplate, forced_iterations: int = 2):
166
+ """Create a tool calling agent for IBM WatsonX/Granite models.
167
+
168
+ Why this exists: WatsonX models have platform-specific tool calling behavior:
169
+ - With tool_choice='auto': Models often describe tools in text instead of calling them
170
+ - With tool_choice='required': Models can't provide final answers (causes infinite loops)
171
+ - Models only reliably support single tool calls per turn
172
+
173
+ Solution: Dynamic switching between 'required' (to force tool use) and 'auto' (to allow answers).
174
+
175
+ Args:
176
+ llm: WatsonX language model instance
177
+ tools: Available tools for the agent
178
+ prompt: Chat prompt template
179
+ forced_iterations: Iterations to force tool_choice='required' before allowing 'auto'
180
+
181
+ Returns:
182
+ Runnable agent chain compatible with AgentExecutor
183
+ """
184
+ if not hasattr(llm, "bind_tools"):
185
+ msg = "WatsonX handler requires a language model with bind_tools support."
186
+ raise ValueError(msg)
187
+
188
+ llm_required = llm.bind_tools(tools or [], tool_choice="required")
189
+ llm_auto = llm.bind_tools(tools or [], tool_choice="auto")
190
+
191
+ def invoke(inputs: dict):
192
+ intermediate_steps = inputs.get("intermediate_steps", [])
193
+ num_steps = len(intermediate_steps)
194
+
195
+ scratchpad = format_to_tool_messages(intermediate_steps)
196
+ messages = prompt.invoke({**inputs, "agent_scratchpad": scratchpad})
197
+
198
+ # Use 'required' for first N iterations, then 'auto' to allow final answers
199
+ use_required = num_steps < forced_iterations
200
+ llm_to_use = llm_required if use_required else llm_auto
201
+ logger.debug(f"[WatsonX] Step {num_steps + 1}, tool_choice={'required' if use_required else 'auto'}")
202
+
203
+ response = llm_to_use.invoke(messages)
204
+ response = _limit_to_single_tool_call(response)
205
+ return _handle_placeholder_in_response(response, messages, llm_auto)
206
+
207
+ return RunnableLambda(invoke) | ToolsAgentOutputParser()
208
+
209
+
210
+ # Alias for backwards compatibility
211
+ create_watsonx_agent = create_granite_agent
@@ -2,6 +2,13 @@ from langchain.agents import create_tool_calling_agent
2
2
  from langchain_core.prompts import ChatPromptTemplate
3
3
 
4
4
  from lfx.base.agents.agent import LCToolsAgentComponent
5
+
6
+ # IBM Granite-specific logic is in a separate file
7
+ from lfx.components.langchain_utilities.ibm_granite_handler import (
8
+ create_granite_agent,
9
+ get_enhanced_system_prompt,
10
+ is_granite_model,
11
+ )
5
12
  from lfx.inputs.inputs import (
6
13
  DataInput,
7
14
  HandleInput,
@@ -46,8 +53,17 @@ class ToolCallingAgentComponent(LCToolsAgentComponent):
46
53
  def create_agent_runnable(self):
47
54
  messages = []
48
55
 
56
+ # Use local variable to avoid mutating component state on repeated calls
57
+ effective_system_prompt = self.system_prompt or ""
58
+
59
+ # Enhance prompt for IBM Granite models (they need explicit tool usage instructions)
60
+ if is_granite_model(self.llm) and self.tools:
61
+ effective_system_prompt = get_enhanced_system_prompt(effective_system_prompt, self.tools)
62
+ # Store enhanced prompt for use in agent.py without mutating original
63
+ self._effective_system_prompt = effective_system_prompt
64
+
49
65
  # Only include system message if system_prompt is provided and not empty
50
- if hasattr(self, "system_prompt") and self.system_prompt and self.system_prompt.strip():
66
+ if effective_system_prompt.strip():
51
67
  messages.append(("system", "{system_prompt}"))
52
68
 
53
69
  messages.extend(
@@ -60,7 +76,14 @@ class ToolCallingAgentComponent(LCToolsAgentComponent):
60
76
 
61
77
  prompt = ChatPromptTemplate.from_messages(messages)
62
78
  self.validate_tool_names()
79
+
63
80
  try:
81
+ # Use IBM Granite-specific agent if detected
82
+ # Other WatsonX models (Llama, Mistral, etc.) use default behavior
83
+ if is_granite_model(self.llm) and self.tools:
84
+ return create_granite_agent(self.llm, self.tools, prompt)
85
+
86
+ # Default behavior for other models (including non-Granite WatsonX models)
64
87
  return create_tool_calling_agent(self.llm, self.tools or [], prompt)
65
88
  except NotImplementedError as e:
66
89
  message = f"{self.display_name} does not support tool calling. Please try using a compatible model."