Files
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

1305 lines
56 KiB
Python

"""
Event-Triggered AI Agent with System Hints
An agent that responds to events from various sources while maintaining
all the system hint features from the original implementation.
"""
import json
import os
import sys
import subprocess
import platform
import logging
import concurrent.futures
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime, timedelta
import requests
from openai import OpenAI
import traceback
import tempfile
import shutil
from pathlib import Path
from event_types import Event, EventType
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.types import TextContent
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
def _reasoning_safe_temperature(model, requested=1.0):
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
Return 1 for those; otherwise the requested value so non-reasoning
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
m = str(model or "").lower().replace("/", "-")
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
# Per-provider env var holding that provider's API key.
_PROVIDER_KEY_ENV = {
"dashscope": "DASHSCOPE_API_KEY",
"qwen": "DASHSCOPE_API_KEY",
"bailian": "DASHSCOPE_API_KEY",
"siliconflow": "SILICONFLOW_API_KEY",
"doubao": "DOUBAO_API_KEY",
"kimi": "KIMI_API_KEY",
"moonshot": "KIMI_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
}
def resolve_provider_and_key(provider: Optional[str] = None):
"""Resolve (provider, api_key) applying a universal OpenRouter fallback.
Preferred provider (default from LLM_PROVIDER, else 'kimi') is used as today
when its own key is present. Otherwise, if OPENROUTER_API_KEY is set, fall
back to the already-supported 'openrouter' provider so the agent still runs.
Returns (provider, None) when no usable key is found, leaving the caller to
emit its own error.
"""
provider = (provider or os.getenv("LLM_PROVIDER", "kimi")).lower()
provider = {"qwen": "dashscope", "bailian": "dashscope"}.get(provider, provider)
key_env = _PROVIDER_KEY_ENV.get(provider)
api_key = os.getenv(key_env) if key_env else None
if api_key:
return provider, api_key
or_key = os.getenv("OPENROUTER_API_KEY")
if or_key:
return "openrouter", or_key
return provider, None
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class TodoStatus(Enum):
"""Status of a TODO item"""
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
@dataclass
class TodoItem:
"""Represents a single TODO item"""
id: int
content: str
status: TodoStatus = TodoStatus.PENDING
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: Optional[str] = None
@dataclass
class ToolCall:
"""Represents a single tool call with enhanced tracking"""
tool_name: str
arguments: Dict[str, Any]
result: Optional[Any] = None
error: Optional[str] = None
call_number: int = 1
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
duration_ms: Optional[int] = None
@dataclass
class SystemHintConfig:
"""Configuration for system hints"""
enable_timestamps: bool = True
enable_tool_counter: bool = True
enable_todo_list: bool = True
enable_detailed_errors: bool = True
enable_system_state: bool = True
timestamp_format: str = "%Y-%m-%d %H:%M:%S"
simulate_time_delay: bool = False
save_trajectory: bool = True
trajectory_file: str = "trajectory.json"
# Model configuration (matching conversational_agent.py)
temperature: float = 0.7
max_tokens: int = 4096
# MCP server configuration
use_mcp_servers: bool = False # Disabled by default - requires async setup
mcp_collaboration_tools_path: str = "../collaboration-tools/src/main.py"
mcp_execution_tools_path: str = "../execution-tools/server.py"
mcp_perception_tools_path: str = "../perception-tools/src/main.py"
class MCPServerManager:
"""Manages connections to multiple MCP servers"""
def __init__(self):
self.sessions: Dict[str, ClientSession] = {}
self.tools: Dict[str, Any] = {}
self.server_contexts = [] # Store context managers for proper cleanup
async def connect_server(self, name: str, script_path: str) -> bool:
"""
Connect to an MCP server with proper error isolation
NOTE: This stores tool metadata only. For actual tool execution,
you'll need to spawn MCP servers differently or use built-in tools.
Args:
name: Server name
script_path: Path to the MCP server script
Returns:
True if connection successful, False otherwise
"""
try:
# Check if script exists
if not os.path.exists(script_path):
logger.warning(f"MCP server script not found: {script_path}")
return False
logger.info(f"Discovering tools from MCP server '{name}' at {script_path}")
server_params = StdioServerParameters(
command=sys.executable,
args=[script_path],
env=os.environ.copy() # Pass through environment variables
)
# Use a temporary connection just to discover tools
# The actual tool execution will spawn servers on-demand
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# Discover tools
tools_result = await session.list_tools()
tools = tools_result.tools
# Store tool metadata (not live session)
for tool in tools:
# Use underscore separator for valid function names
# OpenAI-compatible APIs reject names with dots
tool_key = f"{name}_{tool.name}"
self.tools[tool_key] = {
"server": name,
"tool": tool,
"script_path": script_path,
"server_params": server_params
}
logger.info(f"✅ Discovered tools from '{name}': {len(tools)} tools")
return True
except Exception as e:
logger.warning(f"Failed to discover tools from '{name}': {str(e)[:100]}")
return False
async def call_tool(self, tool_key: str, arguments: Dict[str, Any]) -> Any:
"""
Call an MCP tool by spawning a fresh server connection
Args:
tool_key: Tool key in format "server.tool_name"
arguments: Tool arguments
Returns:
Tool result
"""
if tool_key not in self.tools:
raise ValueError(f"Unknown MCP tool: {tool_key}")
tool_info = self.tools[tool_key]
tool = tool_info["tool"]
server_params = tool_info["server_params"]
try:
# Spawn fresh connection for this tool call
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool(tool.name, arguments)
# Extract text content from result
text_content = []
if hasattr(result, 'content'):
for c in result.content:
if isinstance(c, TextContent):
text_content.append(c.text)
return {
"success": True,
"result": "\n".join(text_content) if text_content else str(result)
}
except Exception as e:
return {
"success": False,
"error": str(e)
}
async def disconnect_all(self):
"""
Cleanup MCP manager (no persistent connections to close)
Since we spawn fresh connections for each tool call,
there's nothing to disconnect.
"""
logger.info("MCP manager cleanup complete (no persistent connections)")
self.sessions.clear()
self.tools.clear()
self.server_contexts.clear()
class EventTriggeredAgent:
"""
Event-Triggered AI Agent with System Hints
Responds to events while maintaining all system hint capabilities
"""
def __init__(self, api_key: str, provider: str = "kimi",
model: Optional[str] = None, config: Optional[SystemHintConfig] = None,
verbose: bool = True):
"""
Initialize the event-triggered agent
Args:
api_key: API key for the LLM provider
provider: LLM provider ('dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot', 'openrouter')
model: Optional model override
config: System hint configuration
verbose: If True, log full details
"""
self.provider = provider.lower()
self.provider = {"qwen": "dashscope", "bailian": "dashscope"}.get(
self.provider, self.provider
)
self.verbose = verbose
self.config = config or SystemHintConfig()
# Configure client based on provider (matching conversational_agent.py)
if self.provider == "dashscope":
self.client = OpenAI(
api_key=api_key,
base_url=os.getenv(
"DASHSCOPE_BASE_URL",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
)
)
self.model = model or "qwen3.7-plus"
elif self.provider == "siliconflow":
self.client = OpenAI(
api_key=api_key,
base_url="https://api.siliconflow.cn/v1"
)
self.model = model or "Qwen/Qwen3-235B-A22B-Thinking-2507"
elif self.provider == "doubao":
self.client = OpenAI(
api_key=api_key,
base_url="https://ark.cn-beijing.volces.com/api/v3"
)
self.model = model or "doubao-seed-1-6-thinking-250715"
elif self.provider == "kimi" or self.provider == "moonshot":
self.client = OpenAI(
api_key=api_key,
base_url="https://api.moonshot.cn/v1"
)
self.model = model or "kimi-k3"
elif self.provider == "openrouter":
self.client = OpenAI(
api_key=api_key,
base_url="https://openrouter.ai/api/v1"
)
# Default to Gemini 3.5 Flash, but allow any of the supported models
self.model = model or "google/gemini-3.5-flash"
# Supported models: google/gemini-3.5-flash, openai/gpt-5.6-luna, anthropic/claude-sonnet-4.6
else:
raise ValueError(f"Unsupported provider: {provider}. Use 'dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot', or 'openrouter'")
# Initialize tracking
self.tool_call_counts: Dict[str, int] = {}
self.tool_calls: List[ToolCall] = []
self.todo_list: List[TodoItem] = []
self.next_todo_id = 1
# Initialize conversation history
self.conversation_history = []
self.simulated_time = datetime.now()
self._init_system_prompt()
# Track current working directory
self.current_directory = os.getcwd()
# Event tracking
self.last_user_interaction = datetime.now()
self.background_processes: Dict[str, Dict[str, Any]] = {}
# Initialize MCP server manager
self.mcp_manager = MCPServerManager()
self.mcp_tools_loaded = False
logger.info(f"Event-Triggered Agent initialized with provider: {self.provider}, model: {self.model}")
logger.info("Note: Call load_mcp_tools() to connect to MCP servers")
async def load_mcp_tools(self):
"""
Load tools from MCP servers
This must be called in an async context after agent initialization.
"""
if not self.config.use_mcp_servers:
logger.info("MCP servers disabled in config")
return
logger.info("Loading tools from MCP servers...")
# Get the directory where the agent script is located
agent_dir = os.path.dirname(os.path.abspath(__file__))
# Try to connect to collaboration-tools
collab_path = os.path.join(agent_dir, self.config.mcp_collaboration_tools_path)
collab_loaded = await self.mcp_manager.connect_server("collaboration", collab_path)
# Try to connect to execution-tools
exec_path = os.path.join(agent_dir, self.config.mcp_execution_tools_path)
exec_loaded = await self.mcp_manager.connect_server("execution", exec_path)
# Try to connect to perception-tools
percept_path = os.path.join(agent_dir, self.config.mcp_perception_tools_path)
percept_loaded = await self.mcp_manager.connect_server("perception", percept_path)
# Set flag if any tools were loaded
self.mcp_tools_loaded = collab_loaded or exec_loaded or percept_loaded
if self.mcp_tools_loaded:
logger.info(f"✅ MCP tools loaded: {len(self.mcp_manager.tools)} tools available")
logger.info(f" Available MCP tools: {list(self.mcp_manager.tools.keys())[:5]}...")
else:
logger.info("⚠️ No MCP servers found, using built-in tools only")
def _init_system_prompt(self):
"""Initialize the system prompt for the conversation"""
system_content = """You are an intelligent assistant with access to various tools for file operations, code execution, and system commands.
You respond to events from multiple sources including:
- User messages from web interfaces and instant messaging
- Email replies and GitHub notifications
- System reminders and timeout alerts
- Timer triggers and process monitoring events
Your task is to complete the given objectives efficiently using the available tools. Think step by step and use tools as needed.
## TODO List Management Rules:
- For any complex task with 3+ distinct steps, immediately create a TODO list using `rewrite_todo_list`
- Break down the user's request into specific, actionable TODO items
- Update TODO items to 'in_progress' when starting work on them using `update_todo_status`
- Mark items as 'completed' immediately after finishing them
- Only have ONE item 'in_progress' at a time
- If you encounter errors or need to change approach, update relevant TODOs to 'cancelled' and add new ones
- Use the TODO list as your primary planning and tracking mechanism
- Reference TODO items by their ID when discussing progress
## Key Behaviors:
1. ALWAYS start complex tasks by creating a TODO list
2. Pay attention to timestamps to understand the timeline of events
3. Notice tool call numbers (e.g., "Tool call #3") to avoid repetitive loops - if you see high numbers, change strategy
4. Learn from detailed error messages to fix issues and adapt your approach
5. Be aware of your current directory and system environment shown in system state
6. When exploring projects, systematically read key files (README, main.py, agent.py) to understand structure
## Event Response Guidelines:
- Acknowledge the event source in your response
- For timeout events, proactively check status and take appropriate action
- For system alerts, investigate the issue before responding
- Maintain context across multiple events from the same conversation
## Error Handling:
- Read error messages carefully - they contain specific information about what went wrong
- Use the suggestions provided in error messages to fix issues
- If a tool fails multiple times (check the call number), try a different approach
- Common fixes: check file paths, verify current directory, ensure proper permissions
Important: When you have completed all tasks, clearly state "FINAL ANSWER:" followed by a comprehensive summary of what was accomplished."""
self.conversation_history = [
{
"role": "system",
"content": system_content
}
]
def _get_system_state(self) -> str:
"""Get current system state information"""
if not self.config.enable_system_state:
return ""
# Detect OS
system = platform.system()
if system == "Windows":
shell_type = "Windows Command Prompt or PowerShell"
elif system == "Darwin":
shell_type = "macOS Terminal (zsh/bash)"
else:
shell_type = f"Linux Shell ({os.environ.get('SHELL', 'bash')})"
state_info = [
f"Current Time: {self._get_timestamp()}",
f"Current Directory: {self.current_directory}",
f"System: {system} ({platform.release()})",
f"Shell Environment: {shell_type}",
f"Python Version: {sys.version.split()[0]}"
]
# Add background process info if any
if self.background_processes:
state_info.append(f"Background Processes: {len(self.background_processes)} active")
return "\n".join(state_info)
def _get_timestamp(self) -> str:
"""Get formatted timestamp"""
if self.config.simulate_time_delay:
return self.simulated_time.strftime(self.config.timestamp_format)
return datetime.now().strftime(self.config.timestamp_format)
def _advance_simulated_time(self, hours: int = 0, minutes: int = 0, seconds: int = 30):
"""Advance simulated time for demo purposes"""
if self.config.simulate_time_delay:
self.simulated_time += timedelta(hours=hours, minutes=minutes, seconds=seconds)
def _save_trajectory(self, iteration: int, final_answer: Optional[str] = None):
"""Save current trajectory to JSON file for debugging"""
if not self.config.save_trajectory:
return
trajectory_data = {
"timestamp": datetime.now().isoformat(),
"iteration": iteration,
"provider": self.provider,
"model": self.model,
"conversation_history": self.conversation_history,
"tool_calls": [
{
"tool_name": call.tool_name,
"arguments": call.arguments,
"result": call.result,
"error": call.error,
"call_number": call.call_number,
"timestamp": call.timestamp,
"duration_ms": call.duration_ms
}
for call in self.tool_calls
],
"todo_list": [
{
"id": item.id,
"content": item.content,
"status": item.status.value,
"created_at": item.created_at,
"updated_at": item.updated_at
}
for item in self.todo_list
],
"current_directory": self.current_directory,
"final_answer": final_answer,
"background_processes": self.background_processes,
"config": {
"enable_timestamps": self.config.enable_timestamps,
"enable_tool_counter": self.config.enable_tool_counter,
"enable_todo_list": self.config.enable_todo_list,
"enable_detailed_errors": self.config.enable_detailed_errors,
"enable_system_state": self.config.enable_system_state,
"timestamp_format": self.config.timestamp_format,
"simulate_time_delay": self.config.simulate_time_delay
}
}
try:
with open(self.config.trajectory_file, 'w', encoding='utf-8') as f:
json.dump(trajectory_data, f, indent=2, ensure_ascii=False)
if self.verbose:
logger.info(f"Trajectory saved to {self.config.trajectory_file} (iteration {iteration})")
except Exception as e:
logger.warning(f"Failed to save trajectory: {e}")
def _format_todo_list(self) -> str:
"""Format TODO list for display"""
if not self.todo_list:
return "TODO List: Empty"
lines = ["TODO List:"]
for item in self.todo_list:
status_symbol = {
TodoStatus.PENDING: "⏳",
TodoStatus.IN_PROGRESS: "🔄",
TodoStatus.COMPLETED: "✅",
TodoStatus.CANCELLED: "❌"
}.get(item.status, "❓")
lines.append(f" [{item.id}] {status_symbol} {item.content} ({item.status.value})")
return "\n".join(lines)
def _get_system_hint(self) -> Optional[str]:
"""Get system hint content with current state"""
if not any([self.config.enable_system_state, self.config.enable_todo_list]):
return None
hint_parts = []
if self.config.enable_system_state:
hint_parts.append("=== SYSTEM STATE ===")
hint_parts.append(self._get_system_state())
hint_parts.append("")
if self.config.enable_todo_list and self.todo_list:
hint_parts.append("=== CURRENT TASKS ===")
hint_parts.append(self._format_todo_list())
hint_parts.append("")
if hint_parts:
return "\n".join(hint_parts)
return None
def _get_tools_description(self) -> List[Dict[str, Any]]:
"""Get tool descriptions for the model"""
tools = []
# Add MCP tools if available
if self.mcp_tools_loaded:
for tool_key, tool_info in self.mcp_manager.tools.items():
mcp_tool = tool_info["tool"]
# Convert MCP tool schema to OpenAI function format
tool_desc = {
"type": "function",
"function": {
"name": tool_key, # Use prefixed name (e.g., "collaboration.mcp_browser_navigate")
"description": mcp_tool.description or mcp_tool.name,
"parameters": mcp_tool.inputSchema if hasattr(mcp_tool, 'inputSchema') else {
"type": "object",
"properties": {}
}
}
}
tools.append(tool_desc)
else:
# Fallback to built-in tools if MCP servers not available
tools.extend([
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read the contents of a text file. Returns error for binary files. Supports partial reading for large files.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to the file to read (absolute or relative to current directory)"
},
"begin_line": {
"type": "integer",
"description": "Optional: Line number to start reading from (1-based indexing)"
},
"number_lines": {
"type": "integer",
"description": "Optional: Number of lines to read from begin_line"
}
},
"required": ["file_path"]
}
}
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Write content to a file (creates or overwrites)",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to the file to write"
},
"content": {
"type": "string",
"description": "Content to write to the file"
}
},
"required": ["file_path", "content"]
}
}
},
{
"type": "function",
"function": {
"name": "code_interpreter",
"description": "Execute Python code in a restricted environment",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Python code to execute"
}
},
"required": ["code"]
}
}
},
{
"type": "function",
"function": {
"name": "execute_command",
"description": "Execute a shell command in the current directory",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Shell command to execute"
},
"working_dir": {
"type": "string",
"description": "Optional working directory for the command"
}
},
"required": ["command"]
}
}
}
])
# Always add TODO management tools if enabled
if self.config.enable_todo_list:
tools.extend([
{
"type": "function",
"function": {
"name": "rewrite_todo_list",
"description": "Rewrite the TODO list with new pending items (keeps completed/cancelled items)",
"parameters": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of new TODO items to add as pending"
}
},
"required": ["items"]
}
}
},
{
"type": "function",
"function": {
"name": "update_todo_status",
"description": "Update the status of existing TODO items",
"parameters": {
"type": "object",
"properties": {
"updates": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "TODO item ID"
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed", "cancelled"],
"description": "New status for the item"
}
},
"required": ["id", "status"]
},
"description": "List of TODO items to update with their new status"
}
},
"required": ["updates"]
}
}
}
])
return tools
def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Tuple[Any, Optional[str]]:
"""Execute a tool and return the result with detailed error information"""
start_time = datetime.now()
try:
# Check if it's an MCP tool (prefixed with server name using underscore)
if "_" in tool_name and tool_name in self.mcp_manager.tools:
# Execute MCP tool asynchronously
# Check if there's already a running event loop
try:
loop = asyncio.get_running_loop()
# If we're already in an async context, we can't use asyncio.run()
# Create a new event loop in a separate thread
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(
asyncio.run,
self.mcp_manager.call_tool(tool_name, arguments)
)
result = future.result()
except RuntimeError:
# No event loop running, safe to use asyncio.run()
result = asyncio.run(self.mcp_manager.call_tool(tool_name, arguments))
duration_ms = int((datetime.now() - start_time).total_seconds() * 1000)
return result, None if result.get("success") else result.get("error")
# Built-in tools
if tool_name == "read_file":
result = self._tool_read_file(**arguments)
elif tool_name == "write_file":
result = self._tool_write_file(**arguments)
elif tool_name == "code_interpreter":
result = self._tool_code_interpreter(**arguments)
elif tool_name == "execute_command":
result = self._tool_execute_command(**arguments)
elif tool_name == "rewrite_todo_list":
result = self._tool_rewrite_todo_list(**arguments)
elif tool_name == "update_todo_status":
result = self._tool_update_todo_status(**arguments)
else:
error = f"Unknown tool: {tool_name}"
return {"error": error}, error
duration_ms = int((datetime.now() - start_time).total_seconds() * 1000)
return result, None
except Exception as e:
duration_ms = int((datetime.now() - start_time).total_seconds() * 1000)
error_detail = self._get_detailed_error(e, tool_name, arguments)
if self.config.enable_detailed_errors:
return {"error": error_detail}, error_detail
else:
return {"error": str(e)}, str(e)
def _get_detailed_error(self, exception: Exception, tool_name: str, arguments: Dict[str, Any]) -> str:
"""Get detailed error information for debugging"""
error_parts = [
f"Tool '{tool_name}' failed with {type(exception).__name__}: {str(exception)}",
f"Arguments: {json.dumps(arguments, indent=2)}",
]
if self.verbose:
tb = traceback.format_exc()
error_parts.append(f"Traceback:\n{tb}")
suggestions = self._get_error_suggestions(exception, tool_name)
if suggestions:
error_parts.append(f"Suggestions: {suggestions}")
return "\n".join(error_parts)
def _get_error_suggestions(self, exception: Exception, tool_name: str) -> str:
"""Get suggestions for fixing common errors"""
error_str = str(exception).lower()
exception_type = type(exception).__name__
suggestions = []
if "permission" in error_str or exception_type == "PermissionError":
suggestions.append("Check file/directory permissions")
suggestions.append("Try using a different directory or running with appropriate permissions")
elif "not found" in error_str or "no such file" in error_str or exception_type == "FileNotFoundError":
suggestions.append("Verify the file/directory path exists")
suggestions.append("Check the current working directory")
suggestions.append("Use absolute paths or create the file/directory first")
elif "syntax" in error_str or exception_type == "SyntaxError":
suggestions.append("Check the code syntax")
suggestions.append("Ensure proper indentation and valid Python syntax")
elif "timeout" in error_str:
suggestions.append("The operation took too long")
suggestions.append("Try with simpler input or break into smaller steps")
elif "import" in error_str or exception_type == "ImportError":
suggestions.append("Required module not available in restricted environment")
suggestions.append("Use only built-in Python modules")
return " | ".join(suggestions) if suggestions else ""
# Tool implementations (copied from original agent.py)
def _tool_read_file(self, file_path: str, begin_line: Optional[int] = None,
number_lines: Optional[int] = None) -> Dict[str, Any]:
"""Read file contents with optional line-based reading"""
try:
if not os.path.isabs(file_path):
file_path = os.path.join(self.current_directory, file_path)
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
# Check if it's a binary file
try:
with open(file_path, 'rb') as f:
chunk = f.read(1024)
if b'\x00' in chunk:
return {
"success": False,
"error": "Cannot read binary file. This tool only supports text files.",
"file_path": file_path,
"is_binary": True
}
try:
chunk.decode('utf-8')
except UnicodeDecodeError:
return {
"success": False,
"error": "File is not a valid text file (encoding error).",
"file_path": file_path,
"is_binary": True
}
except Exception as e:
raise
with open(file_path, 'r', encoding='utf-8') as f:
if begin_line is not None or number_lines is not None:
all_lines = f.readlines()
total_lines = len(all_lines)
start_line = (begin_line - 1) if begin_line is not None else 0
if start_line < 0:
start_line = 0
if start_line >= total_lines:
return {
"success": False,
"error": f"begin_line {begin_line} is beyond file length ({total_lines} lines)",
"file_path": file_path,
"total_lines": total_lines
}
if number_lines is not None:
end_line = min(start_line + number_lines, total_lines)
else:
end_line = total_lines
selected_lines = all_lines[start_line:end_line]
content = ''.join(selected_lines)
stat = os.stat(file_path)
return {
"success": True,
"file_path": file_path,
"content": content,
"size_bytes": stat.st_size,
"total_lines": total_lines,
"begin_line": start_line + 1,
"end_line": end_line,
"lines_read": len(selected_lines),
"partial_read": True
}
else:
content = f.read()
stat = os.stat(file_path)
return {
"success": True,
"file_path": file_path,
"content": content,
"size_bytes": stat.st_size,
"lines": len(content.splitlines()),
"partial_read": False
}
except Exception as e:
raise
def _tool_write_file(self, file_path: str, content: str) -> Dict[str, Any]:
"""Write content to file"""
try:
if not os.path.isabs(file_path):
file_path = os.path.join(self.current_directory, file_path)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
return {
"success": True,
"file_path": file_path,
"bytes_written": len(content.encode('utf-8')),
"lines_written": len(content.splitlines())
}
except Exception as e:
raise
def _tool_code_interpreter(self, code: str) -> Dict[str, Any]:
"""Execute Python code in restricted environment"""
try:
import io
import contextlib
output_buffer = io.StringIO()
error_buffer = io.StringIO()
# Use one namespace so functions defined by the snippet can resolve
# names assigned earlier in the same snippet.
exec_ns = {}
with contextlib.redirect_stdout(output_buffer), contextlib.redirect_stderr(error_buffer):
exec(code, exec_ns)
stdout = output_buffer.getvalue()
stderr = error_buffer.getvalue()
return {
"success": True,
"stdout": stdout,
"stderr": stderr,
}
except Exception as e:
raise
def _tool_execute_command(self, command: str, working_dir: Optional[str] = None) -> Dict[str, Any]:
"""Execute shell command"""
try:
if working_dir is None:
working_dir = self.current_directory
elif not os.path.isabs(working_dir):
working_dir = os.path.join(self.current_directory, working_dir)
if command.strip().startswith('cd '):
new_dir = command.strip()[3:].strip()
if not os.path.isabs(new_dir):
new_dir = os.path.join(self.current_directory, new_dir)
if os.path.isdir(new_dir):
self.current_directory = os.path.abspath(new_dir)
return {
"success": True,
"command": command,
"output": f"Changed directory to: {self.current_directory}",
"return_code": 0
}
else:
raise FileNotFoundError(f"Directory not found: {new_dir}")
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
cwd=working_dir,
timeout=30
)
return {
"success": result.returncode == 0,
"command": command,
"output": result.stdout,
"error": result.stderr if result.stderr else None,
"return_code": result.returncode,
"working_dir": working_dir
}
except subprocess.TimeoutExpired:
raise TimeoutError(f"Command timed out after 30 seconds: {command}")
except Exception as e:
raise
def _tool_rewrite_todo_list(self, items: List[str]) -> Dict[str, Any]:
"""Rewrite TODO list with new pending items"""
kept_items = [
item for item in self.todo_list
if item.status in [TodoStatus.COMPLETED, TodoStatus.CANCELLED]
]
new_items = []
for content in items:
new_items.append(TodoItem(
id=self.next_todo_id,
content=content,
status=TodoStatus.PENDING
))
self.next_todo_id += 1
self.todo_list = kept_items + new_items
return {
"success": True,
"kept_items": len(kept_items),
"new_items": len(new_items),
"total_items": len(self.todo_list)
}
def _tool_update_todo_status(self, updates: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Update status of TODO items"""
updated_count = 0
for update in updates:
item_id = update["id"]
new_status = TodoStatus(update["status"])
for item in self.todo_list:
if item.id == item_id:
item.status = new_status
item.updated_at = datetime.now().isoformat()
updated_count += 1
break
return {
"success": True,
"updated_items": updated_count,
"total_items": len(self.todo_list)
}
def handle_event(self, event: Event, max_iterations: int = 20) -> Dict[str, Any]:
"""
Handle an incoming event and generate a response
Args:
event: The event to handle
max_iterations: Maximum number of tool call iterations
Returns:
Response with agent's actions and final answer
"""
logger.info(f"\n{'='*80}")
logger.info(f"📥 RECEIVED EVENT")
logger.info(f"{'='*80}")
logger.info(f"Event Type: {event.event_type.value}")
logger.info(f"Timestamp: {event.timestamp}")
logger.info(f"Content: {event.content}")
if event.metadata:
logger.info(f"Metadata: {json.dumps(event.metadata, indent=2)}")
logger.info(f"{'='*80}\n")
# Convert event to user message
user_message = event.to_user_message()
# Add timestamp prefix if enabled
if self.config.enable_timestamps:
timestamp_prefix = f"[{self._get_timestamp()}] "
user_message = timestamp_prefix + user_message
# Update last user interaction time for external events
if event.event_type in [EventType.WEB_MESSAGE, EventType.IM_MESSAGE, EventType.EMAIL_REPLY]:
self.last_user_interaction = datetime.now()
# Add user message to conversation
self.conversation_history.append({"role": "user", "content": user_message})
iteration = 0
final_answer = None
while iteration < max_iterations:
iteration += 1
logger.info(f"Iteration {iteration}/{max_iterations}")
self._advance_simulated_time(seconds=5)
self._save_trajectory(iteration)
try:
messages_to_send = self.conversation_history.copy()
system_hint = self._get_system_hint()
if system_hint:
messages_to_send.append({"role": "user", "content": system_hint})
response = self.client.chat.completions.create(
model=self.model,
messages=messages_to_send,
tools=self._get_tools_description(),
tool_choice="auto",
temperature=_reasoning_safe_temperature(self.model, self.config.temperature),
max_tokens=self.config.max_tokens
)
message = response.choices[0].message
has_tool_calls = bool(getattr(message, "tool_calls", None))
# Terminal path: a text reply with no tool calls ends the loop,
# even without the FINAL ANSWER: marker (e.g. a plain "hi"
# reply). Previously only "FINAL ANSWER:" broke the loop, so
# plain replies were re-sent for up to max_iterations.
if not has_tool_calls:
self.conversation_history.append(message.model_dump())
content = (message.content or "").strip()
if content:
final_answer = (content.split("FINAL ANSWER:", 1)[1].strip()
if "FINAL ANSWER:" in content else content)
logger.info(f"✅ Terminal text response (no tool calls); final answer: {final_answer[:100]}...")
else:
logger.warning("Empty model response with no tool calls; "
"stopping to avoid burning remaining iterations")
self._save_trajectory(iteration, final_answer)
break
if has_tool_calls:
self.conversation_history.append(message.model_dump())
for tool_call in message.tool_calls:
function_name = tool_call.function.name
raw_args = tool_call.function.arguments or "{}"
try:
function_args = json.loads(raw_args)
except json.JSONDecodeError as exc:
# Malformed/truncated arguments must not abort the
# turn: the assistant message with tool_calls is
# already in the history, so bailing out here would
# leave this tool_call_id unanswered and every later
# request would be rejected by the provider.
err = (f"Invalid tool arguments (not valid JSON): {exc}. "
f"Raw arguments: {raw_args[:500]}")
logger.warning(f" ❌ {err}")
self.tool_calls.append(ToolCall(
tool_name=function_name, arguments={}, error=err))
self.conversation_history.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps({"error": err})
})
continue
if self.config.enable_tool_counter:
self.tool_call_counts[function_name] = self.tool_call_counts.get(function_name, 0) + 1
call_number = self.tool_call_counts[function_name]
else:
call_number = 1
logger.info(f"🔧 Executing tool: {function_name} (call #{call_number})")
args_str = json.dumps(function_args)
if len(args_str) > 200:
logger.info(f" 📥 Args: {args_str[:200]}...")
else:
logger.info(f" 📥 Args: {args_str}")
result, error = self._execute_tool(function_name, function_args)
if error:
error_preview = str(error).replace('\n', ' ')[:150]
logger.info(f" ❌ Error: {error_preview}")
else:
if isinstance(result, dict):
if result.get('success'):
if 'output' in result and result['output']:
output_preview = str(result['output']).replace('\n', ' ')[:100]
logger.info(f" ✅ Success: {output_preview}...")
elif 'content' in result:
if result.get('partial_read'):
logger.info(f" ✅ Success: Read lines {result.get('begin_line', 1)}-{result.get('end_line', 0)} "
f"({result.get('lines_read', 0)} lines) from {result.get('total_lines', 0)} total")
else:
logger.info(f" ✅ Success: Read {result.get('lines', 0)} lines, {result.get('size_bytes', 0)} bytes")
elif 'file_path' in result:
logger.info(f" ✅ Success: File operation on {result['file_path']}")
else:
logger.info(f" ✅ Success: Operation completed")
elif result.get('success') is False:
if result.get('is_binary'):
logger.info(f" ⚠️ Binary file detected: {result.get('file_path', 'unknown')}")
else:
logger.info(f" ⚠️ Failed: {result.get('error', 'Unknown error')[:100]}")
else:
logger.info(f" ✅ Success: Operation completed")
else:
result_preview = str(result).replace('\n', ' ')[:150]
logger.info(f" ✅ Result: {result_preview}")
tool_call_record = ToolCall(
tool_name=function_name,
arguments=function_args,
result=result if not error else None,
error=error,
call_number=call_number
)
self.tool_calls.append(tool_call_record)
tool_content = json.dumps(result)
metadata_parts = []
if self.config.enable_timestamps:
metadata_parts.append(f"[{self._get_timestamp()}]")
if self.config.enable_tool_counter:
metadata_parts.append(f"[Tool call #{call_number} for '{function_name}']")
if metadata_parts:
tool_content = " ".join(metadata_parts) + "\n" + tool_content
self.conversation_history.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_content
})
# If the same turn also tagged FINAL ANSWER: (unusual with
# tool calls), still stop after recording the tool results.
if message.content and "FINAL ANSWER:" in message.content:
final_answer = message.content.split("FINAL ANSWER:", 1)[1].strip()
logger.info(f"✅ Final answer found alongside tool calls: {final_answer[:100]}...")
self._save_trajectory(iteration, final_answer)
break
except Exception as e:
logger.error(f"Error during event handling: {str(e)}")
self._save_trajectory(iteration)
return {
"success": False,
"error": str(e),
"tool_calls": self.tool_calls,
"iterations": iteration,
"trajectory_file": self.config.trajectory_file if self.config.save_trajectory else None
}
self._save_trajectory(iteration, final_answer)
logger.info(f"\n{'='*80}")
logger.info(f"📤 AGENT RESPONSE")
logger.info(f"{'='*80}")
if final_answer:
logger.info(f"Response: {final_answer}")
else:
logger.info(f"Response: Task processing completed ({iteration} iterations)")
logger.info(f"Tool Calls: {len(self.tool_calls)}")
logger.info(f"{'='*80}\n")
return {
"final_answer": final_answer,
"tool_calls": self.tool_calls,
"todo_list": [
{
"id": item.id,
"content": item.content,
"status": item.status.value
}
for item in self.todo_list
],
"iterations": iteration,
"success": final_answer is not None,
"trajectory_file": self.config.trajectory_file if self.config.save_trajectory else None
}
def reset(self):
"""Reset the agent's state"""
self.tool_call_counts = {}
self.tool_calls = []
self.todo_list = []
self.next_todo_id = 1
self.current_directory = os.getcwd()
self.simulated_time = datetime.now()
self.last_user_interaction = datetime.now()
self.background_processes = {}
self._init_system_prompt()
logger.info("Agent state reset")
def __del__(self):
"""Cleanup when agent is destroyed"""
if hasattr(self, 'mcp_manager') and self.mcp_manager.sessions:
try:
asyncio.run(self.mcp_manager.disconnect_all())
except Exception as e:
logger.warning(f"Error disconnecting MCP servers: {e}")