diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d44d108b..aae14f431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `DailyTransport.stop_transcription()` to be able to start and stop Daily transcription dynamically (maybe with different settings). +### Fixed + +- In `AWSBedrockLLMService`, worked around a possible bug in AWS Bedrock where + a `toolConfig` is required if there has been previous tool use in the + messages array. This workaround includes a no_op factory function call is + used to satisfy the requirement. + ## [0.0.68] - 2025-05-28 ### Added diff --git a/src/pipecat/services/aws/llm.py b/src/pipecat/services/aws/llm.py index 9ec617678..a90620b20 100644 --- a/src/pipecat/services/aws/llm.py +++ b/src/pipecat/services/aws/llm.py @@ -606,6 +606,21 @@ class AWSBedrockLLMService(LLMService): assistant = AWSBedrockAssistantContextAggregator(context, params=assistant_params) return AWSBedrockContextAggregatorPair(_user=user, _assistant=assistant) + def _create_no_op_tool(self): + """Create a no-operation tool for AWS Bedrock when tool content exists but no tools are defined. + + This is required because AWS Bedrock doesn't allow empty tool configurations after tools were + previously set. Other LLM vendors allow NOT_GIVEN or empty tool configurations, + but AWS Bedrock requires at least one tool to be defined. + """ + return { + "toolSpec": { + "name": "no_operation", + "description": "Internal placeholder function. Do not call this function.", + "inputSchema": {"json": {"type": "object", "properties": {}, "required": []}}, + } + } + @traced_llm async def _process_context(self, context: AWSBedrockLLMContext): # Usage tracking @@ -616,6 +631,8 @@ class AWSBedrockLLMService(LLMService): cache_creation_input_tokens = 0 use_completion_tokens_estimate = False + using_noop_tool = False + try: await self.push_frame(LLMFullResponseStartFrame()) await self.start_processing_metrics() @@ -640,12 +657,28 @@ class AWSBedrockLLMService(LLMService): # Add system message request_params["system"] = context.system - # Add tools if present - if context.tools: - tool_config = {"tools": context.tools} + # Check if messages contain tool use or tool result content blocks + has_tool_content = False + for message in context.messages: + if isinstance(message.get("content"), list): + for content_item in message["content"]: + if "toolUse" in content_item or "toolResult" in content_item: + has_tool_content = True + break + if has_tool_content: + break - # Add tool_choice if specified - if context.tool_choice: + # Handle tools: use current tools, or no-op if tool content exists but no current tools + tools = context.tools or [] + if has_tool_content and not tools: + tools = [self._create_no_op_tool()] + using_noop_tool = True + + if tools: + tool_config = {"tools": tools} + + # Only add tool_choice if we have real tools (not just no-op) + if not using_noop_tool and context.tool_choice: if context.tool_choice == "auto": tool_config["toolChoice"] = {"auto": {}} elif context.tool_choice == "none": @@ -704,12 +737,17 @@ class AWSBedrockLLMService(LLMService): if event["messageStop"]["stopReason"] == "tool_use" and tool_use_block: try: arguments = json.loads(json_accumulator) if json_accumulator else {} - await self.call_function( - context=context, - tool_call_id=tool_use_block["id"], - function_name=tool_use_block["name"], - arguments=arguments, - ) + + # Only call function if it's not the no_operation tool + if not using_noop_tool: + await self.call_function( + context=context, + tool_call_id=tool_use_block["id"], + function_name=tool_use_block["name"], + arguments=arguments, + ) + else: + logger.debug("Ignoring no_operation tool call") except json.JSONDecodeError: logger.error(f"Failed to parse tool arguments: {json_accumulator}")