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 ### File & Code Management
- **Repository location:** `{working_dir}` - **Repository location:** `/workspace/<repo_name>` (clone the repo here first — see Repository Setup)
- Never create backup files. - Never create backup files.
- Work only within the existing Git repository. - Work only within the existing Git repository.
- Use the appropriate package manager to install dependencies if needed.""" - 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.""" 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_SECTION = """---
### Tool Usage ### Tool Usage
@ -71,6 +93,12 @@ TOOL_USAGE_SECTION = """---
#### `execute` #### `execute`
Run shell commands in the sandbox. Pass `timeout=<seconds>` for long-running commands (default: 300s). 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` #### `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. 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 WORKING_ENV_SECTION
+ FILE_MANAGEMENT_SECTION + FILE_MANAGEMENT_SECTION
+ TASK_OVERVIEW_SECTION + TASK_OVERVIEW_SECTION
+ REPO_SETUP_SECTION
+ TASK_EXECUTION_SECTION + TASK_EXECUTION_SECTION
+ TOOL_USAGE_SECTION + TOOL_USAGE_SECTION
+ TOOL_BEST_PRACTICES_SECTION + TOOL_BEST_PRACTICES_SECTION

View file

@ -8,7 +8,6 @@ import warnings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from langgraph.config import get_config
from langgraph.graph.state import RunnableConfig from langgraph.graph.state import RunnableConfig
from langgraph.pregel import Pregel from langgraph.pregel import Pregel
from langgraph_sdk import get_client from langgraph_sdk import get_client
@ -36,13 +35,16 @@ from .prompt import construct_system_prompt
from .tools import ( from .tools import (
commit_and_open_pr, commit_and_open_pr,
fetch_url, fetch_url,
get_branch_name,
github_comment, github_comment,
http_request, http_request,
linear_comment, linear_comment,
list_repos,
slack_thread_reply, slack_thread_reply,
) )
from .utils.auth import resolve_github_token from .utils.auth import resolve_github_token
from .utils.model import make_model from .utils.model import make_model
from .utils.sandbox_state import SANDBOX_BACKENDS, get_sandbox_id_from_metadata
client = get_client() client = get_client()
@ -50,133 +52,9 @@ SANDBOX_CREATING = "__creating__"
SANDBOX_CREATION_TIMEOUT = 180 SANDBOX_CREATION_TIMEOUT = 180
SANDBOX_POLL_INTERVAL = 1.0 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 _recreate_sandbox(thread_id: str) -> SandboxBackendProtocol:
async def _clone_or_pull_repo_in_sandbox( # noqa: PLR0915 """Recreate a sandbox after a connection failure."""
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.
"""
SANDBOX_BACKENDS.pop(thread_id, None) SANDBOX_BACKENDS.pop(thread_id, None)
await client.threads.update( await client.threads.update(
thread_id=thread_id, thread_id=thread_id,
@ -184,14 +62,11 @@ async def _recreate_sandbox(
) )
try: try:
sandbox_backend = await asyncio.to_thread(create_langsmith_sandbox) 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: except Exception:
logger.exception("Failed to recreate sandbox after connection failure") logger.exception("Failed to recreate sandbox after connection failure")
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None}) await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None})
raise raise
return sandbox_backend, repo_dir return sandbox_backend
async def _wait_for_sandbox_id(thread_id: str) -> str: 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 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.""" """Get or create an agent with a sandbox for the given thread."""
thread_id = config["configurable"].get("thread_id", None) thread_id = config["configurable"].get("thread_id", None)
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT 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): 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") logger.info("No thread_id or not for execution, returning agent without sandbox")
return create_deep_agent( return create_deep_agent(
@ -244,7 +115,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
tools=[], tools=[],
).with_config(config) ).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 config["metadata"]["github_token_encrypted"] = new_encrypted
sandbox_backend = SANDBOX_BACKENDS.get(thread_id) sandbox_backend = SANDBOX_BACKENDS.get(thread_id)
@ -256,70 +127,37 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
if sandbox_backend: if sandbox_backend:
logger.info("Using cached sandbox backend for thread %s", thread_id) logger.info("Using cached sandbox backend for thread %s", thread_id)
metadata = get_config().get("metadata", {}) try:
repo_dir = metadata.get("repo_dir") await asyncio.to_thread(sandbox_backend.execute, "echo ok", timeout=5)
except SandboxClientError:
if repo_owner and repo_name: logger.warning(
logger.info("Pulling latest changes for repo %s/%s", repo_owner, repo_name) "Cached sandbox is no longer reachable for thread %s, recreating sandbox",
try: thread_id,
repo_dir = await _clone_or_pull_repo_in_sandbox( )
sandbox_backend, repo_owner, repo_name, github_token sandbox_backend = await _recreate_sandbox(thread_id)
)
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
elif sandbox_id is None: elif sandbox_id is None:
logger.info("Creating new sandbox for thread %s", thread_id) logger.info("Creating new sandbox for thread %s", thread_id)
await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": SANDBOX_CREATING}) await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": SANDBOX_CREATING})
try: try:
# Create sandbox without context manager cleanup (sandbox persists)
sandbox_backend = await asyncio.to_thread(create_langsmith_sandbox) sandbox_backend = await asyncio.to_thread(create_langsmith_sandbox)
logger.info("Sandbox created: %s", sandbox_backend.id) 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: except Exception:
logger.exception("Failed to create sandbox or clone repo") logger.exception("Failed to create sandbox")
try: await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None})
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 raise
else: else:
logger.info("Connecting to existing sandbox %s", sandbox_id) logger.info("Connecting to existing sandbox %s", sandbox_id)
try: try:
# Connect to existing sandbox without context manager cleanup
sandbox_backend = await asyncio.to_thread(create_langsmith_sandbox, sandbox_id) sandbox_backend = await asyncio.to_thread(create_langsmith_sandbox, sandbox_id)
logger.info("Connected to existing sandbox %s", sandbox_id) logger.info("Connected to existing sandbox %s", sandbox_id)
except Exception: except Exception:
logger.warning("Failed to connect to existing sandbox %s, creating new one", sandbox_id) 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( await client.threads.update(
thread_id=thread_id, thread_id=thread_id,
metadata={"sandbox_id": SANDBOX_CREATING}, metadata={"sandbox_id": SANDBOX_CREATING},
) )
try: try:
sandbox_backend = await asyncio.to_thread(create_langsmith_sandbox) sandbox_backend = await asyncio.to_thread(create_langsmith_sandbox)
logger.info("New sandbox created: %s", sandbox_backend.id) 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}) await client.threads.update(thread_id=thread_id, metadata={"sandbox_id": None})
raise 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 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_issue = config["configurable"].get("linear_issue", {})
linear_project_id = linear_issue.get("linear_project_id", "") linear_project_id = linear_issue.get("linear_project_id", "")
linear_issue_number = linear_issue.get("linear_issue_number", "") 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) logger.info("Returning agent with sandbox for thread %s", thread_id)
return create_deep_agent( return create_deep_agent(
model=make_model("anthropic:claude-opus-4-6", temperature=0, max_tokens=20_000), model=make_model("anthropic:claude-opus-4-6", temperature=0, max_tokens=20_000),
system_prompt=construct_system_prompt( system_prompt=construct_system_prompt(
repo_dir, "/workspace",
linear_project_id=linear_project_id, linear_project_id=linear_project_id,
linear_issue_number=linear_issue_number, linear_issue_number=linear_issue_number,
agents_md=agents_md,
), ),
tools=[ tools=[
http_request, http_request,
@ -376,6 +187,8 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
linear_comment, linear_comment,
slack_thread_reply, slack_thread_reply,
github_comment, github_comment,
list_repos,
get_branch_name,
], ],
backend=sandbox_backend, backend=sandbox_backend,
middleware=[ middleware=[

View file

@ -1,15 +1,19 @@
from .commit_and_open_pr import commit_and_open_pr from .commit_and_open_pr import commit_and_open_pr
from .fetch_url import fetch_url from .fetch_url import fetch_url
from .get_branch_name import get_branch_name
from .github_comment import github_comment from .github_comment import github_comment
from .http_request import http_request from .http_request import http_request
from .linear_comment import linear_comment from .linear_comment import linear_comment
from .list_repos import list_repos
from .slack_thread_reply import slack_thread_reply from .slack_thread_reply import slack_thread_reply
__all__ = [ __all__ = [
"commit_and_open_pr", "commit_and_open_pr",
"fetch_url", "fetch_url",
"get_branch_name",
"github_comment", "github_comment",
"http_request", "http_request",
"linear_comment", "linear_comment",
"list_repos",
"slack_thread_reply", "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