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.
- lfx/__main__.py +137 -6
- lfx/_assets/component_index.json +1 -1
- lfx/base/agents/agent.py +9 -5
- lfx/base/agents/altk_base_agent.py +5 -3
- lfx/base/agents/events.py +1 -1
- lfx/base/models/unified_models.py +1 -1
- lfx/base/models/watsonx_constants.py +10 -7
- lfx/base/prompts/api_utils.py +40 -5
- lfx/cli/__init__.py +10 -2
- lfx/cli/script_loader.py +5 -4
- lfx/cli/validation.py +6 -3
- lfx/components/datastax/astradb_assistant_manager.py +4 -2
- lfx/components/docling/docling_remote.py +1 -0
- lfx/components/langchain_utilities/ibm_granite_handler.py +211 -0
- lfx/components/langchain_utilities/tool_calling.py +24 -1
- lfx/components/llm_operations/lambda_filter.py +182 -97
- lfx/components/models_and_agents/mcp_component.py +38 -1
- lfx/components/models_and_agents/prompt.py +105 -18
- lfx/components/ollama/ollama_embeddings.py +109 -28
- lfx/components/processing/text_operations.py +580 -0
- lfx/custom/custom_component/component.py +65 -10
- lfx/events/observability/__init__.py +0 -0
- lfx/events/observability/lifecycle_events.py +111 -0
- lfx/field_typing/__init__.py +57 -58
- lfx/graph/graph/base.py +36 -0
- lfx/graph/utils.py +45 -12
- lfx/graph/vertex/base.py +71 -22
- lfx/graph/vertex/vertex_types.py +0 -5
- lfx/inputs/input_mixin.py +1 -0
- lfx/inputs/inputs.py +5 -0
- lfx/interface/components.py +24 -7
- lfx/run/base.py +47 -77
- lfx/schema/__init__.py +50 -0
- lfx/schema/message.py +85 -8
- lfx/schema/workflow.py +171 -0
- lfx/services/deps.py +12 -0
- lfx/services/interfaces.py +43 -1
- lfx/services/schema.py +1 -0
- lfx/services/settings/auth.py +95 -4
- lfx/services/settings/base.py +4 -0
- lfx/services/settings/utils.py +82 -0
- lfx/services/transaction/__init__.py +5 -0
- lfx/services/transaction/service.py +35 -0
- lfx/tests/unit/components/__init__.py +0 -0
- lfx/utils/constants.py +1 -0
- lfx/utils/mustache_security.py +79 -0
- lfx/utils/validate_cloud.py +67 -0
- {lfx_nightly-0.2.1.dev7.dist-info → lfx_nightly-0.3.0.dev3.dist-info}/METADATA +3 -1
- {lfx_nightly-0.2.1.dev7.dist-info → lfx_nightly-0.3.0.dev3.dist-info}/RECORD +51 -42
- {lfx_nightly-0.2.1.dev7.dist-info → lfx_nightly-0.3.0.dev3.dist-info}/WHEEL +0 -0
- {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
|
|
189
|
-
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
382
|
-
|
|
383
|
-
|
|
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.
|
|
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] = {}
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
53
|
+
provider="IBM WatsonX",
|
|
51
54
|
name="intfloat/multilingual-e5-large",
|
|
52
55
|
icon="WatsonxAI",
|
|
53
56
|
model_type="embeddings",
|
lfx/base/prompts/api_utils.py
CHANGED
|
@@ -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"
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
6
|
-
from lfx.
|
|
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
|
-
|
|
303
|
-
|
|
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:
|
|
@@ -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
|
|
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."
|