open-swe/apps/agent/agent/server.py

927 lines
32 KiB
Python

"""Main entry point and CLI loop for Open SWE agent."""
# ruff: noqa: E402
# Suppress deprecation warnings from langchain_core (e.g., Pydantic V1 on Python 3.14+)
# ruff: noqa: E402
import logging
import os
import warnings
from typing import Any
logger = logging.getLogger(__name__)
from langchain.agents.middleware import AgentState, after_agent, after_model, before_model
from langgraph.config import get_config, get_store
from langgraph.graph.state import RunnableConfig
from langgraph.pregel import Pregel
from langgraph.runtime import Runtime
from langgraph_sdk import get_client
warnings.filterwarnings("ignore", module="langchain_core._api.deprecation")
import asyncio
# Suppress Pydantic v1 compatibility warnings from langchain on Python 3.14+
warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarning)
# Now safe to import agent (which imports LangChain modules)
from deepagents import create_deep_agent
from deepagents.backends.sandbox import SandboxBackendProtocol
# Local import for encryption
from langchain_anthropic import ChatAnthropic
from .encryption import decrypt_token
from .prompt import construct_system_prompt
from .tools import fetch_url, http_request
def _get_langsmith_api_key() -> str | None:
"""Get LangSmith API key from environment.
Checks LANGSMITH_API_KEY first, then falls back to LANGSMITH_API_KEY_PROD
for LangGraph Cloud deployments where LANGSMITH_API_KEY is reserved.
"""
return os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGSMITH_API_KEY_PROD")
def _get_sandbox_template_config() -> tuple[str | None, str | None]:
"""Get sandbox template configuration from environment.
Returns:
Tuple of (template_name, template_image) from environment variables.
Values are None if not set in environment.
"""
template_name = os.environ.get("DEFAULT_SANDBOX_TEMPLATE_NAME")
template_image = os.environ.get("DEFAULT_SANDBOX_TEMPLATE_IMAGE")
return template_name, template_image
def _create_langsmith_sandbox(
sandbox_id: str | None = None,
) -> SandboxBackendProtocol:
"""Create or connect to a LangSmith sandbox without automatic cleanup.
This function directly uses the LangSmithProvider to create/connect to sandboxes
without the context manager cleanup, allowing sandboxes to persist across
multiple agent invocations.
Args:
sandbox_id: Optional existing sandbox ID to connect to.
If None, creates a new sandbox.
Returns:
SandboxBackendProtocol instance
"""
from deepagents_cli.integrations.langsmith import LangSmithProvider
api_key = _get_langsmith_api_key()
template_name, template_image = _get_sandbox_template_config()
provider = LangSmithProvider(api_key=api_key)
return provider.get_or_create(
sandbox_id=sandbox_id,
template=template_name,
template_image=template_image,
)
client = get_client()
SANDBOX_CREATING = "__creating__"
SANDBOX_CREATION_TIMEOUT = 180
SANDBOX_POLL_INTERVAL = 1.0
# HTTP status codes
HTTP_CREATED = 201
HTTP_UNPROCESSABLE_ENTITY = 422
# Message count thresholds
MIN_MESSAGES_FOR_PREV_CHECK = 2
_SANDBOX_BACKENDS: dict[str, Any] = {}
import httpx
LINEAR_API_KEY = os.environ.get("LINEAR_API_KEY", "")
async def create_github_pr(
repo_owner: str,
repo_name: str,
github_token: str,
title: str,
head_branch: str,
base_branch: str,
body: str,
) -> tuple[str | None, int | None]:
"""Create a GitHub pull request via the API.
Args:
repo_owner: Repository owner (e.g., "langchain-ai")
repo_name: Repository name (e.g., "deepagents")
github_token: GitHub access token
title: PR title
head_branch: Source branch name
base_branch: Target branch name
body: PR description
Returns:
Tuple of (pr_url, pr_number) if successful, (None, None) otherwise
"""
pr_payload = {
"title": title,
"head": head_branch,
"base": base_branch,
"body": body,
}
logger.info(
"Creating PR: head=%s, base=%s, repo=%s/%s",
head_branch,
base_branch,
repo_owner,
repo_name,
)
try:
async with httpx.AsyncClient() as http_client:
pr_response = await http_client.post(
f"https://api.github.com/repos/{repo_owner}/{repo_name}/pulls",
headers={
"Authorization": f"Bearer {github_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json=pr_payload,
)
pr_data = pr_response.json()
if pr_response.status_code == HTTP_CREATED:
pr_url = pr_data.get("html_url")
pr_number = pr_data.get("number")
logger.info("PR created successfully: %s", pr_url)
return pr_url, pr_number
if pr_response.status_code == HTTP_UNPROCESSABLE_ENTITY:
logger.error("GitHub API validation error (422): %s", pr_data.get("message"))
else:
logger.error(
"GitHub API error (%s): %s",
pr_response.status_code,
pr_data.get("message"),
)
if "errors" in pr_data:
logger.error("GitHub API errors detail: %s", pr_data.get("errors"))
return None, None
except httpx.HTTPError:
logger.exception("Failed to create PR via GitHub API")
return None, None
async def get_github_default_branch(
repo_owner: str,
repo_name: str,
github_token: str,
) -> str:
"""Get the default branch of a GitHub repository via the API.
Args:
repo_owner: Repository owner (e.g., "langchain-ai")
repo_name: Repository name (e.g., "deepagents")
github_token: GitHub access token
Returns:
The default branch name (e.g., "main" or "master")
"""
try:
async with httpx.AsyncClient() as http_client:
response = await http_client.get(
f"https://api.github.com/repos/{repo_owner}/{repo_name}",
headers={
"Authorization": f"Bearer {github_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
if response.status_code == 200: # noqa: PLR2004
repo_data = response.json()
default_branch = repo_data.get("default_branch", "main")
logger.debug("Got default branch from GitHub API: %s", default_branch)
return default_branch
logger.warning(
"Failed to get repo info from GitHub API (%s), falling back to 'main'",
response.status_code,
)
return "main"
except httpx.HTTPError:
logger.exception("Failed to get default branch from GitHub API, falling back to 'main'")
return "main"
async def comment_on_linear_issue(issue_id: str, comment_body: str) -> bool:
"""Add a comment to a Linear issue.
Args:
issue_id: The Linear issue ID
comment_body: The comment text
Returns:
True if successful, False otherwise
"""
if not LINEAR_API_KEY:
return False
import httpx
url = "https://api.linear.app/graphql"
mutation = """
mutation CommentCreate($issueId: String!, $body: String!) {
commentCreate(input: { issueId: $issueId, body: $body }) {
success
comment {
id
}
}
}
"""
async with httpx.AsyncClient() as http_client:
try:
response = await http_client.post(
url,
headers={
"Authorization": LINEAR_API_KEY,
"Content-Type": "application/json",
},
json={
"query": mutation,
"variables": {"issueId": issue_id, "body": comment_body},
},
)
response.raise_for_status()
result = response.json()
return bool(result.get("data", {}).get("commentCreate", {}).get("success"))
except Exception: # noqa: BLE001
return False
class LinearNotifyState(AgentState):
"""Extended agent state for tracking Linear notifications."""
linear_messages_sent_count: int
@before_model(state_schema=LinearNotifyState)
async def check_message_queue_before_model( # noqa: PLR0911
state: LinearNotifyState, # noqa: ARG001
runtime: Runtime, # noqa: ARG001
) -> dict[str, Any] | None:
"""Middleware that checks for queued messages before each model call.
If messages are found in the queue for this thread, it extracts all messages,
adds them to the conversation state as new human messages, and clears the queue.
Messages are processed in FIFO order (oldest first).
This enables handling of follow-up comments that arrive while the agent is busy.
The agent will see the new messages and can incorporate them into its response.
"""
try:
config = get_config()
configurable = config.get("configurable", {})
thread_id = configurable.get("thread_id")
if not thread_id:
return None
try:
store = get_store()
except Exception as e: # noqa: BLE001
logger.debug("Could not get store from context: %s", e)
return None
if store is None:
return None
namespace = ("queue", thread_id)
try:
queued_item = await store.aget(namespace, "pending_messages")
except Exception as e: # noqa: BLE001
logger.warning("Failed to get queued item: %s", e)
return None
if queued_item is None:
return None
queued_value = queued_item.value
queued_messages = queued_value.get("messages", [])
# Delete early to prevent duplicate processing if middleware runs again
await store.adelete(namespace, "pending_messages")
if not queued_messages:
return None
logger.info(
"Found %d queued message(s) for thread %s, injecting into state",
len(queued_messages),
thread_id,
)
content_blocks = [
{"type": "text", "text": msg.get("content", "")}
for msg in queued_messages
if msg.get("content")
]
if not content_blocks:
return None
new_message = {
"role": "user",
"content": content_blocks,
}
logger.info(
"Injected %d queued message(s) into state for thread %s",
len(content_blocks),
thread_id,
)
return {"messages": [new_message]} # noqa: TRY300
except Exception:
logger.exception("Error in check_message_queue_before_model")
return None
@after_model(state_schema=LinearNotifyState)
async def post_to_linear_after_model( # noqa: PLR0911, PLR0912
state: LinearNotifyState,
runtime: Runtime, # noqa: ARG001
) -> dict[str, Any] | None:
"""Middleware that posts AI responses to Linear after each model call.
Only posts if:
- This is a Linear-triggered conversation (has linear_issue in config)
- There's exactly 1 human message (initial request)
- The previous message was from human (not a tool result)
- The AI response has text content (not just tool calls)
- The message hasn't already been sent (tracked via linear_messages_sent_count)
"""
try:
config = get_config()
configurable = config.get("configurable", {})
linear_issue = configurable.get("linear_issue", {})
linear_issue_id = linear_issue.get("id")
if not linear_issue_id:
return None
messages = state.get("messages", [])
if not messages:
return None
sent_count = state.get("linear_messages_sent_count", 0)
human_message_count = 0
for msg in messages:
if isinstance(msg, dict):
role = msg.get("role", "")
else:
role = getattr(msg, "type", "") or getattr(msg, "role", "")
if role in ("human", "user"):
human_message_count += 1
if human_message_count != 1:
return None
last_message = messages[-1]
if isinstance(last_message, dict):
role = last_message.get("role", "")
content = last_message.get("content", "")
else:
role = getattr(last_message, "type", "") or getattr(last_message, "role", "")
content = getattr(last_message, "content", "")
if role not in ("ai", "assistant"):
return None
ai_message_count = 0
for msg in messages:
if isinstance(msg, dict):
r = msg.get("role", "")
else:
r = getattr(msg, "type", "") or getattr(msg, "role", "")
if r in ("ai", "assistant"):
ai_message_count += 1
if ai_message_count <= sent_count:
return None
if len(messages) >= MIN_MESSAGES_FOR_PREV_CHECK:
prev_message = messages[-2]
if isinstance(prev_message, dict):
prev_role = prev_message.get("role", "")
else:
prev_role = getattr(prev_message, "type", "") or getattr(prev_message, "role", "")
if prev_role not in ("human", "user"):
return None
if not content or not isinstance(content, str):
return None
comment = f"""🤖 **Agent Response**
{content}"""
logger.info("Posting AI response to Linear issue %s", linear_issue_id)
success = await comment_on_linear_issue(linear_issue_id, comment)
if success:
logger.info("Successfully posted to Linear")
return {"linear_messages_sent_count": ai_message_count}
logger.warning("Failed to post to Linear")
except Exception:
logger.exception("Error in post_to_linear_after_model")
return None
@after_agent
async def open_pr_if_needed( # noqa: PLR0912, PLR0915
state: AgentState,
runtime: Runtime, # noqa: ARG001
) -> dict[str, Any] | None:
"""Middleware that commits/pushes changes and comments on Linear after agent runs."""
logger.info("After-agent middleware started")
pr_url = None
pr_number = None
pr_title = "feat: Open SWE PR"
try:
config = get_config()
configurable = config.get("configurable", {})
thread_id = configurable.get("thread_id")
logger.debug("Middleware running for thread %s", thread_id)
last_message_content = ""
messages = state.get("messages", [])
if messages:
last_message = messages[-1]
if isinstance(last_message, dict):
last_message_content = last_message.get("content", "")
elif hasattr(last_message, "content"):
last_message_content = last_message.content
linear_issue = configurable.get("linear_issue", {})
linear_issue_id = linear_issue.get("id")
if not thread_id:
if linear_issue_id and last_message_content:
comment = f"""🤖 **Agent Response**
{last_message_content}"""
await comment_on_linear_issue(linear_issue_id, comment)
return None
repo_config = configurable.get("repo", {})
repo_owner = repo_config.get("owner")
repo_name = repo_config.get("name")
sandbox_backend = _SANDBOX_BACKENDS.get(thread_id)
repo_dir = f"/workspace/{repo_name}"
if not sandbox_backend or not repo_dir:
if linear_issue_id and last_message_content:
comment = f"""🤖 **Agent Response**
{last_message_content}"""
await comment_on_linear_issue(linear_issue_id, comment)
return None
result = await asyncio.to_thread(
sandbox_backend.execute, f"cd {repo_dir} && git status --porcelain"
)
has_uncommitted_changes = result.exit_code == 0 and result.output.strip()
await asyncio.to_thread(
sandbox_backend.execute, f"cd {repo_dir} && git fetch origin 2>/dev/null || true"
)
git_log_cmd = (
f"cd {repo_dir} && git log --oneline @{{upstream}}..HEAD 2>/dev/null "
"|| git log --oneline origin/HEAD..HEAD 2>/dev/null || echo ''"
)
unpushed_result = await asyncio.to_thread(sandbox_backend.execute, git_log_cmd)
has_unpushed_commits = unpushed_result.exit_code == 0 and unpushed_result.output.strip()
has_changes = has_uncommitted_changes or has_unpushed_commits
if not has_changes:
logger.info("No changes detected, skipping PR creation")
if linear_issue_id and last_message_content:
comment = f"""🤖 **Agent Response**
{last_message_content}"""
await comment_on_linear_issue(linear_issue_id, comment)
return None
logger.info("Changes detected, preparing PR for thread %s", thread_id)
branch_result = await asyncio.to_thread(
sandbox_backend.execute, f"cd {repo_dir} && git rev-parse --abbrev-ref HEAD"
)
current_branch = branch_result.output.strip() if branch_result.exit_code == 0 else ""
target_branch = f"open-swe/{thread_id}"
if current_branch != target_branch:
checkout_result = await asyncio.to_thread(
sandbox_backend.execute, f"cd {repo_dir} && git checkout -b {target_branch}"
)
if checkout_result.exit_code != 0:
await asyncio.to_thread(
sandbox_backend.execute, f"cd {repo_dir} && git checkout {target_branch}"
)
await asyncio.to_thread(
sandbox_backend.execute, f"cd {repo_dir} && git config user.name 'Open SWE[bot]'"
)
await asyncio.to_thread(
sandbox_backend.execute,
f"cd {repo_dir} && git config user.email 'Open SWE@users.noreply.github.com'",
)
await asyncio.to_thread(sandbox_backend.execute, f"cd {repo_dir} && git add -A")
await asyncio.to_thread(
sandbox_backend.execute, f'cd {repo_dir} && git commit -m "feat: Open SWE PR"'
)
encrypted_token = configurable.get("github_token_encrypted")
if encrypted_token:
github_token = decrypt_token(encrypted_token)
if github_token:
remote_result = await asyncio.to_thread(
sandbox_backend.execute, f"cd {repo_dir} && git remote get-url origin"
)
if remote_result.exit_code == 0:
remote_url = remote_result.output.strip()
if "github.com" in remote_url and "@" not in remote_url:
# Convert https://github.com/owner/repo.git to https://git:token@github.com/owner/repo.git
auth_url = remote_url.replace("https://", f"https://git:{github_token}@")
await asyncio.to_thread(
sandbox_backend.execute,
f"cd {repo_dir} && git push {auth_url} {target_branch}",
)
else:
await asyncio.to_thread(
sandbox_backend.execute, f"cd {repo_dir} && git push origin {target_branch}"
)
# Get default branch from GitHub API (most reliable method)
base_branch = await get_github_default_branch(repo_owner, repo_name, github_token)
logger.info("Using base branch: %s", base_branch)
pr_title = "feat: Open SWE PR"
pr_body = "Automated PR created by Open SWE agent."
pr_url, pr_number = await create_github_pr(
repo_owner=repo_owner,
repo_name=repo_name,
github_token=github_token,
title=pr_title,
head_branch=target_branch,
base_branch=base_branch,
body=pr_body,
)
linear_issue = configurable.get("linear_issue", {})
linear_issue_id = linear_issue.get("id")
if linear_issue_id and last_message_content:
if pr_url:
comment = f"""✅ **Pull Request Created**
I've created a pull request to address this issue:
**[PR #{pr_number}: {pr_title}]({pr_url})**
---
🤖 **Agent Response**
{last_message_content}"""
else:
comment = f"""🤖 **Agent Response**
{last_message_content}"""
await comment_on_linear_issue(linear_issue_id, comment)
logger.info("After-agent middleware completed successfully")
except Exception as e:
logger.exception("Error in after-agent middleware")
try:
config = get_config()
configurable = config.get("configurable", {})
linear_issue = configurable.get("linear_issue", {})
linear_issue_id = linear_issue.get("id")
if linear_issue_id:
error_comment = f"""❌ **Agent Error**
An error occurred while processing this issue:
```
{type(e).__name__}: {e}
```"""
await comment_on_linear_issue(linear_issue_id, error_comment)
except Exception:
logger.exception("Failed to post error comment to Linear")
return None
async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915
sandbox_backend: SandboxBackendProtocol,
owner: str,
repo: str,
github_token: str | None = None,
) -> str:
"""Clone a GitHub repo into the sandbox, or pull if it already exists.
Args:
sandbox_backend: The sandbox backend to execute commands in (LangSmithBackend)
owner: GitHub repo owner
repo: GitHub repo name
github_token: GitHub access token (from agent auth or env var)
Returns:
Path to the cloned/updated repo directory
"""
logger.info("_clone_or_pull_repo_in_sandbox called for %s/%s", owner, repo)
loop = asyncio.get_event_loop()
token = github_token
if not token:
msg = "No GitHub token provided"
logger.error(msg)
raise ValueError(msg)
repo_dir = f"/workspace/{repo}"
logger.debug("Checking if repo already exists at %s", repo_dir)
try:
check_result = await loop.run_in_executor(
None, sandbox_backend.execute, f"test -d {repo_dir}/.git && echo exists"
)
logger.debug(
"Check result: exit_code=%s, output=%s",
check_result.exit_code,
check_result.output[:200] if check_result.output else "",
)
except Exception:
logger.exception("Failed to execute check command in sandbox")
raise
if check_result.exit_code == 0 and "exists" in check_result.output:
logger.info("Repo already exists at %s, pulling latest changes", repo_dir)
try:
status_result = await loop.run_in_executor(
None, sandbox_backend.execute, f"cd {repo_dir} && git status --porcelain"
)
logger.debug("Git status result: exit_code=%s", status_result.exit_code)
except Exception:
logger.exception("Failed to get git status")
raise
# CRITICAL: Ensure remote URL doesn't contain token (clean up from previous runs)
clean_url = f"https://github.com/{owner}/{repo}.git"
try:
await loop.run_in_executor(
None,
sandbox_backend.execute,
f"cd {repo_dir} && git remote set-url origin {clean_url}",
)
except Exception:
logger.exception("Failed to set remote URL")
raise
if status_result.exit_code == 0 and not status_result.output.strip():
auth_url = f"https://git:{token}@github.com/{owner}/{repo}.git"
try:
pull_result = await loop.run_in_executor(
None, sandbox_backend.execute, f"cd {repo_dir} && git pull {auth_url}"
)
logger.debug("Git pull result: exit_code=%s", pull_result.exit_code)
if pull_result.exit_code != 0:
logger.warning(
"Git pull failed with exit code %s: %s",
pull_result.exit_code,
pull_result.output[:200] if pull_result.output else "",
)
except Exception:
logger.exception("Failed to execute git pull")
raise
else:
logger.info("Cloning repo %s/%s to %s", owner, repo, repo_dir)
clone_url = f"https://git:{token}@github.com/{owner}/{repo}.git"
try:
result = await loop.run_in_executor(
None, sandbox_backend.execute, f"git clone {clone_url} {repo_dir}"
)
logger.debug("Git clone result: exit_code=%s", result.exit_code)
except Exception:
logger.exception("Failed to execute git clone")
raise
if result.exit_code != 0:
msg = f"Failed to clone repo {owner}/{repo}: {result.output}"
logger.error(msg)
raise RuntimeError(msg)
clean_url = f"https://github.com/{owner}/{repo}.git"
try:
await loop.run_in_executor(
None,
sandbox_backend.execute,
f"cd {repo_dir} && git remote set-url origin {clean_url}",
)
except Exception:
logger.exception("Failed to set remote URL after clone")
raise
logger.info("Repo setup complete at %s", repo_dir)
return repo_dir
async def _get_sandbox_id_from_metadata(thread_id: str) -> str | None:
"""Get sandbox_id from thread metadata."""
thread = await client.threads.get(thread_id=thread_id)
return thread.get("metadata", {}).get("sandbox_id")
async def _wait_for_sandbox_id(thread_id: str) -> str:
"""Wait for sandbox_id to be set in thread metadata.
Polls thread metadata until sandbox_id is set to a real value
(not the creating sentinel).
Raises:
TimeoutError: If sandbox creation takes too long
"""
elapsed = 0.0
while elapsed < SANDBOX_CREATION_TIMEOUT:
sandbox_id = await _get_sandbox_id_from_metadata(thread_id)
if sandbox_id is not None and sandbox_id != SANDBOX_CREATING:
return sandbox_id
await asyncio.sleep(SANDBOX_POLL_INTERVAL)
elapsed += SANDBOX_POLL_INTERVAL
msg = f"Timeout waiting for sandbox creation for thread {thread_id}"
raise TimeoutError(msg)
def graph_loaded_for_execution(config: RunnableConfig) -> bool:
"""Check if the graph is loaded for actual execution vs introspection."""
return (
config["configurable"].get("__is_for_execution__", False)
if "configurable" in config
else False
)
DEFAULT_RECURSION_LIMIT = 1_000
async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
"""Get or create an agent with a sandbox for the given thread."""
thread_id = config["configurable"].get("thread_id", None)
logger.info("get_agent called for thread %s", thread_id)
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT
repo_config = config["configurable"].get("repo", {})
repo_owner = repo_config.get("owner")
repo_name = repo_config.get("name")
encrypted_token = config["configurable"].get("github_token_encrypted")
if encrypted_token:
github_token = decrypt_token(encrypted_token)
logger.debug("Decrypted GitHub token")
if thread_id is None or not graph_loaded_for_execution(config):
logger.info("No thread_id or not for execution, returning agent without sandbox")
return create_deep_agent(
system_prompt="",
tools=[],
).with_config(config)
sandbox_id = await _get_sandbox_id_from_metadata(thread_id)
if sandbox_id == SANDBOX_CREATING:
logger.info("Sandbox creation in progress, waiting...")
sandbox_id = await _wait_for_sandbox_id(thread_id)
if sandbox_id is None:
logger.info("Creating new sandbox for thread %s", thread_id)
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": SANDBOX_CREATING})
try:
# Create sandbox without context manager cleanup (sandbox persists)
sandbox_backend = await asyncio.to_thread(_create_langsmith_sandbox)
logger.info("Sandbox created: %s", sandbox_backend.id)
# Update metadata immediately after sandbox creation so other callers
# can connect to the sandbox while we clone the repo
await client.threads.update(
thread_id=thread_id,
metadata={"sandbox_id": sandbox_backend.id},
)
repo_dir = None
if repo_owner and repo_name:
logger.info("Cloning repo %s/%s into sandbox", repo_owner, repo_name)
repo_dir = await _clone_or_pull_repo_in_sandbox(
sandbox_backend, repo_owner, repo_name, github_token
)
logger.info("Repo cloned to %s", repo_dir)
await client.threads.update(
thread_id=thread_id,
metadata={"repo_dir": repo_dir},
)
except Exception:
logger.exception("Failed to create sandbox or clone repo")
try:
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None})
logger.info("Reset sandbox_id to None for thread %s", thread_id)
except Exception:
logger.exception("Failed to reset sandbox_id metadata")
raise
else:
logger.info("Connecting to existing sandbox %s", sandbox_id)
try:
# Connect to existing sandbox without context manager cleanup
sandbox_backend = await asyncio.to_thread(_create_langsmith_sandbox, sandbox_id)
logger.info("Connected to existing sandbox %s", sandbox_id)
except Exception:
logger.warning("Failed to connect to existing sandbox %s, creating new one", sandbox_id)
# Reset sandbox_id and create a new sandbox
await client.threads.update(
thread_id=thread_id,
metadata={"sandbox_id": SANDBOX_CREATING},
)
try:
sandbox_backend = await asyncio.to_thread(_create_langsmith_sandbox)
logger.info("New sandbox created: %s", sandbox_backend.id)
await client.threads.update(
thread_id=thread_id,
metadata={"sandbox_id": sandbox_backend.id},
)
except Exception:
logger.exception("Failed to create replacement sandbox")
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None})
raise
thread = await client.threads.get(thread_id=thread_id)
repo_dir = thread.get("metadata", {}).get("repo_dir")
if repo_owner and repo_name:
logger.info("Pulling latest changes for repo %s/%s", repo_owner, repo_name)
try:
repo_dir = await _clone_or_pull_repo_in_sandbox(
sandbox_backend, repo_owner, repo_name, github_token
)
except Exception:
logger.exception("Failed to pull repo in existing sandbox")
raise
_SANDBOX_BACKENDS[thread_id] = sandbox_backend
logger.info("Returning agent with sandbox for thread %s", thread_id)
return create_deep_agent(
model=ChatAnthropic(
model="claude-opus-4-6", max_tokens=20_000
), # TODO: Actually pass a model here
system_prompt=construct_system_prompt(repo_dir),
tools=[http_request, fetch_url],
backend=sandbox_backend,
middleware=[
check_message_queue_before_model,
post_to_linear_after_model,
open_pr_if_needed,
],
).with_config(config)