fix(backend/chat): Add Read tool for SDK oversized tool results

The Claude Agent SDK saves tool results exceeding its token limit to
files and instructs the agent to read them back with a Read tool. Our
MCP server didn't have this tool, breaking the agent on large results
like run_block output (117K+ chars).

Changes:
- Add a Read tool to the MCP server (restricted to /root/.claude/)
- Register it in COPILOT_TOOL_NAMES so the SDK can use it
- Add safety-net truncation at 500K chars for extreme cases
- Clean up SDK tool-result files after each client session
This commit is contained in:
Zamil Majdy
2026-02-10 16:53:04 +04:00
parent 3c92a96504
commit f562d9a277
2 changed files with 119 additions and 18 deletions

View File

@@ -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"

View File

@@ -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())