feat: stop auto-cloning and add list_repos/get_branch_name tools [closes OPE-21]

This commit is contained in:
aran-yogesh 2026-03-16 20:35:39 -07:00
parent 226ddde8a6
commit 16bcb0d94a
5 changed files with 130 additions and 210 deletions

View file

@ -33,7 +33,7 @@ FILE_MANAGEMENT_SECTION = """---
### File & Code Management
- **Repository location:** `{working_dir}`
- **Repository location:** `/workspace/<repo_name>` (clone the repo here first — see Repository Setup)
- Never create backup files.
- Work only within the existing Git repository.
- Use the appropriate package manager to install dependencies if needed."""
@ -64,6 +64,28 @@ For questions or status checks (no code changes needed):
2. **Comment** — Call `linear_comment`, `slack_thread_reply`, or `github_comment` with your answer. Never leave a question unanswered."""
REPO_SETUP_SECTION = """---
### Repository Setup
Before starting any task, set up your workspace in this order:
1. **Find the repo** — Call `list_repos` to get the list of available repos. The default org is `langchain-ai`. If the repo isn't in the common list, call `list_repos(org="<org_name>")` to search via the GitHub API. If unsure which repo to use, ask the user for confirmation before proceeding.
2. **Clone the repo** — Clone into `/workspace`:
```
git clone https://github.com/<owner>/<name>.git /workspace/<name>
```
3. **Get your branch** — Call `get_branch_name` to get your branch name. Never hard-code it.
4. **Checkout your branch** — Always checkout your branch before making any changes:
```
git -C /workspace/<name> fetch origin 2>/dev/null; git -C /workspace/<name> checkout <branch_name> 2>/dev/null || git -C /workspace/<name> checkout -B <branch_name>
```
"""
TOOL_USAGE_SECTION = """---
### Tool Usage
@ -71,6 +93,12 @@ TOOL_USAGE_SECTION = """---
#### `execute`
Run shell commands in the sandbox. Pass `timeout=<seconds>` for long-running commands (default: 300s).
#### `list_repos`
Returns the list of available GitHub repos. Pass `org="<org_name>"` to also search a specific GitHub org via the API. If unsure which repo to use, ask the user for confirmation.
#### `get_branch_name`
Returns the git branch name for this thread (`open-swe/{thread_id}`). Always call this to get your branch name — never hard-code it.
#### `fetch_url`
Fetches a URL and converts HTML to markdown. Use for web pages. Synthesize the content into a response — never dump raw markdown. Only use for URLs provided by the user or discovered during exploration.
@ -260,6 +288,7 @@ SYSTEM_PROMPT = (
WORKING_ENV_SECTION
+ FILE_MANAGEMENT_SECTION
+ TASK_OVERVIEW_SECTION
+ REPO_SETUP_SECTION
+ TASK_EXECUTION_SECTION
+ TOOL_USAGE_SECTION
+ TOOL_BEST_PRACTICES_SECTION

View file

@ -8,7 +8,6 @@ import warnings
logger = logging.getLogger(__name__)
from langgraph.config import get_config
from langgraph.graph.state import RunnableConfig
from langgraph.pregel import Pregel
from langgraph_sdk import get_client
@ -36,13 +35,16 @@ from .prompt import construct_system_prompt
from .tools import (
commit_and_open_pr,
fetch_url,
get_branch_name,
github_comment,
http_request,
linear_comment,
list_repos,
slack_thread_reply,
)
from .utils.auth import resolve_github_token
from .utils.model import make_model
from .utils.sandbox_state import SANDBOX_BACKENDS, get_sandbox_id_from_metadata
client = get_client()
@ -50,133 +52,9 @@ SANDBOX_CREATING = "__creating__"
SANDBOX_CREATION_TIMEOUT = 180
SANDBOX_POLL_INTERVAL = 1.0
from .utils.agents_md import read_agents_md_in_sandbox
from .utils.github import (
_CRED_FILE_PATH,
cleanup_git_credentials,
git_has_uncommitted_changes,
is_valid_git_repo,
remove_directory,
setup_git_credentials,
)
from .utils.sandbox_state import SANDBOX_BACKENDS, get_sandbox_id_from_metadata
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}"
clean_url = f"https://github.com/{owner}/{repo}.git"
cred_helper_arg = f"-c credential.helper='store --file={_CRED_FILE_PATH}'"
is_git_repo = await loop.run_in_executor(None, is_valid_git_repo, sandbox_backend, repo_dir)
if not is_git_repo:
logger.warning("Repo directory missing or not a valid git repo at %s, removing", repo_dir)
try:
removed = await loop.run_in_executor(None, remove_directory, sandbox_backend, repo_dir)
if not removed:
msg = f"Failed to remove invalid directory at {repo_dir}"
logger.error(msg)
raise RuntimeError(msg)
logger.info("Removed invalid directory, will clone fresh repo")
except Exception:
logger.exception("Failed to remove invalid directory")
raise
else:
logger.info("Repo exists at %s, checking for uncommitted changes", repo_dir)
has_changes = await loop.run_in_executor(
None, git_has_uncommitted_changes, sandbox_backend, repo_dir
)
if has_changes:
logger.warning("Repo has uncommitted changes at %s, skipping pull", repo_dir)
return repo_dir
logger.info("Repo is clean, pulling latest changes from %s/%s", owner, repo)
await loop.run_in_executor(None, setup_git_credentials, sandbox_backend, token)
try:
pull_result = await loop.run_in_executor(
None,
sandbox_backend.execute,
f"cd {repo_dir} && git {cred_helper_arg} pull origin $(git rev-parse --abbrev-ref HEAD)",
)
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
finally:
await loop.run_in_executor(None, cleanup_git_credentials, sandbox_backend)
logger.info("Repo updated at %s", repo_dir)
return repo_dir
logger.info("Cloning repo %s/%s to %s", owner, repo, repo_dir)
await loop.run_in_executor(None, setup_git_credentials, sandbox_backend, token)
try:
result = await loop.run_in_executor(
None,
sandbox_backend.execute,
f"git {cred_helper_arg} clone {clean_url} {repo_dir}",
)
logger.debug("Git clone result: exit_code=%s", result.exit_code)
except Exception:
logger.exception("Failed to execute git clone")
raise
finally:
await loop.run_in_executor(None, cleanup_git_credentials, sandbox_backend)
if result.exit_code != 0:
msg = f"Failed to clone repo {owner}/{repo}: {result.output}"
logger.error(msg)
raise RuntimeError(msg)
logger.info("Repo cloned successfully at %s", repo_dir)
return repo_dir
async def _recreate_sandbox(
thread_id: str,
repo_owner: str,
repo_name: str,
*,
github_token: str | None,
) -> tuple[SandboxBackendProtocol, str]:
"""Recreate a sandbox and clone the repo after a connection failure.
Clears the stale cache entry, sets the SANDBOX_CREATING sentinel,
creates a fresh sandbox, and clones the repo.
"""
async def _recreate_sandbox(thread_id: str) -> SandboxBackendProtocol:
"""Recreate a sandbox after a connection failure."""
SANDBOX_BACKENDS.pop(thread_id, None)
await client.threads.update(
thread_id=thread_id,
@ -184,14 +62,11 @@ async def _recreate_sandbox(
)
try:
sandbox_backend = await asyncio.to_thread(create_langsmith_sandbox)
repo_dir = await _clone_or_pull_repo_in_sandbox(
sandbox_backend, repo_owner, repo_name, github_token
)
except Exception:
logger.exception("Failed to recreate sandbox after connection failure")
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None})
raise
return sandbox_backend, repo_dir
return sandbox_backend
async def _wait_for_sandbox_id(thread_id: str) -> str:
@ -227,16 +102,12 @@ def graph_loaded_for_execution(config: RunnableConfig) -> bool:
DEFAULT_RECURSION_LIMIT = 1_000
async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
async def get_agent(config: RunnableConfig) -> Pregel:
"""Get or create an agent with a sandbox for the given thread."""
thread_id = config["configurable"].get("thread_id", None)
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT
repo_config = config["configurable"].get("repo", {})
repo_owner = repo_config.get("owner")
repo_name = repo_config.get("name")
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(
@ -244,7 +115,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
tools=[],
).with_config(config)
github_token, new_encrypted = await resolve_github_token(config, thread_id)
_github_token, new_encrypted = await resolve_github_token(config, thread_id)
config["metadata"]["github_token_encrypted"] = new_encrypted
sandbox_backend = SANDBOX_BACKENDS.get(thread_id)
@ -256,70 +127,37 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
if sandbox_backend:
logger.info("Using cached sandbox backend for thread %s", thread_id)
metadata = get_config().get("metadata", {})
repo_dir = 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 SandboxClientError:
logger.warning(
"Cached sandbox is no longer reachable for thread %s, recreating sandbox",
thread_id,
)
sandbox_backend, repo_dir = await _recreate_sandbox(
thread_id, repo_owner, repo_name, github_token=github_token
)
except Exception:
logger.exception("Failed to pull repo in cached sandbox")
raise
try:
await asyncio.to_thread(sandbox_backend.execute, "echo ok", timeout=5)
except SandboxClientError:
logger.warning(
"Cached sandbox is no longer reachable for thread %s, recreating sandbox",
thread_id,
)
sandbox_backend = await _recreate_sandbox(thread_id)
elif 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)
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")
logger.exception("Failed to create sandbox")
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None})
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)
@ -328,46 +166,19 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None})
raise
metadata = get_config().get("metadata", {})
repo_dir = 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 SandboxClientError:
logger.warning(
"Existing sandbox is no longer reachable for thread %s, recreating sandbox",
thread_id,
)
sandbox_backend, repo_dir = await _recreate_sandbox(
thread_id, repo_owner, repo_name, github_token=github_token
)
except Exception:
logger.exception("Failed to pull repo in existing sandbox")
raise
SANDBOX_BACKENDS[thread_id] = sandbox_backend
if not repo_dir:
msg = "Cannot proceed: no repo was cloned. Set 'repo.owner' and 'repo.name' in the configurable config"
raise RuntimeError(msg)
linear_issue = config["configurable"].get("linear_issue", {})
linear_project_id = linear_issue.get("linear_project_id", "")
linear_issue_number = linear_issue.get("linear_issue_number", "")
agents_md = await read_agents_md_in_sandbox(sandbox_backend, repo_dir)
logger.info("Returning agent with sandbox for thread %s", thread_id)
return create_deep_agent(
model=make_model("anthropic:claude-opus-4-6", temperature=0, max_tokens=20_000),
system_prompt=construct_system_prompt(
repo_dir,
"/workspace",
linear_project_id=linear_project_id,
linear_issue_number=linear_issue_number,
agents_md=agents_md,
),
tools=[
http_request,
@ -376,6 +187,8 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
linear_comment,
slack_thread_reply,
github_comment,
list_repos,
get_branch_name,
],
backend=sandbox_backend,
middleware=[

View file

@ -1,15 +1,19 @@
from .commit_and_open_pr import commit_and_open_pr
from .fetch_url import fetch_url
from .get_branch_name import get_branch_name
from .github_comment import github_comment
from .http_request import http_request
from .linear_comment import linear_comment
from .list_repos import list_repos
from .slack_thread_reply import slack_thread_reply
__all__ = [
"commit_and_open_pr",
"fetch_url",
"get_branch_name",
"github_comment",
"http_request",
"linear_comment",
"list_repos",
"slack_thread_reply",
]

View file

@ -0,0 +1,13 @@
"""Tool to get the branch name for the current thread."""
from __future__ import annotations
from typing import Any
from langgraph.config import get_config
def get_branch_name() -> dict[str, Any]:
"""Return the git branch name for this thread (open-swe/{thread_id})."""
config = get_config()
thread_id = config.get("configurable", {}).get("thread_id", "unknown")
return {"branch_name": f"open-swe/{thread_id}"}

61
agent/tools/list_repos.py Normal file
View file

@ -0,0 +1,61 @@
"""Tool to list available GitHub repositories."""
from __future__ import annotations
import os
from typing import Any
import httpx
from ..utils.linear_team_repo_map import LINEAR_TEAM_TO_REPO
DEFAULT_GITHUB_ORG = os.environ.get("DEFAULT_GITHUB_ORG", "langchain-ai")
def _get_common_repos() -> list[dict[str, str]]:
"""Extract unique repos from LINEAR_TEAM_TO_REPO."""
repos: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for team_config in LINEAR_TEAM_TO_REPO.values():
entries: list[dict[str, str]] = []
if "owner" in team_config and "name" in team_config:
entries.append({"owner": team_config["owner"], "name": team_config["name"]})
if "projects" in team_config:
entries.extend(team_config["projects"].values())
if "default" in team_config:
entries.append(team_config["default"])
for entry in entries:
key = (entry["owner"], entry["name"])
if key not in seen:
seen.add(key)
repos.append({"owner": entry["owner"], "name": entry["name"]})
return repos
def list_repos(org: str | None = None) -> dict[str, Any]:
"""List available GitHub repos.
Returns common repos from config. Pass org to also search that GitHub org via API.
If unsure which repo to use, ask the user for confirmation.
"""
common_repos = _get_common_repos()
result: dict[str, Any] = {"common_repos": common_repos, "default_org": DEFAULT_GITHUB_ORG}
if org:
try:
response = httpx.get(
f"https://api.github.com/orgs/{org}/repos",
headers={"Accept": "application/vnd.github+json"},
params={"per_page": 100, "sort": "updated"},
timeout=10,
)
if response.status_code == 200:
result["org_repos"] = [
{"owner": org, "name": r["name"]} for r in response.json()
]
except Exception:
pass
return result