mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
fix: Reduce graph load and dashboard repo failures (#1412)
* fix: reduce graph load and dashboard repo failures * fix: surface repo listing timeouts
This commit is contained in:
parent
9df5883d1f
commit
8b18e7e95d
10 changed files with 368 additions and 154 deletions
|
|
@ -91,6 +91,8 @@ from .user_mappings import (
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/dashboard/api", tags=["dashboard"])
|
||||
_GITHUB_API_TIMEOUT = httpx.Timeout(10.0, connect=3.0)
|
||||
_SKIPPABLE_INSTALLATION_REPO_STATUS_CODES = frozenset({403, 404})
|
||||
|
||||
|
||||
def _require_admin(session: dict[str, Any]) -> dict[str, Any]:
|
||||
|
|
@ -446,6 +448,16 @@ def _next_link_url(link_header: str | None) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def _github_api_http_exception(status_code: int) -> HTTPException:
|
||||
if status_code == 401:
|
||||
return HTTPException(401, "github token expired, re-login required")
|
||||
if status_code == 403:
|
||||
return HTTPException(403, "github API forbidden")
|
||||
if status_code == 404:
|
||||
return HTTPException(404, "github API resource not found")
|
||||
return HTTPException(502, f"github API error ({status_code})")
|
||||
|
||||
|
||||
async def _paginate(
|
||||
client: httpx.AsyncClient,
|
||||
url: str,
|
||||
|
|
@ -466,10 +478,23 @@ async def _paginate(
|
|||
first = True
|
||||
while next_url and len(out) < cap:
|
||||
params = {"per_page": "100"} if first else None
|
||||
r = await client.get(next_url, headers=headers, params=params)
|
||||
if r.status_code == 401:
|
||||
raise HTTPException(401, "github token expired, re-login required")
|
||||
r.raise_for_status()
|
||||
try:
|
||||
r = await client.get(next_url, headers=headers, params=params)
|
||||
except httpx.TimeoutException as exc:
|
||||
logger.warning("GitHub API timed out while paginating %s", next_url)
|
||||
raise HTTPException(503, "github API request timed out") from exc
|
||||
except httpx.RequestError as exc:
|
||||
logger.warning("GitHub API request failed while paginating %s: %s", next_url, exc)
|
||||
raise HTTPException(502, "github API request failed") from exc
|
||||
try:
|
||||
r.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning(
|
||||
"GitHub API returned %s while paginating %s",
|
||||
r.status_code,
|
||||
next_url,
|
||||
)
|
||||
raise _github_api_http_exception(r.status_code) from exc
|
||||
body = r.json()
|
||||
page = body.get(items_key, []) if items_key else body
|
||||
if isinstance(page, list):
|
||||
|
|
@ -498,7 +523,7 @@ async def list_repos(
|
|||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(timeout=_GITHUB_API_TIMEOUT) as client:
|
||||
try:
|
||||
installations = await _paginate(
|
||||
client,
|
||||
|
|
@ -531,10 +556,13 @@ async def list_repos(
|
|||
headers=headers,
|
||||
items_key="repositories",
|
||||
)
|
||||
except HTTPException:
|
||||
except HTTPException as exc:
|
||||
if exc.status_code in _SKIPPABLE_INSTALLATION_REPO_STATUS_CODES:
|
||||
logger.warning(
|
||||
"Skipping installation %s repository list: %s", inst_id, exc.detail
|
||||
)
|
||||
continue
|
||||
raise
|
||||
except httpx.HTTPStatusError:
|
||||
continue
|
||||
repositories.extend(repos)
|
||||
return {
|
||||
"installations": [
|
||||
|
|
|
|||
|
|
@ -169,6 +169,32 @@ async def get_team_default_model(
|
|||
return _resolve_default_pair(model, effort)
|
||||
|
||||
|
||||
async def get_team_default_model_pair(
|
||||
role: Literal["agent", "reviewer"],
|
||||
) -> tuple[tuple[str, str], tuple[str, str]]:
|
||||
"""Return default ``(main, subagent)`` model pairs for ``role`` from one store read."""
|
||||
settings = await get_team_settings()
|
||||
if role == "agent":
|
||||
main = _resolve_default_pair(
|
||||
settings.get("default_agent_model"),
|
||||
settings.get("default_agent_reasoning_effort"),
|
||||
)
|
||||
subagent = _resolve_default_pair(
|
||||
settings.get("default_agent_subagent_model"),
|
||||
settings.get("default_agent_subagent_reasoning_effort"),
|
||||
)
|
||||
else:
|
||||
main = _resolve_default_pair(
|
||||
settings.get("default_reviewer_model"),
|
||||
settings.get("default_reviewer_reasoning_effort"),
|
||||
)
|
||||
subagent = _resolve_default_pair(
|
||||
settings.get("default_reviewer_subagent_model"),
|
||||
settings.get("default_reviewer_subagent_reasoning_effort"),
|
||||
)
|
||||
return main, subagent
|
||||
|
||||
|
||||
async def get_team_review_trace_links_enabled() -> bool:
|
||||
"""Return whether GitHub review bodies should include a LangSmith trace link."""
|
||||
settings = await get_team_settings()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ agent for code review only:
|
|||
"""
|
||||
# ruff: noqa: E402
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import warnings
|
||||
|
|
@ -30,6 +31,7 @@ warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarnin
|
|||
from deepagents import create_deep_agent
|
||||
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||||
|
||||
from .dashboard.team_settings import get_team_default_model_pair
|
||||
from .middleware import (
|
||||
SanitizeThinkingBlocksMiddleware,
|
||||
SanitizeToolInputsMiddleware,
|
||||
|
|
@ -640,8 +642,8 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
# Cache in-process so reviewer tools and the sandbox proxy can read it this run.
|
||||
cache_github_token_for_thread(thread_id, github_token, expires_at=expires_at)
|
||||
|
||||
repo_private = config["configurable"].get("repo_private")
|
||||
github_proxy_token = github_token if repo_private is False else None
|
||||
github_proxy_token = github_token
|
||||
github_api_token = github_token
|
||||
sandbox_backend = await ensure_sandbox_for_thread(
|
||||
thread_id,
|
||||
github_proxy_token=github_proxy_token,
|
||||
|
|
@ -659,74 +661,51 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
is_re_review = bool(config["configurable"].get("re_review"))
|
||||
reviewer_event = str(config["configurable"].get("reviewer_event", "") or "")
|
||||
|
||||
# Fetch the PR's unified diff from the GitHub API and populate
|
||||
# diff_text + diff_line_set so add_finding can reject bad anchors at
|
||||
# creation time (instead of letting them fail at publish_review with a
|
||||
# 422 the agent then has to clean up). The API path is reliable — the
|
||||
# previous sandbox-based prep was sometimes producing empty diffs,
|
||||
# which is what forced the earlier hotfix. If the fetch fails, leave
|
||||
# the validation disabled so the run isn't blocked entirely.
|
||||
pr_diff_text = ""
|
||||
pr_diff_line_set: dict[str, set[int]] | None = None
|
||||
if (
|
||||
can_fetch_pr = (
|
||||
pr_number is not None
|
||||
and isinstance(pr_number, int)
|
||||
and repo_owner
|
||||
and repo_name
|
||||
and github_token
|
||||
):
|
||||
and bool(repo_owner)
|
||||
and bool(repo_name)
|
||||
and bool(github_api_token)
|
||||
)
|
||||
|
||||
async def _fetch_diff_context() -> tuple[str, dict[str, dict[str, set[int]]] | None]:
|
||||
if not can_fetch_pr or github_api_token is None or not isinstance(pr_number, int):
|
||||
return "", None
|
||||
fetched_diff = await fetch_pr_diff(
|
||||
owner=repo_owner,
|
||||
repo=repo_name,
|
||||
pr_number=pr_number,
|
||||
token=github_token,
|
||||
token=github_api_token,
|
||||
)
|
||||
if fetched_diff is not None:
|
||||
pr_diff_text = fetched_diff
|
||||
pr_diff_line_set = compute_diff_line_set(fetched_diff)
|
||||
config["configurable"]["diff_text"] = pr_diff_text
|
||||
config["configurable"]["diff_line_set"] = pr_diff_line_set
|
||||
if fetched_diff is None:
|
||||
return "", None
|
||||
return fetched_diff, compute_diff_line_set(fetched_diff)
|
||||
|
||||
# Fetch the PR title and body fresh every run (never cached) so an edited
|
||||
# title/description is reflected on re-reviews. Injected into the review
|
||||
# context so the agent knows the original intent of the PR. On failure we
|
||||
# leave both blank and the overview block is simply omitted.
|
||||
pr_title = ""
|
||||
pr_body = ""
|
||||
if (
|
||||
pr_number is not None
|
||||
and isinstance(pr_number, int)
|
||||
and repo_owner
|
||||
and repo_name
|
||||
and github_token
|
||||
):
|
||||
async def _fetch_pr_overview() -> tuple[str, str]:
|
||||
if not can_fetch_pr or github_api_token is None or not isinstance(pr_number, int):
|
||||
return "", ""
|
||||
metadata = await fetch_pr_metadata(
|
||||
owner=repo_owner,
|
||||
repo=repo_name,
|
||||
pr_number=pr_number,
|
||||
token=github_token,
|
||||
token=github_api_token,
|
||||
)
|
||||
if metadata is not None:
|
||||
pr_title, pr_body = metadata
|
||||
return metadata if metadata is not None else ("", "")
|
||||
|
||||
existing_threads_block = ""
|
||||
if (
|
||||
pr_number is not None
|
||||
and isinstance(pr_number, int)
|
||||
and repo_owner
|
||||
and repo_name
|
||||
and github_token
|
||||
):
|
||||
async def _fetch_existing_threads_block() -> str:
|
||||
if not can_fetch_pr or github_api_token is None or not isinstance(pr_number, int):
|
||||
return ""
|
||||
try:
|
||||
threads = await fetch_pr_review_threads(
|
||||
owner=repo_owner,
|
||||
repo=repo_name,
|
||||
pr_number=pr_number,
|
||||
token=github_token,
|
||||
token=github_api_token,
|
||||
)
|
||||
await reconcile_findings_with_review_threads(thread_id, threads)
|
||||
existing_threads_block = _format_pr_review_threads(threads)
|
||||
if existing_threads_block:
|
||||
block = _format_pr_review_threads(threads)
|
||||
if block:
|
||||
logger.info(
|
||||
"Loaded %d existing PR review thread(s) into reviewer context for %s/%s#%s",
|
||||
len(threads),
|
||||
|
|
@ -734,6 +713,7 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
repo_name,
|
||||
pr_number,
|
||||
)
|
||||
return block
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"Failed to load existing PR review threads for %s/%s#%s; "
|
||||
|
|
@ -742,6 +722,51 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
repo_name,
|
||||
pr_number,
|
||||
)
|
||||
return ""
|
||||
|
||||
async def _fetch_repo_style_prompt() -> str | None:
|
||||
if not repo_owner or not repo_name:
|
||||
return None
|
||||
from .dashboard.review_styles import get_repo_custom_prompt
|
||||
|
||||
return await get_repo_custom_prompt(repo_owner, repo_name)
|
||||
|
||||
async def _fetch_agents_md_context() -> str | None:
|
||||
if not repo_owner or not repo_name or not base_sha:
|
||||
return None
|
||||
content = await fetch_agents_md(
|
||||
repo_owner,
|
||||
repo_name,
|
||||
base_sha,
|
||||
token=github_api_token,
|
||||
)
|
||||
if content:
|
||||
logger.info(
|
||||
"Loaded AGENTS.md (%d chars) from %s/%s@%s into reviewer prompt",
|
||||
len(content),
|
||||
repo_owner,
|
||||
repo_name,
|
||||
base_sha,
|
||||
)
|
||||
return content
|
||||
|
||||
(
|
||||
diff_context,
|
||||
pr_overview,
|
||||
existing_threads_block,
|
||||
repo_style_prompt,
|
||||
agents_md_content,
|
||||
) = await asyncio.gather(
|
||||
_fetch_diff_context(),
|
||||
_fetch_pr_overview(),
|
||||
_fetch_existing_threads_block(),
|
||||
_fetch_repo_style_prompt(),
|
||||
_fetch_agents_md_context(),
|
||||
)
|
||||
pr_diff_text, pr_diff_line_set = diff_context
|
||||
pr_title, pr_body = pr_overview
|
||||
config["configurable"]["diff_text"] = pr_diff_text
|
||||
config["configurable"]["diff_line_set"] = pr_diff_line_set
|
||||
|
||||
review_context = ""
|
||||
if pr_number is not None and isinstance(pr_number, int):
|
||||
|
|
@ -787,8 +812,6 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
existing_threads_block=existing_threads_block,
|
||||
)
|
||||
|
||||
from .dashboard.team_settings import get_team_default_model, get_team_default_subagent_model
|
||||
|
||||
configured_model_id = config["configurable"].get("reviewer_model_id")
|
||||
configured_effort = config["configurable"].get("reviewer_reasoning_effort")
|
||||
if isinstance(configured_model_id, str) and configured_model_id:
|
||||
|
|
@ -797,13 +820,15 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
subagent_model_id = model_id
|
||||
subagent_effort = reasoning_effort
|
||||
else:
|
||||
model_id, reasoning_effort = await get_team_default_model("reviewer")
|
||||
(
|
||||
(model_id, reasoning_effort),
|
||||
(subagent_model_id, subagent_effort),
|
||||
) = await get_team_default_model_pair("reviewer")
|
||||
logger.info(
|
||||
"Using team default reviewer model: model=%s effort=%s",
|
||||
model_id,
|
||||
reasoning_effort,
|
||||
)
|
||||
subagent_model_id, subagent_effort = await get_team_default_subagent_model("reviewer")
|
||||
logger.info(
|
||||
"Using team default reviewer subagent model: model=%s effort=%s",
|
||||
subagent_model_id,
|
||||
|
|
@ -833,34 +858,8 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
config["configurable"].get("reviewer_eval") is True
|
||||
or config["configurable"].get("eval") is True
|
||||
)
|
||||
repo_style_prompt: str | None = None
|
||||
if repo_owner and repo_name:
|
||||
from .dashboard.review_styles import get_repo_custom_prompt
|
||||
|
||||
repo_style_prompt = await get_repo_custom_prompt(repo_owner, repo_name)
|
||||
|
||||
# Fetch AGENTS.md from base_sha (the target branch's state before this
|
||||
# PR's changes), not head_sha. The contents are inlined into the system
|
||||
# prompt, so reading from head would let a PR author smuggle reviewer
|
||||
# instructions ("ignore all bugs", "publish no findings") into the
|
||||
# review. base_sha is the trusted ref.
|
||||
agents_md_content: str | None = None
|
||||
if repo_owner and repo_name and base_sha:
|
||||
agents_md_content = await fetch_agents_md(
|
||||
repo_owner,
|
||||
repo_name,
|
||||
base_sha,
|
||||
token=github_token,
|
||||
)
|
||||
if agents_md_content:
|
||||
logger.info(
|
||||
"Loaded AGENTS.md (%d chars) from %s/%s@%s into reviewer prompt",
|
||||
len(agents_md_content),
|
||||
repo_owner,
|
||||
repo_name,
|
||||
base_sha,
|
||||
)
|
||||
del github_token
|
||||
github_api_token = None
|
||||
github_token = None
|
||||
|
||||
system_prompt = _reviewer_system_prompt(
|
||||
f"{work_dir}/{repo_name}" if repo_name else work_dir,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ from .dashboard.agent_overrides import (
|
|||
resolve_github_login,
|
||||
)
|
||||
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
|
||||
from .dashboard.team_settings import get_team_default_model, get_team_default_subagent_model
|
||||
from .dashboard.team_settings import get_team_default_model_pair
|
||||
from .integrations.langsmith import _configure_github_proxy
|
||||
from .middleware import (
|
||||
ModelFallbackMiddleware,
|
||||
|
|
@ -411,13 +411,21 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
).with_config(config)
|
||||
|
||||
github_token, _expires_at = await resolve_github_token(config, thread_id)
|
||||
triggering_user_identity = await asyncio.to_thread(
|
||||
resolve_triggering_user_identity, config, github_token
|
||||
profile_login = resolve_github_login(config)
|
||||
triggering_user_identity_task = asyncio.create_task(
|
||||
asyncio.to_thread(resolve_triggering_user_identity, config, github_token)
|
||||
)
|
||||
sandbox_task = asyncio.create_task(ensure_sandbox_for_thread(thread_id))
|
||||
team_defaults_task = asyncio.create_task(get_team_default_model_pair("agent"))
|
||||
profile_task = asyncio.create_task(load_profile(profile_login)) if profile_login else None
|
||||
triggering_user_identity, sandbox_backend, team_defaults = await asyncio.gather(
|
||||
triggering_user_identity_task,
|
||||
sandbox_task,
|
||||
team_defaults_task,
|
||||
)
|
||||
profile = await profile_task if profile_task is not None else None
|
||||
del github_token
|
||||
|
||||
sandbox_backend = await ensure_sandbox_for_thread(thread_id)
|
||||
|
||||
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", "")
|
||||
|
|
@ -427,44 +435,39 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
def backend_factory(_runtime: object, _thread_id: str = thread_id) -> SandboxBackendProtocol:
|
||||
return _get_cached_sandbox_backend(_thread_id)
|
||||
|
||||
model_id, profile_effort = await get_team_default_model("agent")
|
||||
(model_id, profile_effort), (subagent_model_id, subagent_effort) = team_defaults
|
||||
logger.info("Using team default agent model: model=%s effort=%s", model_id, profile_effort)
|
||||
subagent_model_id, subagent_effort = await get_team_default_subagent_model("agent")
|
||||
logger.info(
|
||||
"Using team default agent subagent model: model=%s effort=%s",
|
||||
subagent_model_id,
|
||||
subagent_effort,
|
||||
)
|
||||
|
||||
profile: dict[str, Any] | None = None
|
||||
profile_login = resolve_github_login(config)
|
||||
if profile_login:
|
||||
profile = await load_profile(profile_login)
|
||||
if profile:
|
||||
overridden_model, overridden_effort = normalize_profile_overrides(profile)
|
||||
if overridden_model:
|
||||
logger.info(
|
||||
"Applying dashboard profile override for %s: model=%s effort=%s",
|
||||
profile_login,
|
||||
overridden_model,
|
||||
overridden_effort,
|
||||
)
|
||||
model_id = overridden_model
|
||||
profile_effort = overridden_effort
|
||||
subagent_model_id = overridden_model
|
||||
subagent_effort = overridden_effort
|
||||
overridden_subagent_model, overridden_subagent_effort = (
|
||||
normalize_profile_subagent_overrides(profile)
|
||||
if profile_login and profile:
|
||||
overridden_model, overridden_effort = normalize_profile_overrides(profile)
|
||||
if overridden_model:
|
||||
logger.info(
|
||||
"Applying dashboard profile override for %s: model=%s effort=%s",
|
||||
profile_login,
|
||||
overridden_model,
|
||||
overridden_effort,
|
||||
)
|
||||
if overridden_subagent_model:
|
||||
logger.info(
|
||||
"Applying dashboard profile subagent override for %s: model=%s effort=%s",
|
||||
profile_login,
|
||||
overridden_subagent_model,
|
||||
overridden_subagent_effort,
|
||||
)
|
||||
subagent_model_id = overridden_subagent_model
|
||||
subagent_effort = overridden_subagent_effort
|
||||
model_id = overridden_model
|
||||
profile_effort = overridden_effort
|
||||
subagent_model_id = overridden_model
|
||||
subagent_effort = overridden_effort
|
||||
overridden_subagent_model, overridden_subagent_effort = (
|
||||
normalize_profile_subagent_overrides(profile)
|
||||
)
|
||||
if overridden_subagent_model:
|
||||
logger.info(
|
||||
"Applying dashboard profile subagent override for %s: model=%s effort=%s",
|
||||
profile_login,
|
||||
overridden_subagent_model,
|
||||
overridden_subagent_effort,
|
||||
)
|
||||
subagent_model_id = overridden_subagent_model
|
||||
subagent_effort = overridden_subagent_effort
|
||||
|
||||
configurable = (config or {}).get("configurable") or {}
|
||||
per_thread_model = configurable.get("agent_model_id")
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
import os
|
||||
from collections.abc import Callable
|
||||
from importlib import import_module
|
||||
|
||||
from deepagents.backends.protocol import SandboxBackendProtocol
|
||||
|
||||
from agent.integrations.daytona import create_daytona_sandbox
|
||||
from agent.integrations.langsmith import create_langsmith_sandbox
|
||||
from agent.integrations.local import create_local_sandbox
|
||||
from agent.integrations.modal import create_modal_sandbox
|
||||
from agent.integrations.runloop import create_runloop_sandbox
|
||||
SandboxFactory = Callable[[str | None], SandboxBackendProtocol]
|
||||
|
||||
SANDBOX_FACTORIES = {
|
||||
"langsmith": create_langsmith_sandbox,
|
||||
"daytona": create_daytona_sandbox,
|
||||
"modal": create_modal_sandbox,
|
||||
"runloop": create_runloop_sandbox,
|
||||
"local": create_local_sandbox,
|
||||
SANDBOX_FACTORIES: dict[str, tuple[str, str]] = {
|
||||
"langsmith": ("agent.integrations.langsmith", "create_langsmith_sandbox"),
|
||||
"daytona": ("agent.integrations.daytona", "create_daytona_sandbox"),
|
||||
"modal": ("agent.integrations.modal", "create_modal_sandbox"),
|
||||
"runloop": ("agent.integrations.runloop", "create_runloop_sandbox"),
|
||||
"local": ("agent.integrations.local", "create_local_sandbox"),
|
||||
}
|
||||
|
||||
|
||||
def _load_sandbox_factory(sandbox_type: str) -> SandboxFactory:
|
||||
factory_path = SANDBOX_FACTORIES.get(sandbox_type)
|
||||
if factory_path is None:
|
||||
supported = ", ".join(sorted(SANDBOX_FACTORIES))
|
||||
raise ValueError(f"Invalid sandbox type: {sandbox_type}. Supported types: {supported}")
|
||||
module_name, function_name = factory_path
|
||||
factory = getattr(import_module(module_name), function_name)
|
||||
if not callable(factory):
|
||||
raise TypeError(f"Sandbox factory {module_name}.{function_name} is not callable")
|
||||
return factory
|
||||
|
||||
|
||||
def create_sandbox(sandbox_id: str | None = None) -> SandboxBackendProtocol:
|
||||
"""Create or reconnect to a sandbox using the configured provider.
|
||||
|
||||
|
|
@ -30,10 +40,7 @@ def create_sandbox(sandbox_id: str | None = None) -> SandboxBackendProtocol:
|
|||
A sandbox backend implementing SandboxBackendProtocol.
|
||||
"""
|
||||
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
|
||||
factory = SANDBOX_FACTORIES.get(sandbox_type)
|
||||
if not factory:
|
||||
supported = ", ".join(sorted(SANDBOX_FACTORIES))
|
||||
raise ValueError(f"Invalid sandbox type: {sandbox_type}. Supported types: {supported}")
|
||||
factory = _load_sandbox_factory(sandbox_type)
|
||||
return factory(sandbox_id)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,6 @@ from .utils.linear import post_linear_trace_comment
|
|||
from .utils.linear_team_repo_map import LINEAR_TEAM_TO_REPO
|
||||
from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block
|
||||
from .utils.repo import extract_repo_from_text
|
||||
from .utils.sandbox import validate_sandbox_startup_config
|
||||
from .utils.slack import (
|
||||
GitHubPrRef,
|
||||
fetch_slack_thread_messages,
|
||||
|
|
@ -110,6 +109,8 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
from .utils.sandbox import validate_sandbox_startup_config
|
||||
|
||||
validate_sandbox_startup_config()
|
||||
yield
|
||||
|
||||
|
|
|
|||
|
|
@ -48,14 +48,9 @@ async def test_agent_uses_profile_subagent_model_override() -> None:
|
|||
return_value="/workspace",
|
||||
),
|
||||
patch(
|
||||
"agent.server.get_team_default_model",
|
||||
"agent.server.get_team_default_model_pair",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("openai:gpt-5.5", "medium"),
|
||||
),
|
||||
patch(
|
||||
"agent.server.get_team_default_subagent_model",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("openai:gpt-5.5", "low"),
|
||||
return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")),
|
||||
),
|
||||
patch(
|
||||
"agent.server.load_profile",
|
||||
|
|
@ -126,14 +121,9 @@ async def test_agent_subagent_inherits_profile_model_override_without_explicit_p
|
|||
return_value="/workspace",
|
||||
),
|
||||
patch(
|
||||
"agent.server.get_team_default_model",
|
||||
"agent.server.get_team_default_model_pair",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("openai:gpt-5.5", "medium"),
|
||||
),
|
||||
patch(
|
||||
"agent.server.get_team_default_subagent_model",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("openai:gpt-5.5", "low"),
|
||||
return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")),
|
||||
),
|
||||
patch(
|
||||
"agent.server.load_profile",
|
||||
|
|
|
|||
92
tests/test_dashboard_repos.py
Normal file
92
tests/test_dashboard_repos.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from agent.dashboard import routes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paginate_converts_github_timeout_to_503() -> None:
|
||||
request = httpx.Request("GET", "https://api.github.com/user/installations")
|
||||
|
||||
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectTimeout("connect timed out", request=request)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await routes._paginate(
|
||||
client,
|
||||
"https://api.github.com/user/installations",
|
||||
headers={},
|
||||
items_key="installations",
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
assert exc.value.detail == "github API request timed out"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paginate_converts_github_status_error_to_502() -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(500, request=request, json={"message": "server error"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await routes._paginate(
|
||||
client,
|
||||
"https://api.github.com/user/installations",
|
||||
headers={},
|
||||
items_key="installations",
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 502
|
||||
assert exc.value.detail == "github API error (500)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_repos_propagates_repository_page_timeouts(monkeypatch) -> None:
|
||||
monkeypatch.setattr(routes, "get_valid_access_token", AsyncMock(return_value="token"))
|
||||
calls = 0
|
||||
|
||||
async def fake_paginate(*args: object, **kwargs: object) -> list[dict[str, object]]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return [{"id": 123, "account": {"login": "acme", "type": "Organization"}}]
|
||||
raise HTTPException(503, "github API request timed out")
|
||||
|
||||
monkeypatch.setattr(routes, "_paginate", fake_paginate)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await routes.list_repos(session={"sub": "octocat"})
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
assert exc.value.detail == "github API request timed out"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_repos_skips_inaccessible_installations(monkeypatch) -> None:
|
||||
monkeypatch.setattr(routes, "get_valid_access_token", AsyncMock(return_value="token"))
|
||||
calls = 0
|
||||
|
||||
async def fake_paginate(*args: object, **kwargs: object) -> list[dict[str, object]]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return [{"id": 123, "account": {"login": "acme", "type": "Organization"}}]
|
||||
raise HTTPException(403, "github API forbidden")
|
||||
|
||||
monkeypatch.setattr(routes, "_paginate", fake_paginate)
|
||||
|
||||
result = await routes.list_repos(session={"sub": "octocat"})
|
||||
|
||||
assert result == {
|
||||
"installations": [{"id": 123, "account": "acme", "account_type": "Organization"}],
|
||||
"repositories": [],
|
||||
}
|
||||
|
|
@ -11,6 +11,25 @@ import pytest
|
|||
from agent.integrations.langsmith import _configure_github_proxy
|
||||
|
||||
|
||||
class TestSandboxFactoryLoading:
|
||||
def test_create_sandbox_loads_only_selected_provider(self) -> None:
|
||||
with (
|
||||
patch("agent.utils.sandbox.import_module") as mock_import_module,
|
||||
patch.dict("os.environ", {"SANDBOX_TYPE": "local"}),
|
||||
):
|
||||
module = MagicMock()
|
||||
module.create_local_sandbox.return_value = MagicMock(id="local")
|
||||
mock_import_module.return_value = module
|
||||
|
||||
from agent.utils.sandbox import create_sandbox
|
||||
|
||||
sandbox = create_sandbox("existing")
|
||||
|
||||
assert sandbox.id == "local"
|
||||
mock_import_module.assert_called_once_with("agent.integrations.local")
|
||||
module.create_local_sandbox.assert_called_once_with("existing")
|
||||
|
||||
|
||||
class TestConfigureGithubProxy:
|
||||
"""Tests for _configure_github_proxy payload shape and error handling."""
|
||||
|
||||
|
|
|
|||
|
|
@ -105,6 +105,55 @@ async def test_reviewer_resolves_app_installation_token_at_run_start() -> None:
|
|||
assert reviewer.check_message_queue_before_model in middleware
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reviewer_reuses_app_token_for_sandbox_proxy() -> None:
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"__is_for_execution__": True,
|
||||
"thread_id": "reviewer-thread-id",
|
||||
"repo": {"owner": "acme", "name": "repo"},
|
||||
"source": "github",
|
||||
"pr_number": 42,
|
||||
"base_sha": "base",
|
||||
},
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"agent.reviewer.get_github_app_installation_token_with_expiry",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("app-token", "exp"),
|
||||
),
|
||||
patch(
|
||||
"agent.reviewer.ensure_sandbox_for_thread",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(),
|
||||
) as mock_sandbox,
|
||||
patch(
|
||||
"agent.reviewer.aresolve_sandbox_work_dir",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/workspace",
|
||||
),
|
||||
patch("agent.reviewer.fetch_pr_diff", new_callable=AsyncMock, return_value=None),
|
||||
patch("agent.reviewer.fetch_pr_metadata", new_callable=AsyncMock, return_value=None),
|
||||
patch("agent.reviewer.fetch_pr_review_threads", new_callable=AsyncMock, return_value=[]),
|
||||
patch(
|
||||
"agent.reviewer.reconcile_findings_with_review_threads",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch("agent.reviewer.fetch_agents_md", new_callable=AsyncMock, return_value=None),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", return_value=_DummyAgent()),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
||||
mock_sandbox.assert_awaited_once_with(
|
||||
"reviewer-thread-id",
|
||||
github_proxy_token="app-token",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reviewer_raises_when_app_installation_token_unavailable() -> None:
|
||||
config: RunnableConfig = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue