Fix message lists

This commit is contained in:
luijait 2025-05-11 10:56:29 +02:00
parent 7fa0971948
commit a5addaf2ab
3 changed files with 333 additions and 5 deletions

View File

@ -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

View File

@ -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"],

View File

@ -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
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