lfx-nightly 0.1.12.dev42__py3-none-any.whl → 0.2.0.dev0__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 (93) hide show
  1. lfx/_assets/component_index.json +1 -1
  2. lfx/base/agents/agent.py +109 -29
  3. lfx/base/agents/events.py +102 -35
  4. lfx/base/agents/utils.py +15 -2
  5. lfx/base/composio/composio_base.py +24 -9
  6. lfx/base/datastax/__init__.py +5 -0
  7. lfx/{components/vectorstores/astradb.py → base/datastax/astradb_base.py} +84 -473
  8. lfx/base/io/chat.py +5 -4
  9. lfx/base/mcp/util.py +101 -15
  10. lfx/base/models/cometapi_constants.py +54 -0
  11. lfx/base/models/model_input_constants.py +74 -7
  12. lfx/base/models/ollama_constants.py +3 -0
  13. lfx/base/models/watsonx_constants.py +12 -0
  14. lfx/cli/commands.py +1 -1
  15. lfx/components/agents/__init__.py +3 -1
  16. lfx/components/agents/agent.py +47 -4
  17. lfx/components/agents/altk_agent.py +366 -0
  18. lfx/components/agents/cuga_agent.py +1 -1
  19. lfx/components/agents/mcp_component.py +32 -2
  20. lfx/components/amazon/amazon_bedrock_converse.py +1 -1
  21. lfx/components/apify/apify_actor.py +3 -3
  22. lfx/components/cometapi/__init__.py +32 -0
  23. lfx/components/cometapi/cometapi.py +166 -0
  24. lfx/components/datastax/__init__.py +12 -6
  25. lfx/components/datastax/{astra_assistant_manager.py → astradb_assistant_manager.py} +1 -0
  26. lfx/components/datastax/astradb_chatmemory.py +40 -0
  27. lfx/components/datastax/astradb_cql.py +5 -31
  28. lfx/components/datastax/astradb_graph.py +9 -123
  29. lfx/components/datastax/astradb_tool.py +12 -52
  30. lfx/components/datastax/astradb_vectorstore.py +133 -976
  31. lfx/components/datastax/create_assistant.py +1 -0
  32. lfx/components/datastax/create_thread.py +1 -0
  33. lfx/components/datastax/dotenv.py +1 -0
  34. lfx/components/datastax/get_assistant.py +1 -0
  35. lfx/components/datastax/getenvvar.py +1 -0
  36. lfx/components/datastax/graph_rag.py +1 -1
  37. lfx/components/datastax/list_assistants.py +1 -0
  38. lfx/components/datastax/run.py +1 -0
  39. lfx/components/docling/__init__.py +3 -0
  40. lfx/components/docling/docling_remote_vlm.py +284 -0
  41. lfx/components/helpers/memory.py +19 -4
  42. lfx/components/ibm/watsonx.py +25 -21
  43. lfx/components/input_output/chat.py +8 -0
  44. lfx/components/input_output/chat_output.py +8 -0
  45. lfx/components/knowledge_bases/ingestion.py +17 -9
  46. lfx/components/knowledge_bases/retrieval.py +16 -8
  47. lfx/components/logic/loop.py +4 -0
  48. lfx/components/mistral/mistral_embeddings.py +1 -1
  49. lfx/components/models/embedding_model.py +88 -7
  50. lfx/components/ollama/ollama.py +221 -14
  51. lfx/components/openrouter/openrouter.py +49 -147
  52. lfx/components/processing/parser.py +6 -1
  53. lfx/components/processing/structured_output.py +55 -17
  54. lfx/components/vectorstores/__init__.py +0 -6
  55. lfx/custom/custom_component/component.py +3 -2
  56. lfx/field_typing/constants.py +1 -0
  57. lfx/graph/edge/base.py +2 -2
  58. lfx/graph/graph/base.py +1 -1
  59. lfx/graph/graph/schema.py +3 -2
  60. lfx/graph/vertex/vertex_types.py +1 -1
  61. lfx/io/schema.py +6 -0
  62. lfx/memory/stubs.py +26 -7
  63. lfx/schema/message.py +6 -0
  64. lfx/schema/schema.py +5 -0
  65. lfx/services/settings/constants.py +1 -0
  66. {lfx_nightly-0.1.12.dev42.dist-info → lfx_nightly-0.2.0.dev0.dist-info}/METADATA +1 -1
  67. {lfx_nightly-0.1.12.dev42.dist-info → lfx_nightly-0.2.0.dev0.dist-info}/RECORD +70 -85
  68. lfx/components/datastax/astra_db.py +0 -77
  69. lfx/components/datastax/cassandra.py +0 -92
  70. lfx/components/vectorstores/astradb_graph.py +0 -326
  71. lfx/components/vectorstores/cassandra.py +0 -264
  72. lfx/components/vectorstores/cassandra_graph.py +0 -238
  73. lfx/components/vectorstores/chroma.py +0 -167
  74. lfx/components/vectorstores/clickhouse.py +0 -135
  75. lfx/components/vectorstores/couchbase.py +0 -102
  76. lfx/components/vectorstores/elasticsearch.py +0 -267
  77. lfx/components/vectorstores/faiss.py +0 -111
  78. lfx/components/vectorstores/graph_rag.py +0 -141
  79. lfx/components/vectorstores/hcd.py +0 -314
  80. lfx/components/vectorstores/milvus.py +0 -115
  81. lfx/components/vectorstores/mongodb_atlas.py +0 -213
  82. lfx/components/vectorstores/opensearch.py +0 -243
  83. lfx/components/vectorstores/pgvector.py +0 -72
  84. lfx/components/vectorstores/pinecone.py +0 -134
  85. lfx/components/vectorstores/qdrant.py +0 -109
  86. lfx/components/vectorstores/supabase.py +0 -76
  87. lfx/components/vectorstores/upstash.py +0 -124
  88. lfx/components/vectorstores/vectara.py +0 -97
  89. lfx/components/vectorstores/vectara_rag.py +0 -164
  90. lfx/components/vectorstores/weaviate.py +0 -89
  91. /lfx/components/datastax/{astra_vectorize.py → astradb_vectorize.py} +0 -0
  92. {lfx_nightly-0.1.12.dev42.dist-info → lfx_nightly-0.2.0.dev0.dist-info}/WHEEL +0 -0
  93. {lfx_nightly-0.1.12.dev42.dist-info → lfx_nightly-0.2.0.dev0.dist-info}/entry_points.txt +0 -0
lfx/base/agents/agent.py CHANGED
@@ -5,12 +5,13 @@ from typing import TYPE_CHECKING, cast
5
5
 
6
6
  from langchain.agents import AgentExecutor, BaseMultiActionAgent, BaseSingleActionAgent
7
7
  from langchain.agents.agent import RunnableAgent
8
- from langchain_core.messages import HumanMessage
8
+ from langchain.callbacks.base import BaseCallbackHandler
9
+ from langchain_core.messages import BaseMessage, HumanMessage
9
10
  from langchain_core.runnables import Runnable
10
11
 
11
12
  from lfx.base.agents.callback import AgentAsyncHandler
12
13
  from lfx.base.agents.events import ExceptionWithMessageError, process_agent_events
13
- from lfx.base.agents.utils import data_to_messages, get_chat_output_sender_name
14
+ from lfx.base.agents.utils import get_chat_output_sender_name
14
15
  from lfx.custom.custom_component.component import Component, _get_component_toolkit
15
16
  from lfx.field_typing import Tool
16
17
  from lfx.inputs.inputs import InputTypes, MultilineInput
@@ -19,14 +20,13 @@ from lfx.log.logger import logger
19
20
  from lfx.memory import delete_message
20
21
  from lfx.schema.content_block import ContentBlock
21
22
  from lfx.schema.data import Data
23
+ from lfx.schema.log import OnTokenFunctionType
22
24
  from lfx.schema.message import Message
23
25
  from lfx.template.field.base import Output
24
26
  from lfx.utils.constants import MESSAGE_SENDER_AI
25
27
 
26
28
  if TYPE_CHECKING:
27
- from langchain_core.messages import BaseMessage
28
-
29
- from lfx.schema.log import SendMessageFunctionType
29
+ from lfx.schema.log import OnTokenFunctionType, SendMessageFunctionType
30
30
 
31
31
 
32
32
  DEFAULT_TOOLS_DESCRIPTION = "A helpful assistant with access to the following tools:"
@@ -75,6 +75,12 @@ class LCAgentComponent(Component):
75
75
  Output(display_name="Response", name="response", method="message_response"),
76
76
  ]
77
77
 
78
+ # Get shared callbacks for tracing and save them to self.shared_callbacks
79
+ def _get_shared_callbacks(self) -> list[BaseCallbackHandler]:
80
+ if not hasattr(self, "shared_callbacks"):
81
+ self.shared_callbacks = self.get_langchain_callbacks()
82
+ return self.shared_callbacks
83
+
78
84
  @abstractmethod
79
85
  def build_agent(self) -> AgentExecutor:
80
86
  """Create the agent."""
@@ -119,6 +125,24 @@ class LCAgentComponent(Component):
119
125
  # might be overridden in subclasses
120
126
  return None
121
127
 
128
+ def _data_to_messages_skip_empty(self, data: list[Data]) -> list[BaseMessage]:
129
+ """Convert data to messages, filtering only empty text while preserving non-text content.
130
+
131
+ Note: added to fix issue with certain providers failing when given empty text as input.
132
+ """
133
+ messages = []
134
+ for value in data:
135
+ # Only skip if the message has a text attribute that is empty/whitespace
136
+ text = getattr(value, "text", None)
137
+ if isinstance(text, str) and not text.strip():
138
+ # Skip only messages with empty/whitespace-only text strings
139
+ continue
140
+
141
+ lc_message = value.to_lc_message()
142
+ messages.append(lc_message)
143
+
144
+ return messages
145
+
122
146
  async def run_agent(
123
147
  self,
124
148
  agent: Runnable | BaseSingleActionAgent | BaseMultiActionAgent | AgentExecutor,
@@ -138,41 +162,64 @@ class LCAgentComponent(Component):
138
162
  max_iterations=max_iterations,
139
163
  )
140
164
  # Convert input_value to proper format for agent
141
- if hasattr(self.input_value, "to_lc_message") and callable(self.input_value.to_lc_message):
165
+ lc_message = None
166
+ if isinstance(self.input_value, Message):
142
167
  lc_message = self.input_value.to_lc_message()
143
- input_text = lc_message.content if hasattr(lc_message, "content") else str(lc_message)
168
+ input_dict: dict[str, str | list[BaseMessage] | BaseMessage] = {"input": lc_message}
144
169
  else:
145
- lc_message = None
146
- input_text = self.input_value
170
+ input_dict = {"input": self.input_value}
147
171
 
148
- input_dict: dict[str, str | list[BaseMessage]] = {}
149
172
  if hasattr(self, "system_prompt"):
150
173
  input_dict["system_prompt"] = self.system_prompt
151
- if hasattr(self, "chat_history") and self.chat_history:
152
- if (
153
- hasattr(self.chat_history, "to_data")
154
- and callable(self.chat_history.to_data)
155
- and self.chat_history.__class__.__name__ == "Data"
156
- ):
157
- input_dict["chat_history"] = data_to_messages(self.chat_history)
158
- # Handle both lfx.schema.message.Message and langflow.schema.message.Message types
159
- if all(hasattr(m, "to_data") and callable(m.to_data) and "text" in m.data for m in self.chat_history):
160
- input_dict["chat_history"] = data_to_messages(self.chat_history)
161
- if all(isinstance(m, Message) for m in self.chat_history):
162
- input_dict["chat_history"] = data_to_messages([m.to_data() for m in self.chat_history])
163
- if hasattr(lc_message, "content") and isinstance(lc_message.content, list):
164
- # ! Because the input has to be a string, we must pass the images in the chat_history
165
174
 
175
+ if hasattr(self, "chat_history") and self.chat_history:
176
+ if isinstance(self.chat_history, Data):
177
+ input_dict["chat_history"] = self._data_to_messages_skip_empty([self.chat_history])
178
+ elif all(hasattr(m, "to_data") and callable(m.to_data) and "text" in m.data for m in self.chat_history):
179
+ input_dict["chat_history"] = self._data_to_messages_skip_empty(self.chat_history)
180
+ elif all(isinstance(m, Message) for m in self.chat_history):
181
+ input_dict["chat_history"] = self._data_to_messages_skip_empty([m.to_data() for m in self.chat_history])
182
+
183
+ # Handle multimodal input (images + text)
184
+ # Note: Agent input must be a string, so we extract text and move images to chat_history
185
+ if lc_message is not None and hasattr(lc_message, "content") and isinstance(lc_message.content, list):
186
+ # Extract images and text from the text content items
166
187
  image_dicts = [item for item in lc_message.content if item.get("type") == "image"]
167
- lc_message.content = [item for item in lc_message.content if item.get("type") != "image"]
188
+ text_content = [item for item in lc_message.content if item.get("type") != "image"]
189
+
190
+ text_strings = [
191
+ item.get("text", "")
192
+ for item in text_content
193
+ if item.get("type") == "text" and item.get("text", "").strip()
194
+ ]
195
+
196
+ # Set input to concatenated text or empty string
197
+ input_dict["input"] = " ".join(text_strings) if text_strings else ""
198
+
199
+ # If input is still a list or empty, provide a default
200
+ if isinstance(input_dict["input"], list) or not input_dict["input"]:
201
+ input_dict["input"] = "Process the provided images."
168
202
 
169
203
  if "chat_history" not in input_dict:
170
204
  input_dict["chat_history"] = []
205
+
171
206
  if isinstance(input_dict["chat_history"], list):
172
207
  input_dict["chat_history"].extend(HumanMessage(content=[image_dict]) for image_dict in image_dicts)
173
208
  else:
174
209
  input_dict["chat_history"] = [HumanMessage(content=[image_dict]) for image_dict in image_dicts]
175
- input_dict["input"] = input_text
210
+
211
+ # Final safety check: ensure input is never empty (prevents Anthropic API errors)
212
+ current_input = input_dict.get("input", "")
213
+ if isinstance(current_input, list):
214
+ current_input = " ".join(map(str, current_input))
215
+ elif not isinstance(current_input, str):
216
+ current_input = str(current_input)
217
+
218
+ if not current_input.strip():
219
+ input_dict["input"] = "Continue the conversation."
220
+ else:
221
+ input_dict["input"] = current_input
222
+
176
223
  if hasattr(self, "graph"):
177
224
  session_id = self.graph.session_id
178
225
  elif hasattr(self, "_session_id"):
@@ -181,7 +228,6 @@ class LCAgentComponent(Component):
181
228
  session_id = None
182
229
 
183
230
  sender_name = get_chat_output_sender_name(self) or self.display_name or "AI"
184
-
185
231
  agent_message = Message(
186
232
  sender=MESSAGE_SENDER_AI,
187
233
  sender_name=sender_name,
@@ -189,15 +235,24 @@ class LCAgentComponent(Component):
189
235
  content_blocks=[ContentBlock(title="Agent Steps", contents=[])],
190
236
  session_id=session_id or uuid.uuid4(),
191
237
  )
238
+
239
+ # Create token callback if event_manager is available
240
+ # This wraps the event_manager's on_token method to match OnTokenFunctionType Protocol
241
+ on_token_callback: OnTokenFunctionType | None = None
242
+ if self._event_manager:
243
+ on_token_callback = cast("OnTokenFunctionType", self._event_manager.on_token)
244
+
192
245
  try:
193
246
  result = await process_agent_events(
194
247
  runnable.astream_events(
195
248
  input_dict,
196
- config={"callbacks": [AgentAsyncHandler(self.log), *self.get_langchain_callbacks()]},
249
+ # here we use the shared callbacks because the AgentExecutor uses the tools
250
+ config={"callbacks": [AgentAsyncHandler(self.log), *self._get_shared_callbacks()]},
197
251
  version="v2",
198
252
  ),
199
253
  agent_message,
200
254
  cast("SendMessageFunctionType", self.send_message),
255
+ on_token_callback,
201
256
  )
202
257
  except ExceptionWithMessageError as e:
203
258
  if hasattr(e, "agent_message") and hasattr(e.agent_message, "id"):
@@ -269,15 +324,40 @@ class LCToolsAgentComponent(LCAgentComponent):
269
324
  tools_names = ", ".join([tool.name for tool in self.tools])
270
325
  return tools_names
271
326
 
327
+ # Set shared callbacks for tracing
328
+ def set_tools_callbacks(self, tools_list: list[Tool], callbacks_list: list[BaseCallbackHandler]):
329
+ """Set shared callbacks for tracing to the tools.
330
+
331
+ If we do not pass down the same callbacks to each tool
332
+ used by the agent, then each tool will instantiate a new callback.
333
+ For some tracing services, this will cause
334
+ the callback handler to lose the id of its parent run (Agent)
335
+ and thus throw an error in the tracing service client.
336
+
337
+ Args:
338
+ tools_list: list of tools to set the callbacks for
339
+ callbacks_list: list of callbacks to set for the tools
340
+ Returns:
341
+ None
342
+ """
343
+ for tool in tools_list or []:
344
+ if hasattr(tool, "callbacks"):
345
+ tool.callbacks = callbacks_list
346
+
272
347
  async def _get_tools(self) -> list[Tool]:
273
348
  component_toolkit = _get_component_toolkit()
274
349
  tools_names = self._build_tools_names()
275
350
  agent_description = self.get_tool_description()
276
351
  # TODO: Agent Description Depreciated Feature to be removed
277
352
  description = f"{agent_description}{tools_names}"
353
+
278
354
  tools = component_toolkit(component=self).get_tools(
279
- tool_name=self.get_tool_name(), tool_description=description, callbacks=self.get_langchain_callbacks()
355
+ tool_name=self.get_tool_name(),
356
+ tool_description=description,
357
+ # here we do not use the shared callbacks as we are exposing the agent as a tool
358
+ callbacks=self.get_langchain_callbacks(),
280
359
  )
281
360
  if hasattr(self, "tools_metadata"):
282
361
  tools = component_toolkit(component=self, metadata=self.tools_metadata).update_tools_metadata(tools=tools)
362
+
283
363
  return tools
lfx/base/agents/events.py CHANGED
@@ -1,4 +1,5 @@
1
1
  # Add helper functions for each event type
2
+ import asyncio
2
3
  from collections.abc import AsyncIterator
3
4
  from time import perf_counter
4
5
  from typing import Any, Protocol
@@ -9,7 +10,7 @@ from typing_extensions import TypedDict
9
10
 
10
11
  from lfx.schema.content_block import ContentBlock
11
12
  from lfx.schema.content_types import TextContent, ToolContent
12
- from lfx.schema.log import SendMessageFunctionType
13
+ from lfx.schema.log import OnTokenFunctionType, SendMessageFunctionType
13
14
  from lfx.schema.message import Message
14
15
 
15
16
 
@@ -53,7 +54,14 @@ def _calculate_duration(start_time: float) -> int:
53
54
 
54
55
 
55
56
  async def handle_on_chain_start(
56
- event: dict[str, Any], agent_message: Message, send_message_method: SendMessageFunctionType, start_time: float
57
+ event: dict[str, Any],
58
+ agent_message: Message,
59
+ send_message_callback: SendMessageFunctionType,
60
+ send_token_callback: OnTokenFunctionType | None, # noqa: ARG001
61
+ start_time: float,
62
+ *,
63
+ had_streaming: bool = False, # noqa: ARG001
64
+ message_id: str | None = None, # noqa: ARG001
57
65
  ) -> tuple[Message, float]:
58
66
  # Create content blocks if they don't exist
59
67
  if not agent_message.content_blocks:
@@ -80,7 +88,7 @@ async def handle_on_chain_start(
80
88
  header={"title": "Input", "icon": "MessageSquare"},
81
89
  )
82
90
  agent_message.content_blocks[0].contents.append(text_content)
83
- agent_message = await send_message_method(message=agent_message, skip_db_update=True)
91
+ agent_message = await send_message_callback(message=agent_message, skip_db_update=True)
84
92
  start_time = perf_counter()
85
93
  return agent_message, start_time
86
94
 
@@ -101,15 +109,23 @@ def _extract_output_text(output: str | list) -> str:
101
109
  if isinstance(item, dict):
102
110
  if "text" in item:
103
111
  return item["text"] or ""
104
- # If the item's type is "tool_use", return an empty string.
105
- if item.get("type") == "tool_use":
106
- return ""
107
- # Handle items with only 'index' key (from ChatBedrockConverse)
108
- if "index" in item and len(item) == 1:
109
- return ""
110
- # This is a workaround to deal with function calling by Anthropic
111
- if "partial_json" in item:
112
+ if "content" in item:
113
+ return str(item["content"])
114
+ if "message" in item:
115
+ return str(item["message"])
116
+
117
+ # Special case handling for non-text-like dicts
118
+ if (
119
+ item.get("type") == "tool_use" # Handle tool use items
120
+ or ("index" in item and len(item) == 1) # Handle index-only items
121
+ or "partial_json" in item # Handle partial json items
122
+ # Handle index-only items
123
+ or ("index" in item and not any(k in item for k in ("text", "content", "message")))
124
+ # Handle other metadata-only chunks that don't contain meaningful text
125
+ or not any(key in item for key in ["text", "content", "message"])
126
+ ):
112
127
  return ""
128
+
113
129
  # For any other dict format, return empty string
114
130
  return ""
115
131
  # For any other single item type (not str or dict), return empty string
@@ -133,7 +149,14 @@ def _extract_output_text(output: str | list) -> str:
133
149
 
134
150
 
135
151
  async def handle_on_chain_end(
136
- event: dict[str, Any], agent_message: Message, send_message_method: SendMessageFunctionType, start_time: float
152
+ event: dict[str, Any],
153
+ agent_message: Message,
154
+ send_message_callback: SendMessageFunctionType,
155
+ send_token_callback: OnTokenFunctionType | None, # noqa: ARG001
156
+ start_time: float,
157
+ *,
158
+ had_streaming: bool = False,
159
+ message_id: str | None = None, # noqa: ARG001
137
160
  ) -> tuple[Message, float]:
138
161
  data_output = event["data"].get("output")
139
162
  if data_output and isinstance(data_output, AgentFinish) and data_output.return_values.get("output"):
@@ -151,7 +174,11 @@ async def handle_on_chain_end(
151
174
  header={"title": "Output", "icon": "MessageSquare"},
152
175
  )
153
176
  agent_message.content_blocks[0].contents.append(text_content)
154
- agent_message = await send_message_method(message=agent_message, skip_db_update=True)
177
+
178
+ # Only send final message if we didn't have streaming chunks
179
+ # If we had streaming, frontend already accumulated the chunks
180
+ if not had_streaming:
181
+ agent_message = await send_message_callback(message=agent_message)
155
182
  start_time = perf_counter()
156
183
  return agent_message, start_time
157
184
 
@@ -160,7 +187,7 @@ async def handle_on_tool_start(
160
187
  event: dict[str, Any],
161
188
  agent_message: Message,
162
189
  tool_blocks_map: dict[str, ToolContent],
163
- send_message_method: SendMessageFunctionType,
190
+ send_message_callback: SendMessageFunctionType,
164
191
  start_time: float,
165
192
  ) -> tuple[Message, float]:
166
193
  tool_name = event["name"]
@@ -190,7 +217,7 @@ async def handle_on_tool_start(
190
217
  tool_blocks_map[tool_key] = tool_content
191
218
  agent_message.content_blocks[0].contents.append(tool_content)
192
219
 
193
- agent_message = await send_message_method(message=agent_message, skip_db_update=True)
220
+ agent_message = await send_message_callback(message=agent_message, skip_db_update=True)
194
221
  if agent_message.content_blocks and agent_message.content_blocks[0].contents:
195
222
  tool_blocks_map[tool_key] = agent_message.content_blocks[0].contents[-1]
196
223
  return agent_message, new_start_time
@@ -200,7 +227,7 @@ async def handle_on_tool_end(
200
227
  event: dict[str, Any],
201
228
  agent_message: Message,
202
229
  tool_blocks_map: dict[str, ToolContent],
203
- send_message_method: SendMessageFunctionType,
230
+ send_message_callback: SendMessageFunctionType,
204
231
  start_time: float,
205
232
  ) -> tuple[Message, float]:
206
233
  run_id = event.get("run_id", "")
@@ -209,8 +236,8 @@ async def handle_on_tool_end(
209
236
  tool_content = tool_blocks_map.get(tool_key)
210
237
 
211
238
  if tool_content and isinstance(tool_content, ToolContent):
212
- # Call send_message_method first to get the updated message structure
213
- agent_message = await send_message_method(message=agent_message, skip_db_update=True)
239
+ # Call send_message_callback first to get the updated message structure
240
+ agent_message = await send_message_callback(message=agent_message, skip_db_update=True)
214
241
  new_start_time = perf_counter()
215
242
 
216
243
  # Now find and update the tool content in the current message
@@ -246,7 +273,7 @@ async def handle_on_tool_error(
246
273
  event: dict[str, Any],
247
274
  agent_message: Message,
248
275
  tool_blocks_map: dict[str, ToolContent],
249
- send_message_method: SendMessageFunctionType,
276
+ send_message_callback: SendMessageFunctionType,
250
277
  start_time: float,
251
278
  ) -> tuple[Message, float]:
252
279
  run_id = event.get("run_id", "")
@@ -258,7 +285,7 @@ async def handle_on_tool_error(
258
285
  tool_content.error = event["data"].get("error", "Unknown error")
259
286
  tool_content.duration = _calculate_duration(start_time)
260
287
  tool_content.header = {"title": f"Error using **{tool_content.name}**", "icon": "Hammer"}
261
- agent_message = await send_message_method(message=agent_message, skip_db_update=True)
288
+ agent_message = await send_message_callback(message=agent_message, skip_db_update=True)
262
289
  start_time = perf_counter()
263
290
  return agent_message, start_time
264
291
 
@@ -266,8 +293,12 @@ async def handle_on_tool_error(
266
293
  async def handle_on_chain_stream(
267
294
  event: dict[str, Any],
268
295
  agent_message: Message,
269
- send_message_method: SendMessageFunctionType,
296
+ send_message_callback: SendMessageFunctionType, # noqa: ARG001
297
+ send_token_callback: OnTokenFunctionType | None,
270
298
  start_time: float,
299
+ *,
300
+ had_streaming: bool = False, # noqa: ARG001
301
+ message_id: str | None = None,
271
302
  ) -> tuple[Message, float]:
272
303
  data_chunk = event["data"].get("chunk", {})
273
304
  if isinstance(data_chunk, dict) and data_chunk.get("output"):
@@ -275,15 +306,26 @@ async def handle_on_chain_stream(
275
306
  if output and isinstance(output, str | list):
276
307
  agent_message.text = _extract_output_text(output)
277
308
  agent_message.properties.state = "complete"
278
- agent_message = await send_message_method(message=agent_message, skip_db_update=True)
309
+ # Don't call send_message_callback here - we must update in place
310
+ # in order to keep the message id consistent throughout the stream.
311
+ # The final message will be sent after the loop completes
279
312
  start_time = perf_counter()
280
313
  elif isinstance(data_chunk, AIMessageChunk):
281
314
  output_text = _extract_output_text(data_chunk.content)
282
- if output_text and isinstance(agent_message.text, str):
283
- agent_message.text += output_text
284
- agent_message.properties.state = "partial"
285
- agent_message = await send_message_method(message=agent_message, skip_db_update=True)
315
+ # For streaming, send token event if callback is available
316
+ # Note: we should expect the callback, but we keep it optional for backwards compatibility
317
+ # as of v1.6.5
318
+ if output_text and output_text.strip() and send_token_callback and message_id:
319
+ await asyncio.to_thread(
320
+ send_token_callback,
321
+ data={
322
+ "chunk": output_text,
323
+ "id": str(message_id),
324
+ },
325
+ )
326
+
286
327
  if not agent_message.text:
328
+ # Starts the timer when the first message is starting to be generated
287
329
  start_time = perf_counter()
288
330
  return agent_message, start_time
289
331
 
@@ -294,7 +336,7 @@ class ToolEventHandler(Protocol):
294
336
  event: dict[str, Any],
295
337
  agent_message: Message,
296
338
  tool_blocks_map: dict[str, ContentBlock],
297
- send_message_method: SendMessageFunctionType,
339
+ send_message_callback: SendMessageFunctionType,
298
340
  start_time: float,
299
341
  ) -> tuple[Message, float]: ...
300
342
 
@@ -304,8 +346,12 @@ class ChainEventHandler(Protocol):
304
346
  self,
305
347
  event: dict[str, Any],
306
348
  agent_message: Message,
307
- send_message_method: SendMessageFunctionType,
349
+ send_message_callback: SendMessageFunctionType,
350
+ send_token_callback: OnTokenFunctionType | None,
308
351
  start_time: float,
352
+ *,
353
+ had_streaming: bool = False,
354
+ message_id: str | None = None,
309
355
  ) -> tuple[Message, float]: ...
310
356
 
311
357
 
@@ -329,7 +375,8 @@ TOOL_EVENT_HANDLERS: dict[str, ToolEventHandler] = {
329
375
  async def process_agent_events(
330
376
  agent_executor: AsyncIterator[dict[str, Any]],
331
377
  agent_message: Message,
332
- send_message_method: SendMessageFunctionType,
378
+ send_message_callback: SendMessageFunctionType,
379
+ send_token_callback: OnTokenFunctionType | None = None,
333
380
  ) -> Message:
334
381
  """Process agent events and return the final output."""
335
382
  if isinstance(agent_message.properties, dict):
@@ -337,26 +384,46 @@ async def process_agent_events(
337
384
  else:
338
385
  agent_message.properties.icon = "Bot"
339
386
  agent_message.properties.state = "partial"
340
- # Store the initial message
341
- agent_message = await send_message_method(message=agent_message)
387
+ # Store the initial message and capture the message id
388
+ agent_message = await send_message_callback(message=agent_message)
389
+ # Capture the original message id - this must stay consistent throughout if streaming
390
+ initial_message_id = agent_message.id
342
391
  try:
343
392
  # Create a mapping of run_ids to tool contents
344
393
  tool_blocks_map: dict[str, ToolContent] = {}
394
+ had_streaming = False
345
395
  start_time = perf_counter()
396
+
346
397
  async for event in agent_executor:
347
398
  if event["event"] in TOOL_EVENT_HANDLERS:
348
399
  tool_handler = TOOL_EVENT_HANDLERS[event["event"]]
349
400
  # Use skip_db_update=True during streaming to avoid DB round-trips
350
401
  agent_message, start_time = await tool_handler(
351
- event, agent_message, tool_blocks_map, send_message_method, start_time
402
+ event, agent_message, tool_blocks_map, send_message_callback, start_time
352
403
  )
353
404
  elif event["event"] in CHAIN_EVENT_HANDLERS:
354
405
  chain_handler = CHAIN_EVENT_HANDLERS[event["event"]]
355
- # Use skip_db_update=True during streaming to avoid DB round-trips
356
- agent_message, start_time = await chain_handler(event, agent_message, send_message_method, start_time)
406
+
407
+ # Check if this is a streaming event
408
+ if event["event"] in ("on_chain_stream", "on_chat_model_stream"):
409
+ had_streaming = True
410
+ agent_message, start_time = await chain_handler(
411
+ event,
412
+ agent_message,
413
+ send_message_callback,
414
+ send_token_callback,
415
+ start_time,
416
+ had_streaming=had_streaming,
417
+ message_id=initial_message_id,
418
+ )
419
+ else:
420
+ agent_message, start_time = await chain_handler(
421
+ event, agent_message, send_message_callback, None, start_time, had_streaming=had_streaming
422
+ )
423
+
357
424
  agent_message.properties.state = "complete"
358
425
  # Final DB update with the complete message (skip_db_update=False by default)
359
- agent_message = await send_message_method(message=agent_message)
426
+ agent_message = await send_message_callback(message=agent_message)
360
427
  except Exception as e:
361
428
  raise ExceptionWithMessageError(agent_message, str(e)) from e
362
429
  return await Message.create(**agent_message.model_dump())
lfx/base/agents/utils.py CHANGED
@@ -47,9 +47,22 @@ def data_to_messages(data: list[Data | Message]) -> list[BaseMessage]:
47
47
  data (List[Data | Message]): The data to convert.
48
48
 
49
49
  Returns:
50
- List[BaseMessage]: The data as messages.
50
+ List[BaseMessage]: The data as messages, filtering out any with empty content.
51
51
  """
52
- return [value.to_lc_message() for value in data]
52
+ messages = []
53
+ for value in data:
54
+ try:
55
+ lc_message = value.to_lc_message()
56
+ # Only add messages with non-empty content (prevents Anthropic API errors)
57
+ content = lc_message.content
58
+ if content and ((isinstance(content, str) and content.strip()) or (isinstance(content, list) and content)):
59
+ messages.append(lc_message)
60
+ else:
61
+ logger.warning("Skipping message with empty content in chat history")
62
+ except (ValueError, AttributeError) as e:
63
+ logger.warning(f"Failed to convert message to BaseMessage: {e}")
64
+ continue
65
+ return messages
53
66
 
54
67
 
55
68
  def validate_and_create_xml_agent(
@@ -284,6 +284,21 @@ class ComposioBaseComponent(Component):
284
284
  # Track all auth field names discovered across all toolkits
285
285
  _all_auth_field_names: set[str] = set()
286
286
 
287
+ @classmethod
288
+ def get_actions_cache(cls) -> dict[str, dict[str, Any]]:
289
+ """Get the class-level actions cache."""
290
+ return cls._actions_cache
291
+
292
+ @classmethod
293
+ def get_action_schema_cache(cls) -> dict[str, dict[str, Any]]:
294
+ """Get the class-level action schema cache."""
295
+ return cls._action_schema_cache
296
+
297
+ @classmethod
298
+ def get_all_auth_field_names(cls) -> set[str]:
299
+ """Get all auth field names discovered across toolkits."""
300
+ return cls._all_auth_field_names
301
+
287
302
  outputs = [
288
303
  Output(name="dataFrame", display_name="DataFrame", method="as_dataframe"),
289
304
  ]
@@ -403,11 +418,11 @@ class ComposioBaseComponent(Component):
403
418
 
404
419
  # Try to load from the class-level cache
405
420
  toolkit_slug = self.app_name.lower()
406
- if toolkit_slug in self.__class__._actions_cache:
421
+ if toolkit_slug in self.__class__.get_actions_cache():
407
422
  # Deep-copy so that any mutation on this instance does not affect the
408
423
  # cached master copy.
409
- self._actions_data = copy.deepcopy(self.__class__._actions_cache[toolkit_slug])
410
- self._action_schemas = copy.deepcopy(self.__class__._action_schema_cache.get(toolkit_slug, {}))
424
+ self._actions_data = copy.deepcopy(self.__class__.get_actions_cache()[toolkit_slug])
425
+ self._action_schemas = copy.deepcopy(self.__class__.get_action_schema_cache().get(toolkit_slug, {}))
411
426
  logger.debug(f"Loaded actions for {toolkit_slug} from in-process cache")
412
427
  return
413
428
 
@@ -630,8 +645,8 @@ class ComposioBaseComponent(Component):
630
645
 
631
646
  # Cache actions for this toolkit so subsequent component instances
632
647
  # can reuse them without hitting the Composio API again.
633
- self.__class__._actions_cache[toolkit_slug] = copy.deepcopy(self._actions_data)
634
- self.__class__._action_schema_cache[toolkit_slug] = copy.deepcopy(self._action_schemas)
648
+ self.__class__.get_actions_cache()[toolkit_slug] = copy.deepcopy(self._actions_data)
649
+ self.__class__.get_action_schema_cache()[toolkit_slug] = copy.deepcopy(self._action_schemas)
635
650
 
636
651
  except ValueError as e:
637
652
  logger.debug(f"Could not populate Composio actions for {self.app_name}: {e}")
@@ -1313,7 +1328,7 @@ class ComposioBaseComponent(Component):
1313
1328
 
1314
1329
  self._auth_dynamic_fields.add(name)
1315
1330
  # Also add to class-level cache for better tracking
1316
- self.__class__._all_auth_field_names.add(name)
1331
+ self.__class__.get_all_auth_field_names().add(name)
1317
1332
 
1318
1333
  def _render_custom_auth_fields(self, build_config: dict, schema: dict[str, Any], mode: str) -> None:
1319
1334
  """Render fields for custom auth based on schema auth_config_details sections."""
@@ -1378,7 +1393,7 @@ class ComposioBaseComponent(Component):
1378
1393
  if name:
1379
1394
  names.add(name)
1380
1395
  # Add to class-level cache for tracking all discovered auth fields
1381
- self.__class__._all_auth_field_names.add(name)
1396
+ self.__class__.get_all_auth_field_names().add(name)
1382
1397
  # Only use names discovered from the toolkit schema; do not add aliases
1383
1398
  return names
1384
1399
 
@@ -1443,7 +1458,7 @@ class ComposioBaseComponent(Component):
1443
1458
  # Check if we need to populate actions - but also check cache availability
1444
1459
  actions_available = bool(self._actions_data)
1445
1460
  toolkit_slug = getattr(self, "app_name", "").lower()
1446
- cached_actions_available = toolkit_slug in self.__class__._actions_cache
1461
+ cached_actions_available = toolkit_slug in self.__class__.get_actions_cache()
1447
1462
 
1448
1463
  should_populate = False
1449
1464
 
@@ -2623,7 +2638,7 @@ class ComposioBaseComponent(Component):
2623
2638
  # Add all dynamic auth fields to protected set
2624
2639
  protected.update(self._auth_dynamic_fields)
2625
2640
  # Also protect any auth fields discovered across all instances
2626
- protected.update(self.__class__._all_auth_field_names)
2641
+ protected.update(self.__class__.get_all_auth_field_names())
2627
2642
 
2628
2643
  for key, cfg in list(build_config.items()):
2629
2644
  if key in protected:
@@ -0,0 +1,5 @@
1
+ from .astradb_base import AstraDBBaseComponent
2
+
3
+ __all__ = [
4
+ "AstraDBBaseComponent",
5
+ ]