Merge pull request #173 from aliasrobotics/deepseek_reasoning_trace

Deepseek reasoning trace
This commit is contained in:
PRT 2025-05-30 10:07:36 +02:00 committed by GitHub
commit 3c650c10d3
2 changed files with 134 additions and 49 deletions

View File

@ -1141,7 +1141,9 @@ class OpenAIChatCompletionsModel(Model):
else:
# Non-streaming mode: Use simple text output
from cai.util import print_claude_reasoning_simple, detect_claude_thinking_in_stream
if detect_claude_thinking_in_stream(str(self.model)):
# Check if model supports reasoning (Claude or DeepSeek)
model_str_lower = str(self.model).lower()
if detect_claude_thinking_in_stream(str(self.model)) or "deepseek" in model_str_lower:
print_claude_reasoning_simple(reasoning_content, self.agent_name, str(self.model))
@ -2153,6 +2155,14 @@ class OpenAIChatCompletionsModel(Model):
# Remove tool_choice if no tools are specified
if not converted_tools:
kwargs.pop("tool_choice", None)
# Add reasoning support for DeepSeek
# DeepSeek supports reasoning_effort parameter
if hasattr(model_settings, "reasoning_effort") and model_settings.reasoning_effort:
kwargs["reasoning_effort"] = model_settings.reasoning_effort
else:
# Default to "low" reasoning effort if model supports it
kwargs["reasoning_effort"] = "low"
elif provider == "claude" or "claude" in model_str:
litellm.drop_params = True
kwargs.pop("store", None)
@ -2355,6 +2365,13 @@ class OpenAIChatCompletionsModel(Model):
provider_kwargs["custom_llm_provider"] = "deepseek"
provider_kwargs.pop("store", None) # DeepSeek doesn't support store parameter
provider_kwargs.pop("parallel_tool_calls", None) # DeepSeek doesn't support parallel tool calls
# Add reasoning support for DeepSeek
if hasattr(model_settings, "reasoning_effort") and model_settings.reasoning_effort:
provider_kwargs["reasoning_effort"] = model_settings.reasoning_effort
else:
# Default to "low" reasoning effort
provider_kwargs["reasoning_effort"] = "low"
elif provider == "claude" or "claude" in model_str:
provider_kwargs["custom_llm_provider"] = "anthropic"
provider_kwargs.pop("store", None) # Claude doesn't support store parameter
@ -3234,6 +3251,9 @@ class _Converter:
call_id = func_output["call_id"]
output_content = func_output["output"]
# IMPORTANT: Truncate call_id to 40 characters for consistency
truncated_call_id = call_id[:40] if call_id else call_id
# Update execution timing if we have the start time
if hasattr(cls, 'recent_tool_calls') and call_id in cls.recent_tool_calls:
tool_call_details = cls.recent_tool_calls[call_id] # Renamed for clarity
@ -3354,10 +3374,10 @@ class _Converter:
# The responsibility for ensuring a preceding assistant message
# is now fully deferred to fix_message_list, called later.
# Now add the tool message
# Now add the tool message with truncated call_id
msg: ChatCompletionToolMessageParam = {
"role": "tool",
"tool_call_id": func_output["call_id"],
"tool_call_id": truncated_call_id,
"content": func_output["output"],
}
result.append(msg)

View File

@ -797,6 +797,7 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912
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.
7. Tool call IDs are truncated to 40 characters for API compatibility.
Args:
messages (List[dict]): List of message dictionaries containing
@ -810,22 +811,45 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912
# Deep-copy to ensure we don't modify the input
sanitized_messages = []
# First pass - identify tool_call_ids from assistant messages and tool messages
# First, truncate all tool call IDs to 40 characters throughout the messages
# This ensures consistency for providers like DeepSeek that have strict ID matching
for msg in messages:
msg_copy = msg.copy()
# Truncate tool_call_id in tool messages
if msg_copy.get("role") == "tool" and msg_copy.get("tool_call_id"):
if len(msg_copy["tool_call_id"]) > 40:
msg_copy["tool_call_id"] = msg_copy["tool_call_id"][:40]
# Truncate IDs in assistant tool_calls
if msg_copy.get("role") == "assistant" and msg_copy.get("tool_calls"):
tool_calls_copy = []
for tc in msg_copy["tool_calls"]:
tc_copy = tc.copy()
if tc_copy.get("id") and len(tc_copy["id"]) > 40:
tc_copy["id"] = tc_copy["id"][:40]
tool_calls_copy.append(tc_copy)
msg_copy["tool_calls"] = tool_calls_copy
sanitized_messages.append(msg_copy)
# Now process the messages with truncated IDs
processed_messages = []
tool_call_map = {} # Map from tool_call_id to (assistant_idx, tool_idx)
for i, msg in enumerate(messages):
for i, msg in enumerate(sanitized_messages):
# Skip empty messages (considered empty if 'content' is None or only whitespace)
if msg.get("role") in ["user", "system"] and (msg.get("content") is None or not str(msg.get("content", "")).strip()):
# Special case: if it's a system message, set content to empty string instead of skipping
if msg.get("role") == "system":
# Replace None with empty string
msg["content"] = ""
sanitized_messages.append(msg)
processed_messages.append(msg)
# Skip empty user messages entirely
continue
# Add valid messages to our sanitized list first
sanitized_messages.append(msg)
# Add valid messages to our processed list first
processed_messages.append(msg)
# Now track tool calls and tool messages for pairing
if msg.get("role") == "assistant" and msg.get("tool_calls"):
@ -833,12 +857,12 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912
if tc.get("id"):
tool_id = tc.get("id")
if tool_id not in tool_call_map:
tool_call_map[tool_id] = {"assistant_idx": len(sanitized_messages) - 1, "tool_idx": None}
tool_call_map[tool_id] = {"assistant_idx": len(processed_messages) - 1, "tool_idx": None}
if msg.get("role") == "tool" and msg.get("tool_call_id"):
tool_id = msg.get("tool_call_id")
if tool_id in tool_call_map:
tool_call_map[tool_id]["tool_idx"] = len(sanitized_messages) - 1
tool_call_map[tool_id]["tool_idx"] = len(processed_messages) - 1
else:
# Tool response without a matching tool call - create a synthetic pair
# by adding a dummy assistant message with a tool_call
@ -855,15 +879,15 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912
}]
}
# Insert the assistant message *before* the tool message
sanitized_messages.insert(len(sanitized_messages) - 1, assistant_msg)
processed_messages.insert(len(processed_messages) - 1, assistant_msg)
# Update mapping
tool_call_map[tool_id] = {"assistant_idx": len(sanitized_messages) - 2, "tool_idx": len(sanitized_messages) - 1}
tool_call_map[tool_id] = {"assistant_idx": len(processed_messages) - 2, "tool_idx": len(processed_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]
while i < len(processed_messages):
msg = processed_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"):
@ -871,7 +895,7 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912
# 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]
prev_msg = processed_messages[i-1]
# Check if the previous message is an assistant message with matching tool_call_id
is_valid_sequence = (
@ -883,7 +907,7 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912
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):
for j, assistant_msg in enumerate(processed_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", []))):
@ -893,10 +917,10 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912
# 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)
tool_msg = processed_messages.pop(i)
# Insert right after the assistant message
sanitized_messages.insert(assistant_idx + 1, tool_msg)
processed_messages.insert(assistant_idx + 1, tool_msg)
# Adjust i to account for the move
if assistant_idx < i:
@ -923,7 +947,7 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912
}
# Insert the assistant message before the tool message
sanitized_messages.insert(i, assistant_msg)
processed_messages.insert(i, assistant_msg)
# Skip past both messages
i += 2
@ -945,7 +969,7 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912
}
# Insert the assistant message before the tool message
sanitized_messages.insert(0, assistant_msg)
processed_messages.insert(0, assistant_msg)
# Skip past both messages
i += 2
@ -959,7 +983,7 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912
if indices["tool_idx"] is None:
# Tool call without a response - create a synthetic tool message
assistant_idx = indices["assistant_idx"]
assistant_msg = sanitized_messages[assistant_idx]
assistant_msg = processed_messages[assistant_idx]
# Find the relevant tool call
tool_name = "unknown_function"
@ -977,18 +1001,18 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912
}
# Insert immediately after the assistant message
if assistant_idx + 1 < len(sanitized_messages):
if assistant_idx + 1 < len(processed_messages):
# Insert at the position after assistant
sanitized_messages.insert(assistant_idx + 1, tool_msg)
processed_messages.insert(assistant_idx + 1, tool_msg)
else:
# Just append if we're at the end
sanitized_messages.append(tool_msg)
processed_messages.append(tool_msg)
# Update the map to note that this tool call now has a response
tool_call_map[tool_id]["tool_idx"] = assistant_idx + 1
# Ensure messages have non-null content (required by some providers)
for msg in sanitized_messages:
for msg in processed_messages:
# For assistant messages with tool_calls, content can be None
if msg.get("role") == "assistant" and msg.get("tool_calls"):
# Assistant messages with tool calls can have None content - this is valid
@ -1005,9 +1029,9 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912
# Special case for Claude: ensure strict alternating pattern between assistant tool_calls and tool results
# If multiple consecutive assistant messages with tool_calls exist, interleave them with tool responses
i = 0
while i < len(sanitized_messages) - 1:
current_msg = sanitized_messages[i]
next_msg = sanitized_messages[i + 1]
while i < len(processed_messages) - 1:
current_msg = processed_messages[i]
next_msg = processed_messages[i + 1]
# When current message is assistant with tool_calls and next message is NOT a tool response
if (current_msg.get("role") == "assistant" and
@ -1028,13 +1052,13 @@ def fix_message_list(messages): # pylint: disable=R0914,R0915,R0912
}
# Insert the tool message after the current assistant message
sanitized_messages.insert(i + 1, tool_msg)
processed_messages.insert(i + 1, tool_msg)
# Skip over the newly inserted message
i += 2
else:
i += 1
return sanitized_messages
return processed_messages
def cli_print_tool_call(tool_name="", args="", output="", prefix=" "):
"""Print a tool call with pretty formatting"""
@ -3221,8 +3245,8 @@ def setup_ctf():
def create_claude_thinking_context(agent_name, counter, model):
"""
Create a streaming context for Claude thinking/reasoning display.
This creates a dedicated panel that shows Claude's internal reasoning process.
Create a streaming context for AI thinking/reasoning display.
This creates a dedicated panel that shows the model's internal reasoning process.
Args:
agent_name: The name of the agent
@ -3254,10 +3278,19 @@ def create_claude_thinking_context(agent_name, counter, model):
terminal_width, _ = shutil.get_terminal_size((100, 24))
panel_width = min(terminal_width - 4, 120)
# Determine model type for display
model_str = str(model).lower()
if "claude" in model_str:
model_display = "Claude"
elif "deepseek" in model_str:
model_display = "DeepSeek"
else:
model_display = "AI"
# Create the thinking panel header
header = Text()
header.append("🧠 ", style="bold yellow")
header.append(f"Claude Reasoning [{counter}]", style="bold yellow")
header.append(f"{model_display} Reasoning [{counter}]", style="bold yellow")
header.append(f" | {agent_name}", style="bold cyan")
header.append(f" | {timestamp}", style="dim")
@ -3267,7 +3300,7 @@ def create_claude_thinking_context(agent_name, counter, model):
# Create the panel for thinking
panel = Panel(
Group(header, Text("\n"), thinking_content),
title="[bold yellow]🧠 Thinking Process[/bold yellow]",
title=f"[bold yellow]🧠 {model_display} Thinking Process[/bold yellow]",
border_style="yellow",
box=ROUNDED,
padding=(1, 2),
@ -3286,6 +3319,7 @@ def create_claude_thinking_context(agent_name, counter, model):
"thinking_content": thinking_content,
"timestamp": timestamp,
"model": model,
"model_display": model_display,
"agent_name": agent_name,
"panel_width": panel_width,
"is_started": False,
@ -3298,12 +3332,12 @@ def create_claude_thinking_context(agent_name, counter, model):
return context
except Exception as e:
print(f"Error creating Claude thinking context: {e}")
print(f"Error creating {model_display} thinking context: {e}")
return None
def update_claude_thinking_content(context, thinking_delta):
"""
Update the Claude thinking content with new reasoning text.
Update the AI thinking content with new reasoning text.
Args:
context: The thinking context created by create_claude_thinking_context
@ -3339,6 +3373,9 @@ def update_claude_thinking_content(context, thinking_delta):
# For short thinking, use regular text with styling
thinking_display = Text(thinking_text, style="white")
# Get model display name from context
model_display = context.get("model_display", "AI")
# Update the panel content
updated_panel = Panel(
Group(
@ -3346,7 +3383,7 @@ def update_claude_thinking_content(context, thinking_delta):
Text("\n"),
thinking_display
),
title="[bold yellow]🧠 Thinking Process[/bold yellow]",
title=f"[bold yellow]🧠 {model_display} Thinking Process[/bold yellow]",
border_style="yellow",
box=ROUNDED,
padding=(1, 2),
@ -3360,7 +3397,8 @@ def update_claude_thinking_content(context, thinking_delta):
context["live"].start()
context["is_started"] = True
except Exception as e:
print(f"Error starting Claude thinking display: {e}")
model_display = context.get("model_display", "AI")
print(f"Error starting {model_display} thinking display: {e}")
return False
# Update the live display
@ -3371,12 +3409,13 @@ def update_claude_thinking_content(context, thinking_delta):
return True
except Exception as e:
print(f"Error updating Claude thinking content: {e}")
model_display = context.get("model_display", "AI")
print(f"Error updating {model_display} thinking content: {e}")
return False
def finish_claude_thinking_display(context):
"""
Finish the Claude thinking display session.
Finish the AI thinking display session.
Args:
context: The thinking context to finish
@ -3395,10 +3434,13 @@ def finish_claude_thinking_display(context):
from rich.syntax import Syntax
from rich.console import Group
# Get model display name
model_display = context.get("model_display", "AI")
# Add final formatting to show completion
final_header = Text()
final_header.append("🧠 ", style="bold green")
final_header.append(f"Claude Reasoning Complete", style="bold green")
final_header.append(f"{model_display} Reasoning Complete", style="bold green")
final_header.append(f" | {context['agent_name']}", style="bold cyan")
final_header.append(f" | {context['timestamp']}", style="dim")
@ -3424,7 +3466,7 @@ def finish_claude_thinking_display(context):
Text("\n"),
final_thinking_display
),
title="[bold green]🧠 Thinking Complete[/bold green]",
title=f"[bold green]🧠 {model_display} Thinking Complete[/bold green]",
border_style="green",
box=ROUNDED,
padding=(1, 2),
@ -3446,13 +3488,14 @@ def finish_claude_thinking_display(context):
return True
except Exception as e:
print(f"Error finishing Claude thinking display: {e}")
model_display = context.get("model_display", "AI")
print(f"Error finishing {model_display} thinking display: {e}")
return False
def detect_claude_thinking_in_stream(model_name):
"""
Detect if a model should show thinking/reasoning display.
Only applies to Claude models with reasoning capability.
Applies to Claude and DeepSeek models with reasoning capability.
Args:
model_name: The model name to check
@ -3468,7 +3511,7 @@ def detect_claude_thinking_in_stream(model_name):
# Check for Claude models with reasoning capability
# Claude 4 models (like claude-sonnet-4-20250514) support reasoning
# Also check for explicit "thinking" in model name
has_reasoning = (
has_claude_reasoning = (
"claude" in model_str and (
# Claude 4 models (sonnet-4, haiku-4, opus-4)
"-4-" in model_str or
@ -3481,11 +3524,23 @@ def detect_claude_thinking_in_stream(model_name):
)
)
return has_reasoning
# Check for DeepSeek models with reasoning capability
has_deepseek_reasoning = (
"deepseek" in model_str and (
# DeepSeek reasoner models
"reasoner" in model_str or
# DeepSeek chat models also support reasoning
"chat" in model_str or
# Generic deepseek models likely support it
"/" in model_str # e.g., deepseek/deepseek-chat
)
)
return has_claude_reasoning or has_deepseek_reasoning
def print_claude_reasoning_simple(reasoning_content, agent_name, model_name):
"""
Print Claude reasoning content in simple mode (no Rich panels).
Print AI reasoning content in simple mode (no Rich panels).
Used when CAI_STREAM=False.
Args:
@ -3496,16 +3551,26 @@ def print_claude_reasoning_simple(reasoning_content, agent_name, model_name):
if not reasoning_content or not reasoning_content.strip():
return
# Determine model type for display
model_str = str(model_name).lower()
if "claude" in model_str:
model_display = "Claude"
elif "deepseek" in model_str:
model_display = "DeepSeek"
else:
model_display = "AI"
# Simple text output without Rich formatting
timestamp = datetime.now().strftime("%H:%M:%S")
print(f"\n🧠 Reasoning | {agent_name} | {model_name} | {timestamp}")
print(f"\n🧠 {model_display} Reasoning | {agent_name} | {model_name} | {timestamp}")
print("=" * 60)
print(reasoning_content)
print("=" * 60 + "\n")
def start_claude_thinking_if_applicable(model_name, agent_name, counter):
"""
Start Claude thinking display if the model supports it AND streaming is enabled.
Start AI thinking display if the model supports it AND streaming is enabled.
Supports Claude and DeepSeek models with reasoning capabilities.
Args:
model_name: The model name