diff --git a/backend/agent/tools/sb_files_tool.py b/backend/agent/tools/sb_files_tool.py
index 0fd83a79..3788dcda 100644
--- a/backend/agent/tools/sb_files_tool.py
+++ b/backend/agent/tools/sb_files_tool.py
@@ -1,12 +1,13 @@
from agentpress.tool import ToolResult, openapi_schema, xml_schema
-from sandbox.tool_base import SandboxToolsBase
+from sandbox.tool_base import SandboxToolsBase
from utils.files_utils import should_exclude_file, clean_path
from agentpress.thread_manager import ThreadManager
from utils.logger import logger
from utils.config import config
import os
import json
-import httpx
+import litellm
+import openai
import asyncio
from typing import Optional
@@ -367,78 +368,74 @@ class SandboxFilesTool(SandboxToolsBase):
except Exception as e:
return self.fail_response(f"Error deleting file: {str(e)}")
- async def _call_morph_api(self, file_content: str, code_edit: str, instructions: str, file_path: str) -> Optional[str]:
- """Call Morph API to apply edits to file content"""
+ async def _call_morph_api(self, file_content: str, code_edit: str, instructions: str, file_path: str) -> tuple[Optional[str], Optional[str]]:
+ """
+ Call Morph API to apply edits to file content.
+ Returns a tuple (new_content, error_message).
+ On success, error_message is None.
+ On failure, new_content is None.
+ """
try:
morph_api_key = getattr(config, 'MORPH_API_KEY', None) or os.getenv('MORPH_API_KEY')
openrouter_key = getattr(config, 'OPENROUTER_API_KEY', None) or os.getenv('OPENROUTER_API_KEY')
- api_key = None
- base_url = None
-
+ messages = [{
+ "role": "user",
+ "content": f"{instructions}\n{file_content}
\n{code_edit}"
+ }]
+
+ response = None
if morph_api_key:
- api_key = morph_api_key
- base_url = "https://api.morphllm.com/v1"
- logger.debug("Using Morph API for file editing.")
+ logger.debug("Using direct Morph API for file editing.")
+ client = openai.AsyncOpenAI(
+ api_key=morph_api_key,
+ base_url="https://api.morphllm.com/v1"
+ )
+ response = await client.chat.completions.create(
+ model="morph-v3-large",
+ messages=messages,
+ temperature=0.0,
+ timeout=30.0
+ )
elif openrouter_key:
- api_key = openrouter_key
- base_url = "https://openrouter.ai/api/v1"
- logger.debug("Morph API key not set, falling back to OpenRouter for file editing.")
+ logger.debug("Morph API key not set, falling back to OpenRouter for file editing via litellm.")
+ response = await litellm.acompletion(
+ model="openrouter/morph/morph-v3-large",
+ messages=messages,
+ api_key=openrouter_key,
+ api_base="https://openrouter.ai/api/v1",
+ temperature=0.0,
+ timeout=30.0
+ )
+ else:
+ error_msg = "No Morph or OpenRouter API key found, cannot perform AI edit."
+ logger.warning(error_msg)
+ return None, error_msg
- if not api_key:
- logger.warning("No Morph or OpenRouter API key found, falling back to traditional editing")
- return None
-
- headers = {
- "Authorization": f"Bearer {api_key}",
- "Content-Type": "application/json",
- "HTTP-Referer": "https://suna.ai",
- "X-Title": "Suna AI Agent"
- }
-
- # Prepare the request for Morph's fast apply using the exact format from their docs
- payload = {
- "model": "morph/morph-code-edit",
- "messages": [
- {
- "role": "user",
- "content": f"\n{instructions}\n\n\n\n{file_content}\n
\n\n\n{code_edit}\n"
- }
- ],
- "max_tokens": 16384,
- "temperature": 0.0
- }
-
- async with httpx.AsyncClient(timeout=30.0) as client:
- response = await client.post(f"{base_url}/chat/completions", json=payload, headers=headers)
- response.raise_for_status()
+ if response and response.choices and len(response.choices) > 0:
+ content = response.choices[0].message.content.strip()
+
+ # Extract code block if wrapped in markdown
+ if content.startswith("```") and content.endswith("```"):
+ lines = content.split('\n')
+ if len(lines) > 2:
+ content = '\n'.join(lines[1:-1])
- result = response.json()
+ return content, None
+ else:
+ error_msg = f"Invalid response from Morph/OpenRouter API: {response}"
+ logger.error(error_msg)
+ return None, error_msg
- if result.get("choices") and len(result["choices"]) > 0:
- content = result["choices"][0]["message"]["content"].strip()
-
- # Extract code block if wrapped in markdown
- if content.startswith("```") and content.endswith("```"):
- lines = content.split('\n')
- if len(lines) > 2:
- # Remove first line (```language) and last line (```)
- content = '\n'.join(lines[1:-1])
-
- return content
- else:
- logger.error("Invalid response from Morph API")
- return None
-
- except httpx.TimeoutException:
- logger.error("Morph API request timed out")
- return None
- except httpx.HTTPStatusError as e:
- logger.error(f"Morph API returned error: {e.response.status_code}")
- return None
except Exception as e:
- logger.error(f"Error calling Morph API: {str(e)}")
- return None
+ error_message = f"AI model call for file edit failed. Exception: {str(e)}"
+ # Try to get more details from the exception if it's an API error
+ if hasattr(e, 'response') and hasattr(e.response, 'text'):
+ error_message += f"\n\nAPI Response Body:\n{e.response.text}"
+ elif hasattr(e, 'body'): # litellm sometimes puts it in body
+ error_message += f"\n\nAPI Response Body:\n{e.body}"
+ logger.error(f"Error calling Morph/OpenRouter API: {error_message}", exc_info=True)
+ return None, error_message
@openapi_schema({
"type": "function",
@@ -513,39 +510,28 @@ def authenticate_user(username, password):
# Try Morph AI editing first
logger.info(f"Attempting AI-powered edit for file '{target_file}' with instructions: {instructions[:100]}...")
- new_content = await self._call_morph_api(original_content, code_edit, instructions, target_file)
+ new_content, error_message = await self._call_morph_api(original_content, code_edit, instructions, target_file)
+
+ if error_message:
+ return self.fail_response(error_message)
+
+ if new_content is None:
+ # This case should ideally be covered by error_message, but as a safeguard:
+ return self.fail_response("AI editing failed for an unknown reason. The model returned no content.")
+
+ if new_content == original_content:
+ return self.fail_response(f"AI editing resulted in no changes to the file '{target_file}'.")
+
+ # AI editing successful
+ await self.sandbox.fs.upload_file(new_content.encode(), full_path)
- if new_content and new_content != original_content:
- # AI editing successful
- await self.sandbox.fs.upload_file(new_content.encode(), full_path)
-
- # Show snippet of changes
- original_lines = original_content.split('\n')
- new_lines = new_content.split('\n')
-
- # Find first differing line for context
- diff_line = 0
- for i, (old_line, new_line) in enumerate(zip(original_lines, new_lines)):
- if old_line != new_line:
- diff_line = i
- break
-
- # Show context around the change
- start_line = max(0, diff_line - self.SNIPPET_LINES)
- end_line = min(len(new_lines), diff_line + self.SNIPPET_LINES + 1)
- snippet = '\n'.join(new_lines[start_line:end_line])
-
- message = f"File '{target_file}' edited successfully using AI-powered editing."
- if snippet:
- message += f"\n\nPreview of changes (around line {diff_line + 1}):\n{snippet}"
-
- return self.success_response(message)
-
- else:
- # No changes could be made
- return self.fail_response(f"AI editing was unable to apply the requested changes. The edit may be unclear or the file content may not match the expected format.")
+
+ message = f"File '{target_file}' edited successfully."
+
+ return self.success_response(message)
except Exception as e:
+ logger.error(f"Unhandled error in edit_file: {str(e)}", exc_info=True)
return self.fail_response(f"Error editing file: {str(e)}")
# @openapi_schema({
@@ -648,5 +634,4 @@ def authenticate_user(username, password):
# except UnicodeDecodeError:
# return self.fail_response(f"File '{file_path}' appears to be binary and cannot be read as text")
# except Exception as e:
- # return self.fail_response(f"Error reading file: {str(e)}")
-
+ # return self.fail_response(f"Error reading file: {str(e)}")
\ No newline at end of file