Assimilate .gitignore changes

Signed-off-by: Víctor Mayoral Vilches <v.mayoralv@gmail.com>
This commit is contained in:
Víctor Mayoral Vilches 2025-05-31 10:55:31 +02:00
commit 3140261864
11 changed files with 1517 additions and 65 deletions

5
.gitignore vendored
View File

@ -157,3 +157,8 @@ benchmarks/outputs
# other
nohup.out
.aider*
caiextensions-*
pentestperf
# python venvs
venv*

View File

@ -0,0 +1,61 @@
"""
Implementation of a Cyclic Swarm Pattern for Bug Bounty Triage Operations
This module establishes a coordinated multi-agent system where specialized agents
collaborate on vulnerability discovery and verification tasks. The pattern
implements a directed graph of agent relationships, where each agent can transfer
context (message history) to another agent through handoff functions, creating a
complete communication network for comprehensive bug bounty and triage analysis.
"""
from cai.agents.retester import retester_agent
from cai.agents.bug_bounter import bug_bounter_agent
from cai.sdk.agents import handoff
# Clone agents to avoid modifying the original instances
_retester_agent_copy = retester_agent.clone()
_bug_bounter_agent_copy = bug_bounter_agent.clone()
# Clear any existing handoffs to ensure independence
_retester_agent_copy.handoffs = []
_bug_bounter_agent_copy.handoffs = []
# Create handoffs using the SDK handoff function
_retester_handoff = handoff(
agent=_retester_agent_copy,
tool_description_override="Transfer to Retester Agent for vulnerablity confirmation and triage"
)
_bug_bounter_handoff = handoff(
agent=_bug_bounter_agent_copy,
tool_description_override="Transfer to Bug Bounter Agent for vulnerability discovery and bug bounty hunting"
)
# Register handoff to enable inter-agent communication pathways
_bug_bounter_agent_copy.handoffs.append(_retester_handoff)
_retester_agent_copy.handoffs.append(_bug_bounter_handoff)
# Customize agent properties and add handoff instructions
_bug_bounter_agent_copy.name = "Bug bounty Triage Agent"
_bug_bounter_agent_copy.description = (
"Agent that specializes in vulnerability discovery and bug bounty "
"hunting without false positives"
)
# Add handoff instructions to Bug Bounter agent
if _bug_bounter_agent_copy.instructions:
_bug_bounter_agent_copy.instructions += (
"\n\nWhen you discover potential vulnerabilities, transfer to "
"the Retester Agent for verification and triage."
)
# Add handoff instructions to Retester agent
if _retester_agent_copy.instructions:
_retester_agent_copy.instructions += (
"\n\nAfter completing verification and triage, transfer back "
"to the Bug Bounter Agent to continue vulnerability discovery."
)
# Initialize the swarm pattern with the bug bounter agent as the entry point
bb_triage_swarm_pattern = _bug_bounter_agent_copy
bb_triage_swarm_pattern.pattern = "swarm"

View File

@ -13,27 +13,38 @@ from cai.agents.mail import dns_smtp_agent
from cai.sdk.agents import handoff
# Clone agents to avoid modifying the original instances
_redteam_agent_copy = redteam_agent.clone()
_thought_agent_copy = thought_agent.clone()
_dns_smtp_agent_copy = dns_smtp_agent.clone()
# Clear any existing handoffs to ensure independence
_redteam_agent_copy.handoffs = []
_thought_agent_copy.handoffs = []
_dns_smtp_agent_copy.handoffs = []
# Create handoffs using the SDK handoff function
dns_smtp_handoff = handoff(
agent=dns_smtp_agent,
_dns_smtp_handoff = handoff(
agent=_dns_smtp_agent_copy,
tool_description_override="Use for DNS scans and domain reconnaissance about DMARC and DKIM records"
)
redteam_handoff = handoff(
agent=redteam_agent,
_redteam_handoff = handoff(
agent=_redteam_agent_copy,
tool_description_override="Transfer to Red Team Agent for security assessment and exploitation tasks"
)
thought_handoff = handoff(
agent=thought_agent,
_thought_handoff = handoff(
agent=_thought_agent_copy,
tool_description_override="Transfer to Thought Agent for analysis and planning"
)
_thought_agent_copy.name = "Red team manager"
# Register handoff to enable inter-agent communication pathways
redteam_agent.handoffs.append(dns_smtp_handoff)
dns_smtp_agent.handoffs.append(redteam_handoff)
thought_agent.handoffs.append(redteam_handoff)
_redteam_agent_copy.handoffs.append(_dns_smtp_handoff)
_dns_smtp_agent_copy.handoffs.append(_redteam_handoff)
_thought_agent_copy.handoffs.append(_redteam_handoff)
# Initialize the swarm pattern with the thought agent as the entry point
redteam_swarm_pattern = thought_agent
redteam_swarm_pattern = _thought_agent_copy
redteam_swarm_pattern.pattern = "swarm"

View File

@ -0,0 +1,46 @@
"""Retester Agent for vulnerability verification and triage"""
import os
from dotenv import load_dotenv
from cai.sdk.agents import Agent, OpenAIChatCompletionsModel
from openai import AsyncOpenAI
from cai.util import load_prompt_template
from cai.tools.reconnaissance.generic_linux_command import ( # pylint: disable=import-error # noqa: E501
generic_linux_command
)
from cai.tools.web.search_web import ( # pylint: disable=import-error # noqa: E501
make_google_search
)
from cai.tools.reconnaissance.exec_code import ( # pylint: disable=import-error # noqa: E501
execute_code
)
load_dotenv()
# Load the triage agent system prompt
retester_system_prompt = load_prompt_template("prompts/system_triage_agent.md")
tools = [
generic_linux_command,
execute_code
]
if os.getenv('GOOGLE_SEARCH_API_KEY') and os.getenv('GOOGLE_SEARCH_CX'):
tools.append(make_google_search)
retester_agent = Agent(
name="Retester Agent",
instructions=retester_system_prompt,
description="""Agent that specializes in vulnerability verification and
triage. Expert in determining exploitability and
eliminating false positives.""",
tools=tools,
model=OpenAIChatCompletionsModel(
model=os.getenv('CAI_MODEL', "qwen2.5:14b"),
openai_client=AsyncOpenAI(),
)
)

View File

@ -0,0 +1,78 @@
# Cybersecurity Triage Agent
You are an autonomous cybersecurity agent specializing in vulnerability
verification and false positive elimination. Your primary objective is to
conduct thorough triage of identified security issues to determine their
actual exploitability and impact.
## Core Objectives
1. **Vulnerability Verification**: Determine if identified vulnerabilities
genuinely affect the target system
2. **Exploitability Assessment**: Evaluate whether vulnerabilities are
practically exploitable given current system constraints, privileges,
and environmental factors
3. **False Positive Elimination**: Filter out non-exploitable findings to
focus on genuine security risks
## Triage Methodology
When vulnerabilities are present in your context, process each one
systematically using this structured approach:
### Phase 1: Initial Assessment
- Review the vulnerability detection data and scan results
- Analyze the specific system context and configuration
- Identify the vulnerability type, severity, and potential impact
- Document current privilege level and system access constraints
### Phase 2: Intelligence Gathering
If vulnerability information is insufficient, conduct comprehensive research:
- Search public vulnerability databases and resources
- Query NIST database via curl commands for CVE details
- Use searchsploit for exploit availability and techniques
- Leverage Google searches for additional context and proof-of-concepts
- Access any available structured databases through curl or Linux commands
- Gather vendor advisories and security bulletins
### Phase 3: Exploitation Validation
- Develop and execute targeted proof-of-concept exploits
- Test vulnerability under current system conditions
- Verify that exploitation succeeds with available privileges
- Document all exploitation attempts and results
- Assess real-world exploitability factors
### Phase 4: Impact Analysis
- Determine the actual security impact if exploitation succeeds
- Evaluate potential for privilege escalation or lateral movement
- Assess data exposure or system compromise risks
- Consider business impact and operational consequences
## Success Criteria
A vulnerability is confirmed as genuine and exploitable when:
- Proof-of-concept exploitation demonstrates actual system compromise
- The exploit works within current privilege and access constraints
- The vulnerability poses measurable security risk to the organization
- Exploitation can be reliably reproduced
## Output Standards
For each vulnerability assessed:
- **Status**: Confirmed Vulnerable / Not Exploitable / False Positive
- **Evidence**: Detailed exploitation steps and proof-of-concept
- **Impact**: Realistic assessment of potential damage
- **Constraints**: Factors limiting or enabling exploitation
- **Recommendations**: Specific remediation guidance
- **Artifacts**: Complete file directory path to exploit code, all
supporting files, scripts, and documentation required to reproduce
the vulnerability exploitation
## Guidelines
- Prioritize thorough verification over speed
- Always attempt practical exploitation before confirming vulnerabilities
- Consider environmental factors that may affect exploitability
Remember: The goal is to provide definitive answers about vulnerability
exploitability, eliminating uncertainty and enabling informed security
decision-making.

View File

@ -40,7 +40,8 @@ from cai.repl.commands import ( # pylint: disable=import-error,unused-import,li
workspace,
virtualization,
load,
parallel # Add the new parallel command
parallel, # Add the new parallel command
mcp # Add the MCP command
)
# Define helper functions

1066
src/cai/repl/commands/mcp.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -98,8 +98,9 @@ class ParallelCommand(Command):
model = args[i + 1]
i += 2
elif args[i] == "--prompt" and i + 1 < len(args):
prompt = args[i + 1]
i += 2
# Capture all remaining arguments as the prompt
prompt = " ".join(args[i + 1:])
break # Stop parsing after --prompt since we take everything after it
else:
i += 1

View File

@ -80,10 +80,52 @@ class MCPUtil:
logger.debug(f"Invoking MCP tool {tool.name} with input {input_json}")
try:
# Check if server session is still valid
if not hasattr(server, 'session') or server.session is None:
logger.warning(f"MCP server session not found for tool {tool.name}, attempting to reconnect...")
# Try to reconnect
try:
await server.connect()
logger.info(f"Successfully reconnected to MCP server for tool {tool.name}")
except Exception as reconnect_error:
logger.error(f"Failed to reconnect to MCP server: {reconnect_error}")
raise AgentsException(
f"MCP server connection lost for tool {tool.name}. "
f"Please remove and re-add the MCP server. "
f"Reconnection error: {str(reconnect_error)}"
) from reconnect_error
# Now try to call the tool
result = await server.call_tool(tool.name, json_data)
except AttributeError as ae:
# This often happens when the server object is not properly initialized
logger.error(f"MCP server not properly initialized for tool {tool.name}: {ae}")
logger.error(f"Server type: {type(server)}, has session: {hasattr(server, 'session')}")
raise AgentsException(
f"MCP server not properly initialized for tool {tool.name}. "
f"The server connection may have been lost. "
f"AttributeError: {str(ae)}\n"
f"Try: /mcp remove <server_name> then /mcp load ... to reconnect."
) from ae
except Exception as e:
logger.error(f"Error invoking MCP tool {tool.name}: {e}")
raise AgentsException(f"Error invoking MCP tool {tool.name}: {e}") from e
# Log the full exception details
logger.error(f"Error invoking MCP tool {tool.name}: {type(e).__name__}: {str(e)}")
logger.error(f"Full exception details: {repr(e)}")
# Check if it's a connection issue
error_str = str(e).lower()
if "session" in error_str or "connection" in error_str or "closed" in error_str:
raise AgentsException(
f"MCP server connection error for tool {tool.name}. "
f"Error: {type(e).__name__}: {str(e)}\n"
f"Use '/mcp status' to check server health and '/mcp remove' + '/mcp load' to reconnect."
) from e
else:
# For other errors, include the full error details
raise AgentsException(
f"Error invoking MCP tool {tool.name}: {type(e).__name__}: {str(e)}"
) from e
if _debug.DONT_LOG_TOOL_DATA:
logger.debug(f"MCP tool {tool.name} completed.")

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

@ -645,9 +645,65 @@ def visualize_agent_graph(start_agent):
# Add tools
tools_node = node.add("[yellow]Tools[/yellow]")
for tool in getattr(agent, "tools", []):
# Get regular tools and MCP tools separately
regular_tools = getattr(agent, "tools", [])
mcp_tools = []
try:
# Try to get MCP tools specifically
import asyncio
async def get_mcp_tools_async():
return await agent.get_mcp_tools()
# Run async function to get MCP tools
try:
loop = asyncio.get_running_loop()
# If we're in a loop, we need to use a different approach
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(get_mcp_tools_async())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
mcp_tools = future.result(timeout=10)
except RuntimeError:
# No running loop, we can use asyncio.run
mcp_tools = asyncio.run(get_mcp_tools_async())
except Exception:
# If MCP tools fetch fails, mcp_tools remains empty list
mcp_tools = []
# Show regular tools first
for tool in regular_tools:
tool_name = getattr(tool, "name", None) or getattr(tool, "__name__", "")
tools_node.add(f"[blue]{tool_name}[/blue]")
# Show MCP tools with a different color/prefix
if mcp_tools:
for tool in mcp_tools:
tool_name = getattr(tool, "name", None) or getattr(tool, "__name__", "")
tools_node.add(f"[magenta]🔌 {tool_name}[/magenta]")
# Add a summary line if we have both types
if regular_tools and mcp_tools:
summary_text = f"[dim]({len(regular_tools)} regular, {len(mcp_tools)} MCP tools)[/dim]"
tools_node.add(summary_text)
elif mcp_tools and not regular_tools:
summary_text = f"[dim]({len(mcp_tools)} MCP tools)[/dim]"
tools_node.add(summary_text)
elif regular_tools and not mcp_tools:
summary_text = f"[dim]({len(regular_tools)} regular tools)[/dim]"
tools_node.add(summary_text)
# Add handoffs
transfers_node = node.add("[magenta]Handoffs[/magenta]")
@ -741,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
@ -754,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"):
@ -777,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
@ -799,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"):
@ -815,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 = (
@ -827,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", []))):
@ -837,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:
@ -867,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
@ -889,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
@ -903,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"
@ -921,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
@ -949,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
@ -972,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"""
@ -3165,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
@ -3198,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")
@ -3211,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),
@ -3230,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,
@ -3242,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
@ -3283,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(
@ -3290,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),
@ -3304,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
@ -3315,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
@ -3339,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")
@ -3368,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),
@ -3390,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
@ -3412,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
@ -3425,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:
@ -3440,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