From a5addaf2abfb71e14d4c5b7994a02779687809be Mon Sep 17 00:00:00 2001 From: luijait Date: Sun, 11 May 2025 10:56:29 +0200 Subject: [PATCH] Fix message lists --- src/cai/cli.py | 73 ++++++- .../agents/models/openai_chatcompletions.py | 82 +++++++- src/cai/util.py | 183 +++++++++++++++++- 3 files changed, 333 insertions(+), 5 deletions(-) diff --git a/src/cai/cli.py b/src/cai/cli.py index b691a493..7027da7a 100644 --- a/src/cai/cli.py +++ b/src/cai/cli.py @@ -490,14 +490,85 @@ def run_cai_cli(starting_agent, context_variables=None, stream=False, max_turns= else: # Use non-streamed response response = asyncio.run(Runner.run(agent, conversation_input)) + + # Process the response items for item in response.new_items: + # Handle tool call output items (tool results) if isinstance(item, ToolCallOutputItem): + # First, ensure there's a corresponding assistant message with tool_calls + # before adding the tool response to prevent the OpenAI error + assistant_with_tool_call_exists = False + tool_call_id = item.raw_item["call_id"] + + for msg in message_history: + if (msg.get("role") == "assistant" and + msg.get("tool_calls") and + any(tc.get("id") == tool_call_id for tc in msg.get("tool_calls", []))): + assistant_with_tool_call_exists = True + break + + # If no matching assistant message exists, create one first + if not assistant_with_tool_call_exists: + tool_call_msg = { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": tool_call_id, + "type": "function", + "function": { + "name": "unknown_function", + "arguments": "{}" + } + }] + } + add_to_message_history(tool_call_msg) + + # Now add the tool response tool_msg = { "role": "tool", - "tool_call_id": item.raw_item["call_id"], # Use consistent format with streaming + "tool_call_id": tool_call_id, "content": item.output, } add_to_message_history(tool_msg) + + # Make sure that assistant messages with tool calls are also added to message_history + # This is especially important for non-streaming mode + if hasattr(agent, 'model'): + # Access the _Converter directly from the OpenAIChatCompletionsModel implementation + from cai.sdk.agents.models.openai_chatcompletions import _Converter + + # Check if recent_tool_calls exists and process them + if hasattr(_Converter, 'recent_tool_calls'): + for call_id, call_info in _Converter.recent_tool_calls.items(): + # Only process new tool calls that haven't been added to message history yet + tool_call_found = False + for msg in message_history: + if (msg.get("role") == "assistant" and + msg.get("tool_calls") and + any(tc.get("id") == call_id for tc in msg.get("tool_calls", []))): + tool_call_found = True + break + + if not tool_call_found: + # Add the assistant message with the tool call + tool_call_msg = { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": call_id, + "type": "function", + "function": { + "name": call_info.get('name', ''), + "arguments": call_info.get('arguments', '{}') + } + }] + } + add_to_message_history(tool_call_msg) + + # Final validation to ensure message history follows OpenAI's requirements + # Ensure every tool message has a preceding assistant message with matching tool_call_id + from cai.util import fix_message_list + message_history[:] = fix_message_list(message_history) turn_count += 1 # Stop measuring active time and start measuring idle time again diff --git a/src/cai/sdk/agents/models/openai_chatcompletions.py b/src/cai/sdk/agents/models/openai_chatcompletions.py index cd921ac9..14d51b8c 100644 --- a/src/cai/sdk/agents/models/openai_chatcompletions.py +++ b/src/cai/sdk/agents/models/openai_chatcompletions.py @@ -1433,10 +1433,17 @@ class OpenAIChatCompletionsModel(Model): if tracing.include_data(): span.span_data.input = converted_messages - # Ensure message list has correct structure regardless of error condition + # IMPORTANT: Always sanitize the message list to prevent tool call errors + # This is critical to fix common errors with tool/assistant sequences try: from cai.util import fix_message_list + prev_length = len(converted_messages) converted_messages = fix_message_list(converted_messages) + new_length = len(converted_messages) + + # Log if the message list was changed significantly + if new_length != prev_length: + logger.debug(f"Message list was fixed: {prev_length} -> {new_length} messages") except Exception as e: logger.warning(f"Failed to fix message list: {e}") @@ -1623,15 +1630,49 @@ class OpenAIChatCompletionsModel(Model): elif ("An assistant message with 'tool_calls'" in str(e) or "`tool_use` blocks must be followed by a user message with `tool_result`" in str(e) or # noqa: E501 # pylint: disable=C0301 - "An assistant message with 'tool_calls' must be followed by tool messages" in str(e)): # Añadir esta condición + "An assistant message with 'tool_calls' must be followed by tool messages" in str(e) or + "messages with role 'tool' must be a response to a preceeding message with 'tool_calls'" in str(e)): print(f"Error: {str(e)}") + + # Use the pretty message history printer instead of the simple loop + try: + from cai.util import print_message_history + print("\nCurrent message sequence causing the error:") + print_message_history(kwargs["messages"], title="Message Sequence Error") + except ImportError: + # Fall back to simple printing if the function isn't available + print("\nCurrent message sequence causing the error:") + for i, msg in enumerate(kwargs["messages"]): + role = msg.get("role", "unknown") + content_type = ( + "text" if isinstance(msg.get("content"), str) else + "list" if isinstance(msg.get("content"), list) else + "None" if msg.get("content") is None else + type(msg.get("content")).__name__ + ) + tool_calls = "with tool_calls" if msg.get("tool_calls") else "" + tool_call_id = f", tool_call_id: {msg.get('tool_call_id')}" if msg.get("tool_call_id") else "" + + print(f" [{i}] {role}{tool_call_id} (content: {content_type}) {tool_calls}") + # NOTE: EDGE CASE: Report Agent CTRL C error # # This fix CTRL-C error when message list is incomplete # When a tool is not finished but the LLM generates a tool call try: from cai.util import fix_message_list - kwargs["messages"] = fix_message_list(kwargs["messages"]) + print("Attempting to fix message sequence...") + fixed_messages = fix_message_list(kwargs["messages"]) + + # Show the fixed messages if they're different + if fixed_messages != kwargs["messages"]: + try: + from cai.util import print_message_history + print_message_history(fixed_messages, title="Fixed Message Sequence") + except ImportError: + print("Messages fixed successfully.") + + kwargs["messages"] = fixed_messages except Exception as fix_error: print(f"Failed to fix message sequence: {fix_error}") @@ -2446,6 +2487,41 @@ class _Converter: # Continue with normal processing flush_assistant_message() + + # CRITICAL: Verify this tool message has a matching assistant message before adding it + # Find if there's any assistant message with a tool call matching this ID + has_matching_assistant_message = False + for msg in result: + if ( + msg.get("role") == "assistant" and + msg.get("tool_calls") and + any(tc.get("id") == call_id for tc in msg.get("tool_calls", [])) + ): + has_matching_assistant_message = True + break + + # If no matching assistant message, create one + if not has_matching_assistant_message: + # Create a synthetic assistant message with this tool call + asst_msg = { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": tool_name, + "arguments": "{}" # Use empty object as default arguments + } + } + ] + } + # Add to result list + result.append(asst_msg) + logger.debug(f"Created synthetic assistant message for tool call {call_id}") + + # Now add the tool message msg: ChatCompletionToolMessageParam = { "role": "tool", "tool_call_id": func_output["call_id"], diff --git a/src/cai/util.py b/src/cai/util.py index 8941c661..1595b211 100644 --- a/src/cai/util.py +++ b/src/cai/util.py @@ -26,6 +26,7 @@ from rich.syntax import Syntax # Import Syntax for highlighting from rich.panel import Panel from rich.console import Group from rich.box import ROUNDED +from rich.table import Table # Global timing variables for tracking active and idle time _active_timer_start = None @@ -549,6 +550,8 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 4. There cannot be empty messages. 5. Each tool_use block (assistant with tool_calls) must be followed by a tool_result block (tool message with matching tool_call_id). + 6. Each 'tool' message must be immediately preceded by an 'assistant' message + with matching tool_call_id in its tool_calls. Args: messages (List[dict]): List of message dictionaries containing @@ -611,6 +614,101 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912 # Update mapping tool_call_map[tool_id] = {"assistant_idx": len(sanitized_messages) - 2, "tool_idx": len(sanitized_messages) - 1} + # Second pass - ensure correct sequence (tool messages must directly follow their assistant messages) + # This fixes the error "messages with role 'tool' must be a response to a preceeding message with 'tool_calls'" + i = 0 + while i < len(sanitized_messages): + msg = sanitized_messages[i] + + # Check if this is a tool message that might be out of sequence + if msg.get("role") == "tool" and msg.get("tool_call_id"): + tool_id = msg.get("tool_call_id") + + # If this isn't the first message, check if the previous message is a matching assistant message + if i > 0: + prev_msg = sanitized_messages[i-1] + + # Check if the previous message is an assistant message with matching tool_call_id + is_valid_sequence = ( + prev_msg.get("role") == "assistant" and + prev_msg.get("tool_calls") and + any(tc.get("id") == tool_id for tc in prev_msg.get("tool_calls", [])) + ) + + if not is_valid_sequence: + # Find the assistant message with this tool_call_id + assistant_idx = None + for j, assistant_msg in enumerate(sanitized_messages): + if (assistant_msg.get("role") == "assistant" and + assistant_msg.get("tool_calls") and + any(tc.get("id") == tool_id for tc in assistant_msg.get("tool_calls", []))): + assistant_idx = j + break + + # If we found a matching assistant message, move this tool message right after it + if assistant_idx is not None: + # Remember to save the tool message + tool_msg = sanitized_messages.pop(i) + + # Insert right after the assistant message + sanitized_messages.insert(assistant_idx + 1, tool_msg) + + # Adjust i to account for the move + if assistant_idx < i: + # We moved the message backward, so i should point to the next message + # which is now at position i (since we removed a message before it) + continue + else: + # We moved the message forward, so i should now point to the message + # that is now at position i + continue + else: + # No matching assistant message found - create one + assistant_msg = { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": tool_id, + "type": "function", + "function": { + "name": "unknown_function", + "arguments": "{}" + } + }] + } + + # Insert the assistant message before the tool message + sanitized_messages.insert(i, assistant_msg) + + # Skip past both messages + i += 2 + continue + else: + # This tool message is at index 0, which means there's no preceding assistant message + # Create a dummy assistant message + assistant_msg = { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": tool_id, + "type": "function", + "function": { + "name": "unknown_function", + "arguments": "{}" + } + }] + } + + # Insert the assistant message before the tool message + sanitized_messages.insert(0, assistant_msg) + + # Skip past both messages + i += 2 + continue + + # Move to the next message + i += 1 + # Final validation - ensure all tool calls have responses for tool_id, indices in list(tool_call_map.items()): if indices["tool_idx"] is None: @@ -2098,4 +2196,87 @@ def finish_tool_streaming(tool_name, args, output, call_id, execution_info=None, # Mark the streaming session as complete if hasattr(cli_print_tool_output, '_streaming_sessions') and call_id in cli_print_tool_output._streaming_sessions: - cli_print_tool_output._streaming_sessions[call_id]['is_complete'] = True \ No newline at end of file + cli_print_tool_output._streaming_sessions[call_id]['is_complete'] = True + +def print_message_history(messages, title="Message History"): + """ + Pretty-print a sequence of messages with enhanced debug information. + + Args: + messages (List[dict]): List of message dictionaries to display + title (str, optional): Title to display above the message history + """ + from rich.console import Console + from rich.panel import Panel + from rich.text import Text + from rich.table import Table + + console = Console() + + # Create a table for displaying messages + table = Table(show_header=True, header_style="bold magenta", expand=True) + table.add_column("#", style="dim", width=3) + table.add_column("Role", style="cyan", width=10) + table.add_column("Content", width=40) + table.add_column("Metadata", width=30) + + # Process each message + for i, msg in enumerate(messages): + # Get role with color based on type + role = msg.get("role", "unknown") + role_style = { + "user": "green", + "assistant": "blue", + "system": "yellow", + "tool": "magenta" + }.get(role, "white") + + # Get content preview + content = msg.get("content") + content_preview = "" + if content is None: + content_preview = "[dim]None[/dim]" + elif isinstance(content, str): + # Truncate and escape long content + content_preview = (content[:37] + "...") if len(content) > 40 else content + content_preview = content_preview.replace("\n", "\\n") + elif isinstance(content, list): + content_preview = f"[list with {len(content)} items]" + else: + content_preview = f"[{type(content).__name__}]" + + # Gather metadata + metadata = [] + if msg.get("tool_calls"): + tc_count = len(msg["tool_calls"]) + tc_info = [] + for tc in msg["tool_calls"]: + tc_id = tc.get("id", "unknown") + tc_name = tc.get("function", {}).get("name", "unknown") if "function" in tc else "unknown" + tc_info.append(f"{tc_name}({tc_id})") + metadata.append(f"tool_calls[{tc_count}]: {', '.join(tc_info)}") + + if msg.get("tool_call_id"): + metadata.append(f"tool_call_id: {msg['tool_call_id']}") + + metadata_str = ", ".join(metadata) + + # Add row to table + table.add_row( + str(i), + f"[{role_style}]{role}[/{role_style}]", + content_preview, + metadata_str + ) + + # Create the panel with the table + panel = Panel( + table, + title=f"[bold]{title}[/bold]", + expand=False + ) + + # Display the panel + console.print(panel) + + return len(messages) # Return message count for convenience \ No newline at end of file