diff --git a/autogpt_platform/backend/backend/api/features/chat/sdk/service.py b/autogpt_platform/backend/backend/api/features/chat/sdk/service.py index 7578cfa221..112aca4ab8 100644 --- a/autogpt_platform/backend/backend/api/features/chat/sdk/service.py +++ b/autogpt_platform/backend/backend/api/features/chat/sdk/service.py @@ -48,6 +48,22 @@ config = ChatConfig() # Set to hold background tasks to prevent garbage collection _background_tasks: set[asyncio.Task[Any]] = set() +# SDK tool-results directory pattern +_SDK_TOOL_RESULTS_GLOB = "/root/.claude/projects/*/tool-results/*" + + +def _cleanup_sdk_tool_results() -> None: + """Remove SDK tool-result files to prevent disk accumulation.""" + import glob + import os + + for path in glob.glob(_SDK_TOOL_RESULTS_GLOB): + try: + os.remove(path) + except OSError: + pass + + DEFAULT_SYSTEM_PROMPT = """You are **Otto**, an AI Co-Pilot for AutoGPT and a Forward-Deployed Automation Engineer serving small business owners. Your mission is to help users automate business tasks with AI by delivering tangible value through working automations—not through documentation or lengthy explanations. Here is everything you know about the current user from previous interactions: @@ -414,6 +430,9 @@ async def stream_chat_completion_sdk( ) and not has_appended_assistant: session.messages.append(assistant_response) + # Clean up SDK tool-result files to avoid accumulation + _cleanup_sdk_tool_results() + except ImportError: logger.warning( "[SDK] claude-agent-sdk not available, using Anthropic fallback" diff --git a/autogpt_platform/backend/backend/api/features/chat/sdk/tool_adapter.py b/autogpt_platform/backend/backend/api/features/chat/sdk/tool_adapter.py index bd2fc77481..ab2a56adef 100644 --- a/autogpt_platform/backend/backend/api/features/chat/sdk/tool_adapter.py +++ b/autogpt_platform/backend/backend/api/features/chat/sdk/tool_adapter.py @@ -6,6 +6,7 @@ into in-process MCP tools that can be used with the Claude Agent SDK. import json import logging +import os import uuid from contextvars import ContextVar from typing import Any @@ -16,6 +17,13 @@ from backend.api.features.chat.tools.base import BaseTool logger = logging.getLogger(__name__) +# Safety-net truncation for extreme tool results (e.g. infinite loops). +# Normal oversized results are handled by the SDK via the Read tool. +MAX_TOOL_RESULT_CHARS = 500_000 + +# Allowed base directory for the Read tool (SDK saves oversized tool results here) +_SDK_TOOL_RESULTS_DIR = "/root/.claude/" + # MCP server naming - the SDK prefixes tool names as "mcp__{server_name}__{tool}" MCP_SERVER_NAME = "copilot" MCP_TOOL_PREFIX = f"mcp__{MCP_SERVER_NAME}__" @@ -93,17 +101,26 @@ def create_tool_handler(base_tool: BaseTool): ) # The result is a StreamToolOutputAvailable, extract the output + text = ( + result.output + if isinstance(result.output, str) + else json.dumps(result.output) + ) + + # Safety-net truncation for extreme results (e.g. infinite loops). + # Normal oversized results are handled by the SDK + our Read tool. + if len(text) > MAX_TOOL_RESULT_CHARS: + logger.warning( + f"Tool {base_tool.name} result truncated: " + f"{len(text)} -> {MAX_TOOL_RESULT_CHARS} chars" + ) + text = ( + text[:MAX_TOOL_RESULT_CHARS] + + f"\n\n... [truncated — {len(text) - MAX_TOOL_RESULT_CHARS} chars omitted]" + ) + return { - "content": [ - { - "type": "text", - "text": ( - result.output - if isinstance(result.output, str) - else json.dumps(result.output) - ), - } - ], + "content": [{"type": "text", "text": text}], "isError": not result.success, } @@ -169,6 +186,66 @@ def get_tool_handlers() -> dict[str, Any]: return handlers +async def _read_file_handler(args: dict[str, Any]) -> dict[str, Any]: + """Read a file with optional offset/limit. Restricted to SDK working directory. + + After reading, the file is deleted to prevent accumulation in long-running pods. + """ + file_path = args.get("file_path", "") + offset = args.get("offset", 0) + limit = args.get("limit", 2000) + + # Security: only allow reads under the SDK's working directory + real_path = os.path.realpath(file_path) + if not real_path.startswith(_SDK_TOOL_RESULTS_DIR): + return { + "content": [{"type": "text", "text": f"Access denied: {file_path}"}], + "isError": True, + } + + try: + with open(real_path) as f: + lines = f.readlines() + selected = lines[offset : offset + limit] + content = "".join(selected) + return {"content": [{"type": "text", "text": content}], "isError": False} + except FileNotFoundError: + return { + "content": [{"type": "text", "text": f"File not found: {file_path}"}], + "isError": True, + } + except Exception as e: + return { + "content": [{"type": "text", "text": f"Error reading file: {e}"}], + "isError": True, + } + + +_READ_TOOL_NAME = "Read" +_READ_TOOL_DESCRIPTION = ( + "Read a file from the local filesystem. " + "Use offset and limit to read specific line ranges for large files." +) +_READ_TOOL_SCHEMA = { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The absolute path to the file to read", + }, + "offset": { + "type": "integer", + "description": "Line number to start reading from (0-indexed). Default: 0", + }, + "limit": { + "type": "integer", + "description": "Number of lines to read. Default: 2000", + }, + }, + "required": ["file_path"], +} + + # Create the MCP server configuration def create_copilot_mcp_server(): """Create an in-process MCP server configuration for CoPilot tools. @@ -186,21 +263,22 @@ def create_copilot_mcp_server(): sdk_tools = [] for tool_name, base_tool in TOOL_REGISTRY.items(): - # Get the handler handler = create_tool_handler(base_tool) - - # Create the decorated tool - # The @tool decorator expects (name, description, schema) - # Pass full JSON schema with type, properties, and required decorated = tool( tool_name, base_tool.description, _build_input_schema(base_tool), )(handler) - sdk_tools.append(decorated) - # Create the MCP server + # Add the Read tool so the SDK can read back oversized tool results + read_tool = tool( + _READ_TOOL_NAME, + _READ_TOOL_DESCRIPTION, + _READ_TOOL_SCHEMA, + )(_read_file_handler) + sdk_tools.append(read_tool) + server = create_sdk_mcp_server( name=MCP_SERVER_NAME, version="1.0.0", @@ -215,7 +293,11 @@ def create_copilot_mcp_server(): # List of tool names for allowed_tools configuration -COPILOT_TOOL_NAMES = [f"{MCP_TOOL_PREFIX}{name}" for name in TOOL_REGISTRY.keys()] +# Include the Read tool so the SDK can use it for oversized tool results +COPILOT_TOOL_NAMES = [ + *[f"{MCP_TOOL_PREFIX}{name}" for name in TOOL_REGISTRY.keys()], + f"{MCP_TOOL_PREFIX}{_READ_TOOL_NAME}", +] # Also export the raw tool names for flexibility RAW_TOOL_NAMES = list(TOOL_REGISTRY.keys())