From 16bcb0d94a77082bbaab5f7ef1616691e0367e99 Mon Sep 17 00:00:00 2001 From: aran-yogesh Date: Mon, 16 Mar 2026 20:35:39 -0700 Subject: [PATCH] feat: stop auto-cloning and add list_repos/get_branch_name tools [closes OPE-21] --- agent/prompt.py | 31 ++++- agent/server.py | 231 ++++----------------------------- agent/tools/__init__.py | 4 + agent/tools/get_branch_name.py | 13 ++ agent/tools/list_repos.py | 61 +++++++++ 5 files changed, 130 insertions(+), 210 deletions(-) create mode 100644 agent/tools/get_branch_name.py create mode 100644 agent/tools/list_repos.py diff --git a/agent/prompt.py b/agent/prompt.py index b7b290bb..94e59c6c 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -33,7 +33,7 @@ FILE_MANAGEMENT_SECTION = """--- ### File & Code Management -- **Repository location:** `{working_dir}` +- **Repository location:** `/workspace/` (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="")` 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//.git /workspace/ + ``` + +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/ fetch origin 2>/dev/null; git -C /workspace/ checkout 2>/dev/null || git -C /workspace/ checkout -B + ``` +""" + + TOOL_USAGE_SECTION = """--- ### Tool Usage @@ -71,6 +93,12 @@ TOOL_USAGE_SECTION = """--- #### `execute` Run shell commands in the sandbox. Pass `timeout=` for long-running commands (default: 300s). +#### `list_repos` +Returns the list of available GitHub repos. Pass `org=""` 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 diff --git a/agent/server.py b/agent/server.py index ff3f0dcd..9f96b134 100644 --- a/agent/server.py +++ b/agent/server.py @@ -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=[ diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index 2a67cfb2..567ae89c 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -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", ] diff --git a/agent/tools/get_branch_name.py b/agent/tools/get_branch_name.py new file mode 100644 index 00000000..8d2484f9 --- /dev/null +++ b/agent/tools/get_branch_name.py @@ -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}"} diff --git a/agent/tools/list_repos.py b/agent/tools/list_repos.py new file mode 100644 index 00000000..a3ad0ec9 --- /dev/null +++ b/agent/tools/list_repos.py @@ -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