feat: stop auto-cloning and let agent manage repo setup [closes OPE-21] (#1159)

* 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 <braceasproul@gmail.com>

* Apply suggestion from @bracesproul

Co-authored-by: Brace Sproul <braceasproul@gmail.com>

* 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 <braceasproul@gmail.com>

* Update agent/prompt.py

Co-authored-by: Brace Sproul <braceasproul@gmail.com>

* 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 <braceasproul@gmail.com>
This commit is contained in:
Aran Yogesh 2026-04-10 17:04:55 -07:00 • committed by GitHub
parent 91f63de361
commit 2039fe6660
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 184 additions and 284 deletions

View file

@ -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 # agent/server.py — the key lines
return create_deep_agent( return create_deep_agent(
model=make_model(os.environ.get("LLM_MODEL_ID", DEFAULT_LLM_MODEL_ID), temperature=0, max_tokens=20_000), 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, ...), system_prompt=construct_system_prompt(...),
tools=[http_request, fetch_url, commit_and_open_pr, linear_comment, slack_thread_reply], tools=[http_request, fetch_url, list_repos, get_branch_name, commit_and_open_pr, linear_comment, slack_thread_reply],
backend=sandbox_backend, backend=sandbox_backend,
middleware=[ middleware=[
ToolErrorMiddleware(), ToolErrorMiddleware(),

View file

@ -42,8 +42,8 @@ Rather than forking an existing agent or building from scratch, Open SWE **compo
```python ```python
create_deep_agent( create_deep_agent(
model="anthropic:claude-opus-4-6", model="anthropic:claude-opus-4-6",
system_prompt=construct_system_prompt(repo_dir, ...), system_prompt=construct_system_prompt(...),
tools=[http_request, fetch_url, commit_and_open_pr, linear_comment, slack_thread_reply], tools=[http_request, fetch_url, list_repos, get_branch_name, commit_and_open_pr, linear_comment, slack_thread_reply],
backend=sandbox_backend, backend=sandbox_backend,
middleware=[ToolErrorMiddleware(), check_message_queue_before_model, ...], middleware=[ToolErrorMiddleware(), check_message_queue_before_model, ...],
) )

View file

@ -85,6 +85,10 @@ def create_langsmith_sandbox(
) -> SandboxBackendProtocol: ) -> SandboxBackendProtocol:
"""Create or connect to a LangSmith sandbox without automatic cleanup. """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: Args:
sandbox_id: Optional existing sandbox ID to connect to. sandbox_id: Optional existing sandbox ID to connect to.
If None, creates a new sandbox. If None, creates a new sandbox.

View file

@ -26,16 +26,35 @@ You are currently executing a software engineering task. You have access to:
- Project context and files - Project context and files
- Shell commands and code editing tools - Shell commands and code editing tools
- A sandboxed, git-backed workspace - 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="<org>")` to list repositories for a GitHub organization, or `list_repos(organization_name="<username>", 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}/<repo>/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_MANAGEMENT_SECTION = """---
### File & Code Management ### File & Code Management
- **Repository location:** `{working_dir}` - **Repository location:** `{working_dir}/<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 cloned Git repository.
- Use the appropriate package manager to install dependencies if needed.""" - Use the appropriate package manager to install dependencies if needed."""
@ -68,6 +87,12 @@ TOOL_USAGE_SECTION = """---
### Tool Usage ### 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` #### `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).
@ -259,8 +284,9 @@ Always call `commit_and_open_pr` followed by the appropriate reply tool once imp
SYSTEM_PROMPT = ( SYSTEM_PROMPT = (
WORKING_ENV_SECTION WORKING_ENV_SECTION
+ FILE_MANAGEMENT_SECTION
+ TASK_OVERVIEW_SECTION + TASK_OVERVIEW_SECTION
+ REPO_SETUP_SECTION
+ FILE_MANAGEMENT_SECTION
+ TASK_EXECUTION_SECTION + TASK_EXECUTION_SECTION
+ TOOL_USAGE_SECTION + TOOL_USAGE_SECTION
+ TOOL_BEST_PRACTICES_SECTION + TOOL_BEST_PRACTICES_SECTION
@ -271,10 +297,6 @@ SYSTEM_PROMPT = (
+ COMMUNICATION_SECTION + COMMUNICATION_SECTION
+ EXTERNAL_UNTRUSTED_COMMENTS_SECTION + EXTERNAL_UNTRUSTED_COMMENTS_SECTION
+ COMMIT_PR_SECTION + COMMIT_PR_SECTION
+ """
{agents_md_section}
"""
) )
@ -282,20 +304,9 @@ def construct_system_prompt(
working_dir: str, working_dir: str,
linear_project_id: str = "", linear_project_id: str = "",
linear_issue_number: str = "", linear_issue_number: str = "",
agents_md: str = "",
) -> 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"
"<agents_md>\n"
f"{agents_md}\n"
"</agents_md>\n"
)
return SYSTEM_PROMPT.format( return SYSTEM_PROMPT.format(
working_dir=working_dir, working_dir=working_dir,
linear_project_id=linear_project_id or "<PROJECT_ID>", linear_project_id=linear_project_id or "<PROJECT_ID>",
linear_issue_number=linear_issue_number or "<ISSUE_NUMBER>", linear_issue_number=linear_issue_number or "<ISSUE_NUMBER>",
agents_md_section=agents_md_section,
) )

View file

@ -5,12 +5,10 @@
# ruff: noqa: E402 # ruff: noqa: E402
import logging import logging
import os import os
import shlex
import warnings 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
@ -40,6 +38,7 @@ from .tools import (
create_pr_review, create_pr_review,
dismiss_pr_review, dismiss_pr_review,
fetch_url, fetch_url,
get_branch_name,
get_pr_review, get_pr_review,
github_comment, github_comment,
http_request, http_request,
@ -52,6 +51,7 @@ from .tools import (
linear_update_issue, linear_update_issue,
list_pr_review_comments, list_pr_review_comments,
list_pr_reviews, list_pr_reviews,
list_repos,
slack_thread_reply, slack_thread_reply,
submit_pr_review, submit_pr_review,
update_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.github_app import get_github_app_installation_token
from .utils.model import make_model from .utils.model import make_model
from .utils.sandbox import create_sandbox from .utils.sandbox import create_sandbox
from .utils.sandbox_paths import aresolve_sandbox_work_dir
client = get_client() client = get_client()
@ -68,110 +69,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 (
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 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: async def _create_sandbox_with_proxy() -> SandboxBackendProtocol:
"""Create a new sandbox with GitHub proxy auth configured. """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) await asyncio.to_thread(_configure_github_proxy, sandbox_backend.id, installation_token)
async def _recreate_sandbox( async def _recreate_sandbox(thread_id: str) -> SandboxBackendProtocol:
thread_id: str, """Recreate a sandbox after a connection failure.
repo_owner: str,
repo_name: str,
) -> tuple[SandboxBackendProtocol, str]:
"""Recreate a sandbox and clone the repo after a connection failure.
Clears the stale cache entry, sets the SANDBOX_CREATING sentinel, 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) SANDBOX_BACKENDS.pop(thread_id, None)
await client.threads.update( await client.threads.update(
@ -227,12 +124,33 @@ async def _recreate_sandbox(
) )
try: try:
sandbox_backend = await _create_sandbox_with_proxy() sandbox_backend = await _create_sandbox_with_proxy()
repo_dir = await _clone_or_pull_repo_in_sandbox(sandbox_backend, repo_owner, repo_name)
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 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: 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 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(
@ -298,28 +212,8 @@ 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", {})
repo_dir = metadata.get("repo_dir")
await _refresh_github_proxy(sandbox_backend) await _refresh_github_proxy(sandbox_backend)
sandbox_backend = await check_or_recreate_sandbox(sandbox_backend, thread_id)
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
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)
@ -328,21 +222,8 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
try: try:
sandbox_backend = await _create_sandbox_with_proxy() sandbox_backend = await _create_sandbox_with_proxy()
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
)
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: 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) 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 raise
await _refresh_github_proxy(sandbox_backend) await _refresh_github_proxy(sandbox_backend)
sandbox_backend = await check_or_recreate_sandbox(sandbox_backend, 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
)
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_BACKENDS[thread_id] = sandbox_backend SANDBOX_BACKENDS[thread_id] = sandbox_backend
if not repo_dir: if sandbox_id != sandbox_backend.id:
msg = "Cannot proceed: no repo was cloned. Set 'repo.owner' and 'repo.name' in the configurable config" await client.threads.update(
raise RuntimeError(msg) thread_id=thread_id,
metadata={"sandbox_id": sandbox_backend.id},
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) await asyncio.to_thread(
loop = asyncio.get_event_loop() sandbox_backend.execute,
safe_repo_dir = shlex.quote(repo_dir) "git config --global user.name 'open-swe[bot]' && git config --global user.email 'open-swe@users.noreply.github.com'",
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 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_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)
work_dir = await aresolve_sandbox_work_dir(sandbox_backend)
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(
@ -430,15 +281,16 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
max_tokens=20_000, max_tokens=20_000,
), ),
system_prompt=construct_system_prompt( system_prompt=construct_system_prompt(
repo_dir, working_dir=work_dir,
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,
fetch_url, fetch_url,
web_search, web_search,
list_repos,
get_branch_name,
commit_and_open_pr, commit_and_open_pr,
linear_comment, linear_comment,
linear_create_issue, linear_create_issue,

View file

@ -1,5 +1,6 @@
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 .github_review import ( from .github_review import (
create_pr_review, 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_get_issue_comments import linear_get_issue_comments
from .linear_list_teams import linear_list_teams from .linear_list_teams import linear_list_teams
from .linear_update_issue import linear_update_issue from .linear_update_issue import linear_update_issue
from .list_repos import list_repos
from .slack_thread_reply import slack_thread_reply from .slack_thread_reply import slack_thread_reply
from .web_search import web_search from .web_search import web_search
@ -26,6 +28,7 @@ __all__ = [
"create_pr_review", "create_pr_review",
"dismiss_pr_review", "dismiss_pr_review",
"fetch_url", "fetch_url",
"get_branch_name",
"get_pr_review", "get_pr_review",
"github_comment", "github_comment",
"http_request", "http_request",
@ -38,6 +41,7 @@ __all__ = [
"linear_get_issue_comments", "linear_get_issue_comments",
"linear_list_teams", "linear_list_teams",
"linear_update_issue", "linear_update_issue",
"list_repos",
"slack_thread_reply", "slack_thread_reply",
"submit_pr_review", "submit_pr_review",
"update_pr_review", "update_pr_review",

View file

@ -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}

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

@ -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}"}

View file

@ -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

View file

@ -23,21 +23,6 @@ def _run_git(
return sandbox_backend.execute(f"cd {safe_repo_dir} && {command}") 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: def git_has_uncommitted_changes(sandbox_backend: SandboxBackendProtocol, repo_dir: str) -> bool:
"""Check whether the repo has uncommitted changes.""" """Check whether the repo has uncommitted changes."""
result = _run_git(sandbox_backend, repo_dir, "git status --porcelain") result = _run_git(sandbox_backend, repo_dir, "git status --porcelain")

View file

@ -383,7 +383,11 @@ async def extract_pr_context(
return repo_config, pr_number, branch_name, github_login, pr_url, comment_id, node_id 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.""" """Format PR comments into a human message for the agent."""
lines: list[str] = [] lines: list[str] = []
for c in comments: 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") lines.append(f"\n**{author}**:\n{body}\n")
comments_text = "".join(lines) comments_text = "".join(lines)
repo_line = ""
if repo_config:
repo_line = f"## Repository: {repo_config.get('owner')}/{repo_config.get('name')}\n\n"
return ( return (
"You've been tagged in GitHub PR comments. Please resolve them.\n\n" "You've been tagged in GitHub PR comments. Please resolve them.\n\n"
f"{repo_line}"
f"PR: {pr_url}\n\n" f"PR: {pr_url}\n\n"
f"## Comments:\n{comments_text}\n\n" f"## Comments:\n{comments_text}\n\n"
"If code changes are needed:\n" "If code changes are needed:\n"

View file

@ -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) logger.info("No comments found since last @open-swe tag for PR %s", pr_number)
return return
prompt = build_pr_prompt(comments, pr_url) prompt = build_pr_prompt(comments, pr_url, repo_config=repo_config)
await _trigger_or_queue_run( await _trigger_or_queue_run(
thread_id, thread_id,
prompt, prompt,

View file

@ -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: 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 "External Untrusted Comments" in prompt
assert github_comments.UNTRUSTED_GITHUB_COMMENT_OPEN_TAG in prompt assert github_comments.UNTRUSTED_GITHUB_COMMENT_OPEN_TAG in prompt

View file

@ -198,7 +198,6 @@ class TestRefreshProxyOnSandboxReuse:
mock_sandbox = MagicMock(id="sandbox-cached") mock_sandbox = MagicMock(id="sandbox-cached")
with ( with (
patch("agent.server.get_config", return_value=config),
patch( patch(
"agent.server.resolve_github_token", "agent.server.resolve_github_token",
new_callable=AsyncMock, new_callable=AsyncMock,
@ -216,14 +215,14 @@ class TestRefreshProxyOnSandboxReuse:
), ),
patch("agent.server._configure_github_proxy") as mock_proxy, patch("agent.server._configure_github_proxy") as mock_proxy,
patch( patch(
"agent.server._clone_or_pull_repo_in_sandbox", "agent.server.aresolve_sandbox_work_dir",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value="/workspace/open-swe", return_value="/workspace",
), ),
patch( patch(
"agent.server.read_agents_md_in_sandbox", "agent.server.check_or_recreate_sandbox",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value="", return_value=mock_sandbox,
), ),
patch("agent.server.make_model", return_value=MagicMock()), patch("agent.server.make_model", return_value=MagicMock()),
patch("agent.server.construct_system_prompt", return_value="prompt"), patch("agent.server.construct_system_prompt", return_value="prompt"),
@ -248,7 +247,6 @@ class TestRefreshProxyOnSandboxReuse:
mock_sandbox = MagicMock(id="sandbox-existing") mock_sandbox = MagicMock(id="sandbox-existing")
with ( with (
patch("agent.server.get_config", return_value=config),
patch( patch(
"agent.server.resolve_github_token", "agent.server.resolve_github_token",
new_callable=AsyncMock, new_callable=AsyncMock,
@ -267,14 +265,9 @@ class TestRefreshProxyOnSandboxReuse:
), ),
patch("agent.server._configure_github_proxy") as mock_proxy, patch("agent.server._configure_github_proxy") as mock_proxy,
patch( patch(
"agent.server._clone_or_pull_repo_in_sandbox", "agent.server.aresolve_sandbox_work_dir",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value="/workspace/open-swe", return_value="/workspace",
),
patch(
"agent.server.read_agents_md_in_sandbox",
new_callable=AsyncMock,
return_value="",
), ),
patch("agent.server.make_model", return_value=MagicMock()), patch("agent.server.make_model", return_value=MagicMock()),
patch("agent.server.construct_system_prompt", return_value="prompt"), patch("agent.server.construct_system_prompt", return_value="prompt"),