Files
2026-07-13 12:49:17 +08:00

259 lines
10 KiB
Python

# Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
# SPDX-License-Identifier: MIT
"""TraeAgent for software engineering tasks."""
import asyncio
import contextlib
import os
import subprocess
from typing import override
from trae_agent.agent.agent_basics import AgentError, AgentExecution
from trae_agent.agent.base_agent import BaseAgent
from trae_agent.prompt.agent_prompt import TRAE_AGENT_SYSTEM_PROMPT
from trae_agent.tools import tools_registry
from trae_agent.tools.base import Tool, ToolResult
from trae_agent.utils.config import MCPServerConfig, TraeAgentConfig
from trae_agent.utils.llm_clients.llm_basics import LLMMessage, LLMResponse
from trae_agent.utils.mcp_client import MCPClient
TraeAgentToolNames = [
"str_replace_based_edit_tool",
"sequentialthinking",
"json_edit_tool",
"task_done",
"bash",
]
class TraeAgent(BaseAgent):
"""Trae Agent specialized for software engineering tasks."""
def __init__(
self,
trae_agent_config: TraeAgentConfig,
docker_config: dict | None = None,
docker_keep: bool = True,
):
"""Initialize TraeAgent.
Args:
config: Configuration object containing model parameters and other settings.
Required if llm_client is not provided.
llm_client: Optional pre-configured LLMClient instance.
If provided, it will be used instead of creating a new one from config.
docker_config: Optional configuration for running in a Docker environment.
"""
self.project_path: str = ""
self.base_commit: str | None = None
self.must_patch: str = "false"
self.patch_path: str | None = None
self.mcp_servers_config: dict[str, MCPServerConfig] | None = (
trae_agent_config.mcp_servers_config if trae_agent_config.mcp_servers_config else None
)
self.allow_mcp_servers: list[str] | None = (
trae_agent_config.allow_mcp_servers if trae_agent_config.allow_mcp_servers else []
)
self.mcp_tools: list[Tool] = []
self.mcp_clients: list[MCPClient] = [] # Keep track of MCP clients for cleanup
self.docker_config = docker_config
super().__init__(
agent_config=trae_agent_config, docker_config=docker_config, docker_keep=docker_keep
)
async def initialise_mcp(self):
"""Async factory to create and initialize TraeAgent."""
await self.discover_mcp_tools()
if self.mcp_tools:
self._tools.extend(self.mcp_tools)
async def discover_mcp_tools(self):
if self.mcp_servers_config:
for mcp_server_name, mcp_server_config in self.mcp_servers_config.items():
if self.allow_mcp_servers is None:
return
if mcp_server_name not in self.allow_mcp_servers:
continue
mcp_client = MCPClient()
try:
await mcp_client.connect_and_discover(
mcp_server_name,
mcp_server_config,
self.mcp_tools,
self._llm_client.provider.value,
)
# Store client for later cleanup
self.mcp_clients.append(mcp_client)
except Exception:
# Clean up failed client
with contextlib.suppress(Exception):
await mcp_client.cleanup(mcp_server_name)
continue
except asyncio.CancelledError:
# If the task is cancelled, clean up and skip this server
with contextlib.suppress(Exception):
await mcp_client.cleanup(mcp_server_name)
continue
else:
return
@override
def new_task(
self,
task: str,
extra_args: dict[str, str] | None = None,
tool_names: list[str] | None = None,
):
"""Create a new task."""
self._task: str = task
if tool_names is None and len(self._tools) == 0:
tool_names = TraeAgentToolNames
# Get the model provider from the LLM client
provider = self._model_config.model_provider.provider
self._tools: list[Tool] = [
tools_registry[tool_name](model_provider=provider) for tool_name in tool_names
]
# self._tool_caller: ToolExecutor = ToolExecutor(self._tools)
self._initial_messages: list[LLMMessage] = []
self._initial_messages.append(LLMMessage(role="system", content=self.get_system_prompt()))
user_message = ""
if not extra_args:
raise AgentError("Project path and issue information are required.")
if "project_path" not in extra_args:
raise AgentError("Project path is required")
self.project_path = extra_args.get("project_path", "")
if self.docker_config:
user_message += r"[Project root path]:\workspace\n\n"
else:
user_message += f"[Project root path]:\n{self.project_path}\n\n"
if "issue" in extra_args:
user_message += f"[Problem statement]: We're currently solving the following issue within our repository. Here's the issue text:\n{extra_args['issue']}\n"
optional_attrs_to_set = ["base_commit", "must_patch", "patch_path"]
for attr in optional_attrs_to_set:
if attr in extra_args:
setattr(self, attr, extra_args[attr])
self._initial_messages.append(LLMMessage(role="user", content=user_message))
# If trajectory recorder is set, start recording
if self._trajectory_recorder:
self._trajectory_recorder.start_recording(
task=task,
provider=self._llm_client.provider.value,
model=self._model_config.model,
max_steps=self._max_steps,
)
@override
async def execute_task(self) -> AgentExecution:
"""Execute the task and finalize trajectory recording."""
execution = await super().execute_task()
# Finalize trajectory recording if recorder is available
if self._trajectory_recorder:
self._trajectory_recorder.finalize_recording(
success=execution.success, final_result=execution.final_result
)
if self.patch_path is not None:
with open(self.patch_path, "w") as patch_f:
_ = patch_f.write(self.get_git_diff())
return execution
def get_system_prompt(self) -> str:
"""Get the system prompt for TraeAgent."""
return TRAE_AGENT_SYSTEM_PROMPT
@override
def reflect_on_result(self, tool_results: list[ToolResult]) -> str | None:
return None
def get_git_diff(self) -> str:
"""Get the git diff of the project."""
pwd = os.getcwd()
if not os.path.isdir(self.project_path):
return ""
os.chdir(self.project_path)
try:
if not self.base_commit:
stdout = subprocess.check_output(["git", "--no-pager", "diff"]).decode()
else:
stdout = subprocess.check_output(
["git", "--no-pager", "diff", self.base_commit, "HEAD"]
).decode()
except (subprocess.CalledProcessError, FileNotFoundError):
stdout = ""
finally:
os.chdir(pwd)
return stdout
# Copyright (c) 2024 paul-gauthier
# SPDX-License-Identifier: Apache-2.0
# Original remove_patches_to_tests function was released under Apache-2.0 License, with the full license text
# available at https://github.com/Aider-AI/aider-swe-bench/blob/6e98cd6c3b2cbcba12976d6ae1b07f847480cb74/LICENSE.txt
# Original function is at https://github.com/Aider-AI/aider-swe-bench/blob/6e98cd6c3b2cbcba12976d6ae1b07f847480cb74/tests.py#L45
def remove_patches_to_tests(self, model_patch: str) -> str:
"""
Remove any changes to the tests directory from the provided patch.
This is to ensure that the model_patch does not disturb the repo's
tests when doing acceptance testing with the `test_patch`.
"""
lines = model_patch.splitlines(keepends=True)
filtered_lines: list[str] = []
test_patterns = ["/test/", "/tests/", "/testing/", "test_", "tox.ini"]
is_tests = False
for line in lines:
if line.startswith("diff --git a/"):
target_path = line.split()[-1]
is_tests = target_path.startswith("b/") and any(
p in target_path for p in test_patterns
)
if not is_tests:
filtered_lines.append(line)
return "".join(filtered_lines)
@override
def llm_indicates_task_completed(self, llm_response: LLMResponse) -> bool:
"""Check if the LLM indicates that the task is completed."""
if llm_response.tool_calls is None:
return False
return any(tool_call.name == "task_done" for tool_call in llm_response.tool_calls)
@override
def _is_task_completed(self, llm_response: LLMResponse) -> bool:
"""Enhanced task completion detection."""
if self.must_patch == "true":
model_patch = self.get_git_diff()
patch = self.remove_patches_to_tests(model_patch)
if not patch.strip():
return False
return True
@override
def task_incomplete_message(self) -> str:
"""Return a message indicating that the task is incomplete."""
return "ERROR! Your Patch is empty. Please provide a patch that fixes the problem."
@override
async def cleanup_mcp_clients(self) -> None:
"""Clean up all MCP clients to prevent async context leaks."""
for client in self.mcp_clients:
with contextlib.suppress(Exception):
# Use a generic server name for cleanup since we don't track which server each client is for
await client.cleanup("cleanup")
self.mcp_clients.clear()