mirror of https://github.com/aliasrobotics/cai.git
Assimilate .gitignore changes
Signed-off-by: Víctor Mayoral Vilches <v.mayoralv@gmail.com>
This commit is contained in:
commit
3140261864
|
|
@ -157,3 +157,8 @@ benchmarks/outputs
|
|||
# other
|
||||
nohup.out
|
||||
.aider*
|
||||
caiextensions-*
|
||||
pentestperf
|
||||
|
||||
# python venvs
|
||||
venv*
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
215
src/cai/util.py
215
src/cai/util.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue