From 2039fe6660501a74b40b2eb10aaaea0df79588b2 Mon Sep 17 00:00:00 2001 From: Aran Yogesh Date: Fri, 10 Apr 2026 17:04:55 -0700 Subject: [PATCH] feat: stop auto-cloning and let agent manage repo setup [closes OPE-21] (#1159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: authenticate git operations via sandbox proxy instead of credential files * feat: authenticate git operations via sandbox proxy instead of credential files * feat: authenticate git operations via sandbox proxy instead of credential files * removing logger.info * formatting and linting * fix: resolve lint errors in server.py (imports, unused vars, undefined names) * feat: use opaque proxy headers for GitHub auth in sandbox * linting formatting and test changes * linting * Delete .claude directory * Delete tests/evals directory * fix: address PR review — guard missing tokens, quote shell paths, add proxy auth tests * fix: restore authorship, branch_name support, and installation token for PR creation * linitng * fix: move installation token fetch before commit, clean up dead proxy validation code * feat: stop auto-cloning and let agent manage repo setup [closes OPE-21] * feat: stop auto-cloning and let agent manage repo setup [closes OPE-21] * fix: address review feedback — restore agents_md, add git user config, lint fixes * fix: drop github_token arg from sandbox creation, use generic create_sandbox factory with langsmith-only proxy config * fix: use _get_langsmith_api_key() for prod key fallback, warn when API key missing for proxy config * linting * linting * feat: add installation token auth to list_repos GitHub API call * agents.md update * linting * fix: address PR review feedback — shell precedence bug in prompt, remove dead code * linting * Apply suggestion from @bracesproul Co-authored-by: Brace Sproul * Apply suggestion from @bracesproul Co-authored-by: Brace Sproul * fix: address PR review feedback — restore {working_dir} in prompt, remove clone code block * fix:Extract check_or_recreate_sandbox utility from inline sandbox health check * fix: address PR review feedback — async list_repos, restore template name, fix prompt colon * fix: resolve merge conflicts with main, adopt deepagents v0.5.0a4 LangSmithSandbox * linting * yogesh/ope-21-stop-auto-cloning * Update agent/tools/list_repos.py Co-authored-by: Brace Sproul * Update agent/prompt.py Co-authored-by: Brace Sproul * feat: address PR review — list_repos uses GitHub API only, PR trigger includes org/repo * linting * feat: address PR review feedback — list_repos pagination, simpler return, sandbox health check * feat: support listing repos for personal user accounts via is_organization flag --------- Co-authored-by: Brace Sproul --- CUSTOMIZATION.md | 4 +- README.md | 4 +- agent/integrations/langsmith.py | 4 + agent/prompt.py | 49 +++--- agent/server.py | 244 ++++++--------------------- agent/tools/__init__.py | 4 + agent/tools/get_branch_name.py | 18 ++ agent/tools/list_repos.py | 59 +++++++ agent/utils/agents_md.py | 34 ---- agent/utils/github.py | 15 -- agent/utils/github_comments.py | 10 +- agent/webapp.py | 2 +- tests/test_github_comment_prompts.py | 2 +- tests/test_proxy_auth.py | 19 +-- 14 files changed, 184 insertions(+), 284 deletions(-) create mode 100644 agent/tools/get_branch_name.py create mode 100644 agent/tools/list_repos.py delete mode 100644 agent/utils/agents_md.py diff --git a/CUSTOMIZATION.md b/CUSTOMIZATION.md index 4aba65e6..22dbf84f 100644 --- a/CUSTOMIZATION.md +++ b/CUSTOMIZATION.md @@ -6,8 +6,8 @@ Open SWE is designed to be forked and customized for your org. The core agent is # agent/server.py — the key lines return create_deep_agent( model=make_model(os.environ.get("LLM_MODEL_ID", DEFAULT_LLM_MODEL_ID), temperature=0, max_tokens=20_000), - system_prompt=construct_system_prompt(repo_dir, ...), - tools=[http_request, fetch_url, commit_and_open_pr, linear_comment, slack_thread_reply], + system_prompt=construct_system_prompt(...), + tools=[http_request, fetch_url, list_repos, get_branch_name, commit_and_open_pr, linear_comment, slack_thread_reply], backend=sandbox_backend, middleware=[ ToolErrorMiddleware(), diff --git a/README.md b/README.md index 7b951ac1..d37a89c5 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ Rather than forking an existing agent or building from scratch, Open SWE **compo ```python create_deep_agent( model="anthropic:claude-opus-4-6", - system_prompt=construct_system_prompt(repo_dir, ...), - tools=[http_request, fetch_url, commit_and_open_pr, linear_comment, slack_thread_reply], + system_prompt=construct_system_prompt(...), + tools=[http_request, fetch_url, list_repos, get_branch_name, commit_and_open_pr, linear_comment, slack_thread_reply], backend=sandbox_backend, middleware=[ToolErrorMiddleware(), check_message_queue_before_model, ...], ) diff --git a/agent/integrations/langsmith.py b/agent/integrations/langsmith.py index 9324be2a..1d34bd98 100644 --- a/agent/integrations/langsmith.py +++ b/agent/integrations/langsmith.py @@ -85,6 +85,10 @@ def create_langsmith_sandbox( ) -> 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. diff --git a/agent/prompt.py b/agent/prompt.py index 4492f820..22fd26de 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -26,16 +26,35 @@ You are currently executing a software engineering task. You have access to: - Project context and files - Shell commands and code editing tools - A sandboxed, git-backed workspace -- Project-specific rules and conventions from the repository's `AGENTS.md` file (if present)""" +- Project-specific rules and conventions from the repository's `AGENTS.md` file (read after cloning — see Repository Setup)""" + + +REPO_SETUP_SECTION = """--- + +### Repository Setup + +Before starting any task, you must set up the repository in your sandbox. Follow these steps in order: + +1. **Find the repo** — Call `list_repos(organization_name="")` to list repositories for a GitHub organization, or `list_repos(organization_name="", is_organization=False)` for a personal user account. Match the repo to your task context (e.g. the Linear team/project or issue description). If you are unsure which repo to use, ask the user for confirmation before proceeding. + +2. **Clone the repo** — Clone it into `{working_dir}`. + +3. **Get your branch** — Always call the `get_branch_name` tool to get the branch name for this thread. + +4. **Checkout your branch** — Always fetch and checkout your branch before making any changes. + +5. **Read and follow AGENTS.md** — After cloning, check if `AGENTS.md` exists at the repository root (`{working_dir}//AGENTS.md`). If it exists, you MUST read it immediately and treat its contents as **mandatory rules** for all work in that repository. AGENTS.md contains project-specific conventions, coding standards, and constraints that override your default behavior. Violating AGENTS.md rules is equivalent to violating the system prompt. If AGENTS.md does not exist, skip this step. + +You MUST complete ALL of these steps before doing any other work. The sandbox starts clean — no repo is pre-cloned.""" FILE_MANAGEMENT_SECTION = """--- ### File & Code Management -- **Repository location:** `{working_dir}` +- **Repository location:** `{working_dir}/` (clone the repo here first — see Repository Setup) - Never create backup files. -- Work only within the existing Git repository. +- Work only within the cloned Git repository. - Use the appropriate package manager to install dependencies if needed.""" @@ -68,6 +87,12 @@ TOOL_USAGE_SECTION = """--- ### Tool Usage +#### `list_repos` +Lists GitHub repositories for a given organization or user via the GitHub API. Pass `organization_name` to specify which org or user to query. Set `is_organization=False` for personal user accounts (defaults to True). Call this first to find the right repo for your task. + +#### `get_branch_name` +Returns the git branch name for this thread. Always call this tool to get the correct branch before making any changes. + #### `execute` Run shell commands in the sandbox. Pass `timeout=` for long-running commands (default: 300s). @@ -259,8 +284,9 @@ Always call `commit_and_open_pr` followed by the appropriate reply tool once imp SYSTEM_PROMPT = ( WORKING_ENV_SECTION - + FILE_MANAGEMENT_SECTION + TASK_OVERVIEW_SECTION + + REPO_SETUP_SECTION + + FILE_MANAGEMENT_SECTION + TASK_EXECUTION_SECTION + TOOL_USAGE_SECTION + TOOL_BEST_PRACTICES_SECTION @@ -271,10 +297,6 @@ SYSTEM_PROMPT = ( + COMMUNICATION_SECTION + EXTERNAL_UNTRUSTED_COMMENTS_SECTION + COMMIT_PR_SECTION - + """ - -{agents_md_section} -""" ) @@ -282,20 +304,9 @@ def construct_system_prompt( working_dir: str, linear_project_id: str = "", linear_issue_number: str = "", - agents_md: str = "", ) -> str: - agents_md_section = "" - if agents_md: - agents_md_section = ( - "\nThe following text is pulled from the repository's AGENTS.md file. " - "It may contain specific instructions and guidelines for the agent.\n" - "\n" - f"{agents_md}\n" - "\n" - ) return SYSTEM_PROMPT.format( working_dir=working_dir, linear_project_id=linear_project_id or "", linear_issue_number=linear_issue_number or "", - agents_md_section=agents_md_section, ) diff --git a/agent/server.py b/agent/server.py index 82710da0..59526d71 100644 --- a/agent/server.py +++ b/agent/server.py @@ -5,12 +5,10 @@ # ruff: noqa: E402 import logging import os -import shlex 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 @@ -40,6 +38,7 @@ from .tools import ( create_pr_review, dismiss_pr_review, fetch_url, + get_branch_name, get_pr_review, github_comment, http_request, @@ -52,6 +51,7 @@ from .tools import ( linear_update_issue, list_pr_review_comments, list_pr_reviews, + list_repos, slack_thread_reply, submit_pr_review, update_pr_review, @@ -61,6 +61,7 @@ from .utils.auth import resolve_github_token from .utils.github_app import get_github_app_installation_token from .utils.model import make_model from .utils.sandbox import create_sandbox +from .utils.sandbox_paths import aresolve_sandbox_work_dir client = get_client() @@ -68,110 +69,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 ( - git_has_uncommitted_changes, - is_valid_git_repo, - remove_directory, -) -from .utils.sandbox_paths import aresolve_repo_dir, aresolve_sandbox_work_dir from .utils.sandbox_state import SANDBOX_BACKENDS, get_sandbox_id_from_metadata -async def _clone_or_pull_repo_in_sandbox( - sandbox_backend: SandboxBackendProtocol, - owner: str, - repo: str, -) -> str: - """Clone a GitHub repo into the sandbox, or pull if it already exists. - - Authentication is handled by the sandbox proxy (configured at sandbox creation - time), so no token is needed here. - - Args: - sandbox_backend: The sandbox backend to execute commands in - owner: GitHub repo owner - repo: GitHub repo name - - 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() - - work_dir = await aresolve_sandbox_work_dir(sandbox_backend) - repo_dir = await aresolve_repo_dir(sandbox_backend, repo) - clean_url = f"https://github.com/{owner}/{repo}.git" - - logger.info("Resolved sandbox work dir to %s", work_dir) - - 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) - - try: - pull_result = await loop.run_in_executor( - None, - sandbox_backend.execute, - f"cd {shlex.quote(repo_dir)} && git 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 - - logger.info("Repo updated at %s", repo_dir) - return repo_dir - - logger.info("Cloning repo %s/%s to %s", owner, repo, repo_dir) - try: - result = await loop.run_in_executor( - None, - sandbox_backend.execute, - f"git clone {shlex.quote(clean_url)} {shlex.quote(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) - - logger.info("Repo cloned successfully at %s", repo_dir) - return repo_dir - - async def _create_sandbox_with_proxy() -> SandboxBackendProtocol: """Create a new sandbox with GitHub proxy auth configured. @@ -210,15 +110,12 @@ async def _refresh_github_proxy( await asyncio.to_thread(_configure_github_proxy, sandbox_backend.id, installation_token) -async def _recreate_sandbox( - thread_id: str, - repo_owner: str, - repo_name: str, -) -> tuple[SandboxBackendProtocol, str]: - """Recreate a sandbox and clone the repo after a connection failure. +async def _recreate_sandbox(thread_id: str) -> SandboxBackendProtocol: + """Recreate a sandbox after a connection failure. Clears the stale cache entry, sets the SANDBOX_CREATING sentinel, - creates a fresh sandbox (with proxy auth configured), and clones the repo. + and creates a fresh sandbox (with proxy auth configured). + The agent is responsible for cloning repos via tools. """ SANDBOX_BACKENDS.pop(thread_id, None) await client.threads.update( @@ -227,12 +124,33 @@ async def _recreate_sandbox( ) try: sandbox_backend = await _create_sandbox_with_proxy() - repo_dir = await _clone_or_pull_repo_in_sandbox(sandbox_backend, repo_owner, repo_name) 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 check_or_recreate_sandbox( + sandbox_backend: SandboxBackendProtocol, thread_id: str +) -> SandboxBackendProtocol: + """Check if a cached sandbox is reachable; recreate it if not. + + Pings the sandbox with a lightweight command. If the sandbox is + unreachable (SandboxClientError), it is torn down and a fresh one + is created via _recreate_sandbox. + + Returns the original backend if healthy, or a new one if recreated. + """ + try: + await asyncio.to_thread(sandbox_backend.execute, "echo ok") + except SandboxClientError: + logger.warning( + "Cached sandbox is no longer reachable for thread %s, recreating", + thread_id, + ) + sandbox_backend = await _recreate_sandbox(thread_id) + return sandbox_backend async def _wait_for_sandbox_id(thread_id: str) -> str: @@ -269,16 +187,12 @@ DEFAULT_LLM_MODEL_ID = "anthropic:claude-opus-4-6" 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( @@ -298,28 +212,8 @@ 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") - await _refresh_github_proxy(sandbox_backend) - - 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 - ) - 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 - ) - except Exception: - logger.exception("Failed to pull repo in cached sandbox") - raise + sandbox_backend = await check_or_recreate_sandbox(sandbox_backend, thread_id) elif sandbox_id is None: logger.info("Creating new sandbox for thread %s", thread_id) @@ -328,21 +222,8 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 try: sandbox_backend = await _create_sandbox_with_proxy() 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 - ) - 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") + logger.exception("Failed to create sandbox") 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) @@ -371,56 +252,26 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 raise await _refresh_github_proxy(sandbox_backend) - - 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 - ) - 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 - ) - except Exception: - logger.exception("Failed to pull repo in existing sandbox") - raise + sandbox_backend = await check_or_recreate_sandbox(sandbox_backend, thread_id) 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) - - branch_name = get_config().get("metadata", {}).get("branch_name") - if branch_name: - logger.info("Checking out branch '%s' in sandbox for thread %s", branch_name, thread_id) - loop = asyncio.get_event_loop() - safe_repo_dir = shlex.quote(repo_dir) - safe_branch = shlex.quote(branch_name) - checkout_result = await loop.run_in_executor( - None, - sandbox_backend.execute, - f"cd {safe_repo_dir} && git fetch origin && git checkout {safe_branch}", + if sandbox_id != sandbox_backend.id: + await client.threads.update( + thread_id=thread_id, + metadata={"sandbox_id": sandbox_backend.id}, + ) + + await asyncio.to_thread( + sandbox_backend.execute, + "git config --global user.name 'open-swe[bot]' && git config --global user.email 'open-swe@users.noreply.github.com'", ) - if checkout_result.exit_code != 0: - logger.warning( - "Failed to checkout branch '%s': %s", - branch_name, - checkout_result.output[:200] if checkout_result.output else "", - ) 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) + + work_dir = await aresolve_sandbox_work_dir(sandbox_backend) logger.info("Returning agent with sandbox for thread %s", thread_id) return create_deep_agent( @@ -430,15 +281,16 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915 max_tokens=20_000, ), system_prompt=construct_system_prompt( - repo_dir, + working_dir=work_dir, linear_project_id=linear_project_id, linear_issue_number=linear_issue_number, - agents_md=agents_md, ), tools=[ http_request, fetch_url, web_search, + list_repos, + get_branch_name, commit_and_open_pr, linear_comment, linear_create_issue, diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py index fc9bce1f..a7e6655e 100644 --- a/agent/tools/__init__.py +++ b/agent/tools/__init__.py @@ -1,5 +1,6 @@ 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 .github_review import ( create_pr_review, @@ -18,6 +19,7 @@ from .linear_get_issue import linear_get_issue from .linear_get_issue_comments import linear_get_issue_comments from .linear_list_teams import linear_list_teams from .linear_update_issue import linear_update_issue +from .list_repos import list_repos from .slack_thread_reply import slack_thread_reply from .web_search import web_search @@ -26,6 +28,7 @@ __all__ = [ "create_pr_review", "dismiss_pr_review", "fetch_url", + "get_branch_name", "get_pr_review", "github_comment", "http_request", @@ -38,6 +41,7 @@ __all__ = [ "linear_get_issue_comments", "linear_list_teams", "linear_update_issue", + "list_repos", "slack_thread_reply", "submit_pr_review", "update_pr_review", diff --git a/agent/tools/get_branch_name.py b/agent/tools/get_branch_name.py new file mode 100644 index 00000000..afd1a53c --- /dev/null +++ b/agent/tools/get_branch_name.py @@ -0,0 +1,18 @@ +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. + + Returns the branch_name from thread metadata if set, + otherwise falls back to open-swe/{thread_id}. + """ + config = get_config() + metadata = config.get("metadata", {}) + branch_name = metadata.get("branch_name") + if not branch_name: + thread_id = config.get("configurable", {}).get("thread_id", "unknown") + branch_name = f"open-swe/{thread_id}" + return {"branch_name": branch_name} diff --git a/agent/tools/list_repos.py b/agent/tools/list_repos.py new file mode 100644 index 00000000..1f8a6a94 --- /dev/null +++ b/agent/tools/list_repos.py @@ -0,0 +1,59 @@ +import logging +from typing import Any + +import httpx + +from ..utils.github_app import get_github_app_installation_token + +logger = logging.getLogger(__name__) + + +async def list_repos( + organization_name: str, + is_organization: bool = True, + page: int = 1, + per_page: int = 100, + sort: str = "updated", + name_filter: str | None = None, +) -> dict[str, Any]: + """List GitHub repositories for an organization or user via the GitHub API. + + Uses /orgs/{name}/repos for organizations and /users/{name}/repos for + personal user accounts, based on the is_organization flag. + + Args: + organization_name: The GitHub organization or username to list repos for. + is_organization: If True, uses the /orgs/ endpoint. If False, uses the + /users/ endpoint for personal accounts. Default: True. + page: Page number to fetch (default: 1). + per_page: Number of repos per page, max 100 (default: 100). + sort: Sort field — "updated", "created", "pushed", or "full_name" (default: "updated"). + name_filter: Optional substring to filter repo names by (case-insensitive). + + If unsure which repo to use, ask the user for confirmation. + """ + try: + headers = {"Accept": "application/vnd.github+json"} + token = await get_github_app_installation_token() + if token: + headers["Authorization"] = f"Bearer {token}" + path_prefix = "orgs" if is_organization else "users" + async with httpx.AsyncClient() as client: + response = await client.get( + f"https://api.github.com/{path_prefix}/{organization_name}/repos", + headers=headers, + params={"per_page": min(per_page, 100), "sort": sort, "page": page}, + timeout=10, + ) + if response.status_code == 200: + repos: list[str] = [r["name"] for r in response.json()] + if name_filter: + repos = [r for r in repos if name_filter.lower() in r.lower()] + + has_next = 'rel="next"' in response.headers.get("link", "") + result: dict[str, Any] = {"repos": repos, "page": page, "has_next_page": has_next} + return result + return {"error": f"GitHub API returned status {response.status_code}"} + except Exception: + logger.warning("Failed to fetch repos for %s", organization_name) + return {"error": f"Failed to fetch repos for {organization_name}"} diff --git a/agent/utils/agents_md.py b/agent/utils/agents_md.py deleted file mode 100644 index 6afd63e7..00000000 --- a/agent/utils/agents_md.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Helpers for reading agent instructions from AGENTS.md.""" - -from __future__ import annotations - -import asyncio -import logging -import shlex - -from deepagents.backends.protocol import SandboxBackendProtocol - -logger = logging.getLogger(__name__) - - -async def read_agents_md_in_sandbox( - sandbox_backend: SandboxBackendProtocol, - repo_dir: str | None, -) -> str | None: - """Read AGENTS.md from the repo root if it exists.""" - if not repo_dir: - return None - - safe_agents_path = shlex.quote(f"{repo_dir}/AGENTS.md") - loop = asyncio.get_event_loop() - result = await loop.run_in_executor( - None, - sandbox_backend.execute, - f"test -f {safe_agents_path} && cat {safe_agents_path}", - ) - if result.exit_code != 0: - logger.debug("AGENTS.md not found at %s", safe_agents_path) - return None - content = result.output or "" - content = content.strip() - return content or None diff --git a/agent/utils/github.py b/agent/utils/github.py index fec0c5c3..be7fc835 100644 --- a/agent/utils/github.py +++ b/agent/utils/github.py @@ -23,21 +23,6 @@ def _run_git( return sandbox_backend.execute(f"cd {safe_repo_dir} && {command}") -def is_valid_git_repo(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> bool: - """Check if directory is a valid git repository.""" - git_dir = f"{repo_dir}/.git" - safe_git_dir = shlex.quote(git_dir) - result = sandbox_backend.execute(f"test -d {safe_git_dir} && echo exists") - return result.exit_code == 0 and "exists" in result.output - - -def remove_directory(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> bool: - """Remove a directory and all its contents.""" - safe_repo_dir = shlex.quote(repo_dir) - result = sandbox_backend.execute(f"rm -rf {safe_repo_dir}") - return result.exit_code == 0 - - def git_has_uncommitted_changes(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> bool: """Check whether the repo has uncommitted changes.""" result = _run_git(sandbox_backend, repo_dir, "git status --porcelain") diff --git a/agent/utils/github_comments.py b/agent/utils/github_comments.py index bad1a78b..3ef1f4dc 100644 --- a/agent/utils/github_comments.py +++ b/agent/utils/github_comments.py @@ -383,7 +383,11 @@ async def extract_pr_context( return repo_config, pr_number, branch_name, github_login, pr_url, comment_id, node_id -def build_pr_prompt(comments: list[dict[str, Any]], pr_url: str) -> str: +def build_pr_prompt( + comments: list[dict[str, Any]], + pr_url: str, + repo_config: dict[str, str] | None = None, +) -> str: """Format PR comments into a human message for the agent.""" lines: list[str] = [] for c in comments: @@ -398,8 +402,12 @@ def build_pr_prompt(comments: list[dict[str, Any]], pr_url: str) -> str: lines.append(f"\n**{author}**:\n{body}\n") comments_text = "".join(lines) + repo_line = "" + if repo_config: + repo_line = f"## Repository: {repo_config.get('owner')}/{repo_config.get('name')}\n\n" return ( "You've been tagged in GitHub PR comments. Please resolve them.\n\n" + f"{repo_line}" f"PR: {pr_url}\n\n" f"## Comments:\n{comments_text}\n\n" "If code changes are needed:\n" diff --git a/agent/webapp.py b/agent/webapp.py index e8fb7e0e..2ba61379 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -1317,7 +1317,7 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) -> logger.info("No comments found since last @open-swe tag for PR %s", pr_number) return - prompt = build_pr_prompt(comments, pr_url) + prompt = build_pr_prompt(comments, pr_url, repo_config=repo_config) await _trigger_or_queue_run( thread_id, prompt, diff --git a/tests/test_github_comment_prompts.py b/tests/test_github_comment_prompts.py index 3c88fe13..8195fc7c 100644 --- a/tests/test_github_comment_prompts.py +++ b/tests/test_github_comment_prompts.py @@ -24,7 +24,7 @@ def test_build_pr_prompt_wraps_external_comments_without_trust_section() -> None def test_construct_system_prompt_includes_untrusted_comment_guidance() -> None: - prompt = construct_system_prompt("/workspace/open-swe") + prompt = construct_system_prompt(working_dir="/workspace") assert "External Untrusted Comments" in prompt assert github_comments.UNTRUSTED_GITHUB_COMMENT_OPEN_TAG in prompt diff --git a/tests/test_proxy_auth.py b/tests/test_proxy_auth.py index 8ada47f0..c9cf196b 100644 --- a/tests/test_proxy_auth.py +++ b/tests/test_proxy_auth.py @@ -198,7 +198,6 @@ class TestRefreshProxyOnSandboxReuse: mock_sandbox = MagicMock(id="sandbox-cached") with ( - patch("agent.server.get_config", return_value=config), patch( "agent.server.resolve_github_token", new_callable=AsyncMock, @@ -216,14 +215,14 @@ class TestRefreshProxyOnSandboxReuse: ), patch("agent.server._configure_github_proxy") as mock_proxy, patch( - "agent.server._clone_or_pull_repo_in_sandbox", + "agent.server.aresolve_sandbox_work_dir", new_callable=AsyncMock, - return_value="/workspace/open-swe", + return_value="/workspace", ), patch( - "agent.server.read_agents_md_in_sandbox", + "agent.server.check_or_recreate_sandbox", new_callable=AsyncMock, - return_value="", + return_value=mock_sandbox, ), patch("agent.server.make_model", return_value=MagicMock()), patch("agent.server.construct_system_prompt", return_value="prompt"), @@ -248,7 +247,6 @@ class TestRefreshProxyOnSandboxReuse: mock_sandbox = MagicMock(id="sandbox-existing") with ( - patch("agent.server.get_config", return_value=config), patch( "agent.server.resolve_github_token", new_callable=AsyncMock, @@ -267,14 +265,9 @@ class TestRefreshProxyOnSandboxReuse: ), patch("agent.server._configure_github_proxy") as mock_proxy, patch( - "agent.server._clone_or_pull_repo_in_sandbox", + "agent.server.aresolve_sandbox_work_dir", new_callable=AsyncMock, - return_value="/workspace/open-swe", - ), - patch( - "agent.server.read_agents_md_in_sandbox", - new_callable=AsyncMock, - return_value="", + return_value="/workspace", ), patch("agent.server.make_model", return_value=MagicMock()), patch("agent.server.construct_system_prompt", return_value="prompt"),