Mitigate tool-call-related hallucination
When tools change mid-conversation, LLMs can produce a few different flavors of tool-call-related hallucination: calling tools that have been removed, avoiding tools that have been re-added, or hallucinating output (made-up answers or tool-call-shaped non-tool-calls) when tools are unavailable. This change introduces an opt-in ``add_tool_change_messages`` flag on the LLM aggregators (preferred entry point: ``LLMContextAggregatorPair( ..., add_tool_change_messages=True)``) that appends a developer-role message to the context whenever ``LLMSetToolsFrame`` changes the set of advertised standard tools. Helps the LLM stay coherent across tool changes by spelling out exactly what just became available or unavailable. Both aggregators participate; whichever handles the frame first wins, and the other (if any) sees an empty diff against the shared context and stays silent — order-independent regardless of whether the frame flows downstream or upstream. Also tightens the existing missing-handler path (introduced in #4301): - Reworded the terminal tool result to a neutral "The function ``X`` is not currently available." (overridable via ``LLMService.MISSING_FUNCTION_CALL_MESSAGE_TEMPLATE``). Previously read "Error: function 'X' is not registered." - Logs at the call site now distinguish developer error (tool advertised but no handler registered → ``logger.error``) from hallucination (tool not advertised → ``logger.warning``). Includes a manual validation harness (``examples/features/features-add-tool-change-messages.py``) that exercises the new ``add_tool_change_messages`` mitigation by flipping tool availability on a turn counter so its effect can be observed end-to-end with the flag on vs. off.
This commit is contained in:
@@ -72,6 +72,7 @@ from pipecat.processors.aggregators.llm_context import (
|
||||
LLMContextMessage,
|
||||
LLMSpecificMessage,
|
||||
NotGiven,
|
||||
is_given,
|
||||
)
|
||||
from pipecat.processors.aggregators.llm_context_summarizer import (
|
||||
LLMContextSummarizer,
|
||||
@@ -118,6 +119,18 @@ class LLMUserAggregatorParams:
|
||||
user_turn_completion_config: Configuration for turn completion behavior including
|
||||
custom instructions, timeouts, and prompts. Only used when
|
||||
filter_incomplete_user_turns is True.
|
||||
add_tool_change_messages: When True, on each ``LLMSetToolsFrame`` the
|
||||
aggregator computes the diff against the currently advertised tools
|
||||
and appends a developer-role message to the context describing
|
||||
additions/removals. Helps the LLM stay coherent across
|
||||
mid-conversation tool changes, mitigating several flavors of
|
||||
tool-call-related hallucination: calling tools that have been
|
||||
removed, avoiding tools that have been re-added, and hallucinating
|
||||
output (made-up answers or tool-call-shaped non-tool-calls) when
|
||||
tools are unavailable. Only standard tools are diffed; custom
|
||||
(LLM-specific) tools are ignored. When using
|
||||
``LLMContextAggregatorPair``, prefer setting this via its
|
||||
``add_tool_change_messages`` argument instead. Defaults to False.
|
||||
"""
|
||||
|
||||
user_turn_strategies: UserTurnStrategies | None = None
|
||||
@@ -128,6 +141,7 @@ class LLMUserAggregatorParams:
|
||||
audio_idle_timeout: float = 1.0
|
||||
filter_incomplete_user_turns: bool = False
|
||||
user_turn_completion_config: UserTurnCompletionConfig | None = None
|
||||
add_tool_change_messages: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -143,10 +157,23 @@ class LLMAssistantAggregatorParams:
|
||||
summarization. Controls trigger thresholds, message preservation, and
|
||||
summarization prompts. If None, uses default
|
||||
``LLMAutoContextSummarizationConfig`` values.
|
||||
add_tool_change_messages: When True, on each ``LLMSetToolsFrame`` the
|
||||
aggregator computes the diff against the currently advertised tools
|
||||
and appends a developer-role message to the context describing
|
||||
additions/removals. Helps the LLM stay coherent across
|
||||
mid-conversation tool changes, mitigating several flavors of
|
||||
tool-call-related hallucination: calling tools that have been
|
||||
removed, avoiding tools that have been re-added, and hallucinating
|
||||
output (made-up answers or tool-call-shaped non-tool-calls) when
|
||||
tools are unavailable. Only standard tools are diffed; custom
|
||||
(LLM-specific) tools are ignored. When using
|
||||
``LLMContextAggregatorPair``, prefer setting this via its
|
||||
``add_tool_change_messages`` argument instead. Defaults to False.
|
||||
"""
|
||||
|
||||
enable_auto_context_summarization: bool = False
|
||||
auto_context_summarization_config: LLMAutoContextSummarizationConfig | None = None
|
||||
add_tool_change_messages: bool = False
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deprecated field names — kept for backward compatibility.
|
||||
@@ -248,20 +275,87 @@ class LLMContextAggregator(FrameProcessor):
|
||||
common functionality for context-based conversation management.
|
||||
"""
|
||||
|
||||
def __init__(self, *, context: LLMContext, role: str, **kwargs):
|
||||
# Developer-role messages appended to the context when tools are added/
|
||||
# removed via ``LLMSetToolsFrame`` (only when ``add_tool_change_messages``
|
||||
# is enabled on the aggregator's params). ``{function_names}`` is
|
||||
# substituted with a sorted, comma-separated, backtick-wrapped list.
|
||||
TOOL_ACTIVATION_MESSAGE_TEMPLATE = (
|
||||
"The following function(s) have just been added and may now be called: "
|
||||
"{function_names}. Any previously available functions remain available."
|
||||
)
|
||||
TOOL_DEACTIVATION_MESSAGE_TEMPLATE = (
|
||||
"The following function(s) have just been removed and should not be called: "
|
||||
"{function_names}. Any previously available functions remain available. "
|
||||
"The removed function(s) may become available again later, in which case "
|
||||
"you will be informed."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
context: LLMContext,
|
||||
role: str,
|
||||
add_tool_change_messages: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the context response aggregator.
|
||||
|
||||
Args:
|
||||
context: The LLM context to use for conversation storage.
|
||||
role: The role this aggregator represents (e.g. "user", "assistant").
|
||||
add_tool_change_messages: See the field of the same name on the
|
||||
aggregator-specific params dataclasses. Subclasses propagate
|
||||
this from their ``params``.
|
||||
**kwargs: Additional arguments passed to parent class.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._context = context
|
||||
self._role = role
|
||||
self._add_tool_change_messages = add_tool_change_messages
|
||||
|
||||
self._aggregation: list[TextPartForConcatenation] = []
|
||||
|
||||
def _maybe_add_tool_change_messages(self, new_tools: ToolsSchema | NotGiven) -> None:
|
||||
"""Append a developer message describing tool add/remove deltas.
|
||||
|
||||
No-op unless ``add_tool_change_messages`` was enabled on the aggregator,
|
||||
and no-op when the diff against the currently advertised tools is empty.
|
||||
Custom (LLM-specific) tools are ignored — only standard tools are diffed.
|
||||
|
||||
Both aggregators call this on every ``LLMSetToolsFrame`` they handle.
|
||||
Whichever aggregator handles the frame first computes a real diff
|
||||
against the shared context and adds the announcement; by the time
|
||||
the other aggregator sees it (if at all), the context already
|
||||
reflects the new tools, so its diff is empty and no duplicate
|
||||
message is added. This is order-independent: it works whether the
|
||||
frame flows downstream (user aggregator first) or upstream
|
||||
(assistant aggregator first, and consumed without being forwarded).
|
||||
"""
|
||||
if not self._add_tool_change_messages:
|
||||
return
|
||||
|
||||
def _names(tools: ToolsSchema | NotGiven) -> set[str]:
|
||||
if not is_given(tools):
|
||||
return set()
|
||||
return {s.name for s in tools.standard_tools}
|
||||
|
||||
old_names = _names(self._context.tools)
|
||||
new_names = _names(new_tools)
|
||||
added = new_names - old_names
|
||||
removed = old_names - new_names
|
||||
if not added and not removed:
|
||||
return
|
||||
|
||||
parts: list[str] = []
|
||||
if added:
|
||||
names = ", ".join(f"`{n}`" for n in sorted(added))
|
||||
parts.append(self.TOOL_ACTIVATION_MESSAGE_TEMPLATE.format(function_names=names))
|
||||
if removed:
|
||||
names = ", ".join(f"`{n}`" for n in sorted(removed))
|
||||
parts.append(self.TOOL_DEACTIVATION_MESSAGE_TEMPLATE.format(function_names=names))
|
||||
|
||||
self._context.add_message({"role": "developer", "content": " ".join(parts)})
|
||||
|
||||
@property
|
||||
def messages(self) -> list[LLMContextMessage]:
|
||||
"""Get messages from the LLM context.
|
||||
@@ -434,8 +528,14 @@ class LLMUserAggregator(LLMContextAggregator):
|
||||
params: Configuration parameters for aggregation behavior.
|
||||
**kwargs: Additional arguments.
|
||||
"""
|
||||
super().__init__(context=context, role="user", **kwargs)
|
||||
self._params = params or LLMUserAggregatorParams()
|
||||
params = params or LLMUserAggregatorParams()
|
||||
super().__init__(
|
||||
context=context,
|
||||
role="user",
|
||||
add_tool_change_messages=params.add_tool_change_messages,
|
||||
**kwargs,
|
||||
)
|
||||
self._params = params
|
||||
|
||||
self._register_event_handler("on_user_turn_started")
|
||||
self._register_event_handler("on_user_turn_stopped")
|
||||
@@ -536,6 +636,7 @@ class LLMUserAggregator(LLMContextAggregator):
|
||||
elif isinstance(frame, LLMMessagesTransformFrame):
|
||||
await self._handle_llm_messages_transform(frame)
|
||||
elif isinstance(frame, LLMSetToolsFrame):
|
||||
self._maybe_add_tool_change_messages(frame.tools)
|
||||
self.set_tools(frame.tools)
|
||||
# Push the LLMSetToolsFrame as well, since speech-to-speech LLM
|
||||
# services (like OpenAI Realtime) may need to know about tool
|
||||
@@ -843,8 +944,14 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
||||
params: Configuration parameters for aggregation behavior.
|
||||
**kwargs: Additional arguments.
|
||||
"""
|
||||
super().__init__(context=context, role="assistant", **kwargs)
|
||||
self._params = params or LLMAssistantAggregatorParams()
|
||||
params = params or LLMAssistantAggregatorParams()
|
||||
super().__init__(
|
||||
context=context,
|
||||
role="assistant",
|
||||
add_tool_change_messages=params.add_tool_change_messages,
|
||||
**kwargs,
|
||||
)
|
||||
self._params = params
|
||||
|
||||
self._function_calls_in_progress: dict[str, FunctionCallInProgressFrame | None] = {}
|
||||
self._function_calls_image_results: dict[str, UserImageRawFrame] = {}
|
||||
@@ -949,6 +1056,7 @@ class LLMAssistantAggregator(LLMContextAggregator):
|
||||
elif isinstance(frame, LLMMessagesTransformFrame):
|
||||
await self._handle_llm_messages_transform(frame)
|
||||
elif isinstance(frame, LLMSetToolsFrame):
|
||||
self._maybe_add_tool_change_messages(frame.tools)
|
||||
self.set_tools(frame.tools)
|
||||
elif isinstance(frame, LLMSetToolChoiceFrame):
|
||||
self.set_tool_choice(frame.tool_choice)
|
||||
@@ -1478,6 +1586,7 @@ class LLMContextAggregatorPair:
|
||||
*,
|
||||
user_params: LLMUserAggregatorParams | None = None,
|
||||
assistant_params: LLMAssistantAggregatorParams | None = None,
|
||||
add_tool_change_messages: bool | None = None,
|
||||
):
|
||||
"""Initialize the LLM context aggregator pair.
|
||||
|
||||
@@ -1485,9 +1594,22 @@ class LLMContextAggregatorPair:
|
||||
context: The context to be managed by the aggregators.
|
||||
user_params: Parameters for the user context aggregator.
|
||||
assistant_params: Parameters for the assistant context aggregator.
|
||||
add_tool_change_messages: When provided, sets the field of the
|
||||
same name on both ``user_params`` and ``assistant_params``,
|
||||
overriding any value already set on either. This is the
|
||||
preferred way to enable tool-change announcements: it ensures
|
||||
both aggregators participate, which makes the feature robust
|
||||
regardless of which aggregator handles a given
|
||||
``LLMSetToolsFrame``. The shared context guarantees the
|
||||
announcement is added exactly once (the second aggregator's
|
||||
diff is empty by the time it sees the frame). Leave as
|
||||
``None`` to respect per-params settings.
|
||||
"""
|
||||
user_params = user_params or LLMUserAggregatorParams()
|
||||
assistant_params = assistant_params or LLMAssistantAggregatorParams()
|
||||
if add_tool_change_messages is not None:
|
||||
user_params.add_tool_change_messages = add_tool_change_messages
|
||||
assistant_params.add_tool_change_messages = add_tool_change_messages
|
||||
self._user = LLMUserAggregator(context, params=user_params)
|
||||
self._assistant = LLMAssistantAggregator(context, params=assistant_params)
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ from pipecat.frames.frames import (
|
||||
from pipecat.processors.aggregators.llm_context import (
|
||||
LLMContext,
|
||||
LLMSpecificMessage,
|
||||
is_given,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection
|
||||
from pipecat.services.ai_service import AIService
|
||||
@@ -243,6 +244,15 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService, Generic[TAdapter]
|
||||
# However, subclasses should override this with a more specific adapter when necessary.
|
||||
adapter_class: type[BaseLLMAdapter] = OpenAILLMAdapter
|
||||
|
||||
# Returned to the LLM as the tool result when an unavailable function is
|
||||
# called. Deliberately neutral about future availability so the LLM can
|
||||
# pick the function up again if it returns (e.g. via the
|
||||
# ``add_tool_change_messages`` activation message, or silently on a
|
||||
# later inference). ``{function_name}`` is substituted at runtime.
|
||||
MISSING_FUNCTION_CALL_MESSAGE_TEMPLATE = (
|
||||
"The function `{function_name}` is not currently available."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
run_in_parallel: bool = True,
|
||||
@@ -764,9 +774,7 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService, Generic[TAdapter]
|
||||
elif None in self._functions.keys():
|
||||
item = self._functions[None]
|
||||
else:
|
||||
logger.warning(
|
||||
f"{self} is calling '{function_call.function_name}', but it's not registered."
|
||||
)
|
||||
self._log_missing_function_call(function_call.function_name, function_call.context)
|
||||
item = self._build_missing_function_call_registry_item(function_call.function_name)
|
||||
|
||||
runner_items.append(
|
||||
@@ -835,8 +843,12 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService, Generic[TAdapter]
|
||||
elif runner_item.registry_item.handler == self._missing_function_call_handler:
|
||||
item = runner_item.registry_item
|
||||
else:
|
||||
# Function was unregistered between queue and execution; the
|
||||
# registry-item-handler check above already covered the
|
||||
# missing-from-the-start case.
|
||||
logger.warning(
|
||||
f"{self} is calling '{runner_item.function_name}', but it was just unregistered."
|
||||
f"{self}: '{runner_item.function_name}' was just unregistered "
|
||||
f"between queueing and execution."
|
||||
)
|
||||
item = self._build_missing_function_call_registry_item(runner_item.function_name)
|
||||
|
||||
@@ -962,7 +974,45 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService, Generic[TAdapter]
|
||||
|
||||
async def _missing_function_call_handler(self, params: FunctionCallParams):
|
||||
"""Return a terminal tool result when the LLM calls an unknown function."""
|
||||
await params.result_callback(f"Error: function '{params.function_name}' is not registered.")
|
||||
await params.result_callback(
|
||||
self.MISSING_FUNCTION_CALL_MESSAGE_TEMPLATE.format(function_name=params.function_name)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _advertised_tool_names(context) -> set[str]:
|
||||
"""Return the set of standard-tool names currently advertised to the LLM.
|
||||
|
||||
Custom (LLM-specific) tools are not included, since they have no
|
||||
consistent name field across adapters.
|
||||
"""
|
||||
tools = context.tools if context is not None else None
|
||||
if tools is None or not is_given(tools):
|
||||
return set()
|
||||
return {t.name for t in tools.standard_tools}
|
||||
|
||||
def _log_missing_function_call(self, function_name: str, context) -> None:
|
||||
"""Log an appropriate message when a tool is called with no handler.
|
||||
|
||||
Distinguishes two cases:
|
||||
|
||||
- **Developer error:** the tool is advertised to the LLM but no handler
|
||||
was registered (likely a missed ``register_function`` call). Logged
|
||||
at error level since this almost always indicates a bug.
|
||||
- **Hallucination:** the tool is not in the currently advertised tool
|
||||
set. Logged at warning level since this is model behavior the
|
||||
application can do little about beyond returning a terminal result.
|
||||
"""
|
||||
if function_name in self._advertised_tool_names(context):
|
||||
logger.error(
|
||||
f"{self}: tool '{function_name}' is advertised to the LLM "
|
||||
f"but has no registered handler — did you forget to call "
|
||||
f"register_function()?"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"{self}: LLM called '{function_name}', which is not in the "
|
||||
f"currently advertised tool set."
|
||||
)
|
||||
|
||||
def _has_async_tools(self) -> bool:
|
||||
"""Return True if at least one non-builtin async tool is registered."""
|
||||
|
||||
Reference in New Issue
Block a user