mirror of https://github.com/aliasrobotics/cai.git
Fix message lists
This commit is contained in:
parent
7fa0971948
commit
a5addaf2ab
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
183
src/cai/util.py
183
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
|
||||
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
|
||||
Loading…
Reference in New Issue