mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 04:23:20 +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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard/api", tags=["dashboard"])
|
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]:
|
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
|
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(
|
async def _paginate(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
url: str,
|
url: str,
|
||||||
|
|
@ -466,10 +478,23 @@ async def _paginate(
|
||||||
first = True
|
first = True
|
||||||
while next_url and len(out) < cap:
|
while next_url and len(out) < cap:
|
||||||
params = {"per_page": "100"} if first else None
|
params = {"per_page": "100"} if first else None
|
||||||
r = await client.get(next_url, headers=headers, params=params)
|
try:
|
||||||
if r.status_code == 401:
|
r = await client.get(next_url, headers=headers, params=params)
|
||||||
raise HTTPException(401, "github token expired, re-login required")
|
except httpx.TimeoutException as exc:
|
||||||
r.raise_for_status()
|
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()
|
body = r.json()
|
||||||
page = body.get(items_key, []) if items_key else body
|
page = body.get(items_key, []) if items_key else body
|
||||||
if isinstance(page, list):
|
if isinstance(page, list):
|
||||||
|
|
@ -498,7 +523,7 @@ async def list_repos(
|
||||||
"Accept": "application/vnd.github+json",
|
"Accept": "application/vnd.github+json",
|
||||||
"X-GitHub-Api-Version": "2022-11-28",
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
}
|
}
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient(timeout=_GITHUB_API_TIMEOUT) as client:
|
||||||
try:
|
try:
|
||||||
installations = await _paginate(
|
installations = await _paginate(
|
||||||
client,
|
client,
|
||||||
|
|
@ -531,10 +556,13 @@ async def list_repos(
|
||||||
headers=headers,
|
headers=headers,
|
||||||
items_key="repositories",
|
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
|
raise
|
||||||
except httpx.HTTPStatusError:
|
|
||||||
continue
|
|
||||||
repositories.extend(repos)
|
repositories.extend(repos)
|
||||||
return {
|
return {
|
||||||
"installations": [
|
"installations": [
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,32 @@ async def get_team_default_model(
|
||||||
return _resolve_default_pair(model, effort)
|
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:
|
async def get_team_review_trace_links_enabled() -> bool:
|
||||||
"""Return whether GitHub review bodies should include a LangSmith trace link."""
|
"""Return whether GitHub review bodies should include a LangSmith trace link."""
|
||||||
settings = await get_team_settings()
|
settings = await get_team_settings()
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ agent for code review only:
|
||||||
"""
|
"""
|
||||||
# ruff: noqa: E402
|
# ruff: noqa: E402
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import warnings
|
import warnings
|
||||||
|
|
@ -30,6 +31,7 @@ warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarnin
|
||||||
from deepagents import create_deep_agent
|
from deepagents import create_deep_agent
|
||||||
from langchain.agents.middleware import ModelCallLimitMiddleware
|
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||||||
|
|
||||||
|
from .dashboard.team_settings import get_team_default_model_pair
|
||||||
from .middleware import (
|
from .middleware import (
|
||||||
SanitizeThinkingBlocksMiddleware,
|
SanitizeThinkingBlocksMiddleware,
|
||||||
SanitizeToolInputsMiddleware,
|
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 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)
|
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
|
||||||
github_proxy_token = github_token if repo_private is False else None
|
github_api_token = github_token
|
||||||
sandbox_backend = await ensure_sandbox_for_thread(
|
sandbox_backend = await ensure_sandbox_for_thread(
|
||||||
thread_id,
|
thread_id,
|
||||||
github_proxy_token=github_proxy_token,
|
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"))
|
is_re_review = bool(config["configurable"].get("re_review"))
|
||||||
reviewer_event = str(config["configurable"].get("reviewer_event", "") or "")
|
reviewer_event = str(config["configurable"].get("reviewer_event", "") or "")
|
||||||
|
|
||||||
# Fetch the PR's unified diff from the GitHub API and populate
|
can_fetch_pr = (
|
||||||
# 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 (
|
|
||||||
pr_number is not None
|
pr_number is not None
|
||||||
and isinstance(pr_number, int)
|
and isinstance(pr_number, int)
|
||||||
and repo_owner
|
and bool(repo_owner)
|
||||||
and repo_name
|
and bool(repo_name)
|
||||||
and github_token
|
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(
|
fetched_diff = await fetch_pr_diff(
|
||||||
owner=repo_owner,
|
owner=repo_owner,
|
||||||
repo=repo_name,
|
repo=repo_name,
|
||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
token=github_token,
|
token=github_api_token,
|
||||||
)
|
)
|
||||||
if fetched_diff is not None:
|
if fetched_diff is None:
|
||||||
pr_diff_text = fetched_diff
|
return "", None
|
||||||
pr_diff_line_set = compute_diff_line_set(fetched_diff)
|
return fetched_diff, compute_diff_line_set(fetched_diff)
|
||||||
config["configurable"]["diff_text"] = pr_diff_text
|
|
||||||
config["configurable"]["diff_line_set"] = pr_diff_line_set
|
|
||||||
|
|
||||||
# Fetch the PR title and body fresh every run (never cached) so an edited
|
async def _fetch_pr_overview() -> tuple[str, str]:
|
||||||
# title/description is reflected on re-reviews. Injected into the review
|
if not can_fetch_pr or github_api_token is None or not isinstance(pr_number, int):
|
||||||
# context so the agent knows the original intent of the PR. On failure we
|
return "", ""
|
||||||
# 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
|
|
||||||
):
|
|
||||||
metadata = await fetch_pr_metadata(
|
metadata = await fetch_pr_metadata(
|
||||||
owner=repo_owner,
|
owner=repo_owner,
|
||||||
repo=repo_name,
|
repo=repo_name,
|
||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
token=github_token,
|
token=github_api_token,
|
||||||
)
|
)
|
||||||
if metadata is not None:
|
return metadata if metadata is not None else ("", "")
|
||||||
pr_title, pr_body = metadata
|
|
||||||
|
|
||||||
existing_threads_block = ""
|
async def _fetch_existing_threads_block() -> str:
|
||||||
if (
|
if not can_fetch_pr or github_api_token is None or not isinstance(pr_number, int):
|
||||||
pr_number is not None
|
return ""
|
||||||
and isinstance(pr_number, int)
|
|
||||||
and repo_owner
|
|
||||||
and repo_name
|
|
||||||
and github_token
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
threads = await fetch_pr_review_threads(
|
threads = await fetch_pr_review_threads(
|
||||||
owner=repo_owner,
|
owner=repo_owner,
|
||||||
repo=repo_name,
|
repo=repo_name,
|
||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
token=github_token,
|
token=github_api_token,
|
||||||
)
|
)
|
||||||
await reconcile_findings_with_review_threads(thread_id, threads)
|
await reconcile_findings_with_review_threads(thread_id, threads)
|
||||||
existing_threads_block = _format_pr_review_threads(threads)
|
block = _format_pr_review_threads(threads)
|
||||||
if existing_threads_block:
|
if block:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Loaded %d existing PR review thread(s) into reviewer context for %s/%s#%s",
|
"Loaded %d existing PR review thread(s) into reviewer context for %s/%s#%s",
|
||||||
len(threads),
|
len(threads),
|
||||||
|
|
@ -734,6 +713,7 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
||||||
repo_name,
|
repo_name,
|
||||||
pr_number,
|
pr_number,
|
||||||
)
|
)
|
||||||
|
return block
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Failed to load existing PR review threads for %s/%s#%s; "
|
"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,
|
repo_name,
|
||||||
pr_number,
|
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 = ""
|
review_context = ""
|
||||||
if pr_number is not None and isinstance(pr_number, int):
|
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,
|
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_model_id = config["configurable"].get("reviewer_model_id")
|
||||||
configured_effort = config["configurable"].get("reviewer_reasoning_effort")
|
configured_effort = config["configurable"].get("reviewer_reasoning_effort")
|
||||||
if isinstance(configured_model_id, str) and configured_model_id:
|
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_model_id = model_id
|
||||||
subagent_effort = reasoning_effort
|
subagent_effort = reasoning_effort
|
||||||
else:
|
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(
|
logger.info(
|
||||||
"Using team default reviewer model: model=%s effort=%s",
|
"Using team default reviewer model: model=%s effort=%s",
|
||||||
model_id,
|
model_id,
|
||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
)
|
)
|
||||||
subagent_model_id, subagent_effort = await get_team_default_subagent_model("reviewer")
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Using team default reviewer subagent model: model=%s effort=%s",
|
"Using team default reviewer subagent model: model=%s effort=%s",
|
||||||
subagent_model_id,
|
subagent_model_id,
|
||||||
|
|
@ -833,34 +858,8 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
||||||
config["configurable"].get("reviewer_eval") is True
|
config["configurable"].get("reviewer_eval") is True
|
||||||
or config["configurable"].get("eval") is True
|
or config["configurable"].get("eval") is True
|
||||||
)
|
)
|
||||||
repo_style_prompt: str | None = None
|
github_api_token = None
|
||||||
if repo_owner and repo_name:
|
github_token = None
|
||||||
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
|
|
||||||
|
|
||||||
system_prompt = _reviewer_system_prompt(
|
system_prompt = _reviewer_system_prompt(
|
||||||
f"{work_dir}/{repo_name}" if repo_name else work_dir,
|
f"{work_dir}/{repo_name}" if repo_name else work_dir,
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ from .dashboard.agent_overrides import (
|
||||||
resolve_github_login,
|
resolve_github_login,
|
||||||
)
|
)
|
||||||
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
|
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 .integrations.langsmith import _configure_github_proxy
|
||||||
from .middleware import (
|
from .middleware import (
|
||||||
ModelFallbackMiddleware,
|
ModelFallbackMiddleware,
|
||||||
|
|
@ -411,13 +411,21 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
||||||
).with_config(config)
|
).with_config(config)
|
||||||
|
|
||||||
github_token, _expires_at = await resolve_github_token(config, thread_id)
|
github_token, _expires_at = await resolve_github_token(config, thread_id)
|
||||||
triggering_user_identity = await asyncio.to_thread(
|
profile_login = resolve_github_login(config)
|
||||||
resolve_triggering_user_identity, config, github_token
|
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
|
del github_token
|
||||||
|
|
||||||
sandbox_backend = await ensure_sandbox_for_thread(thread_id)
|
|
||||||
|
|
||||||
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", "")
|
||||||
|
|
@ -427,44 +435,39 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
||||||
def backend_factory(_runtime: object, _thread_id: str = thread_id) -> SandboxBackendProtocol:
|
def backend_factory(_runtime: object, _thread_id: str = thread_id) -> SandboxBackendProtocol:
|
||||||
return _get_cached_sandbox_backend(_thread_id)
|
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)
|
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(
|
logger.info(
|
||||||
"Using team default agent subagent model: model=%s effort=%s",
|
"Using team default agent subagent model: model=%s effort=%s",
|
||||||
subagent_model_id,
|
subagent_model_id,
|
||||||
subagent_effort,
|
subagent_effort,
|
||||||
)
|
)
|
||||||
|
|
||||||
profile: dict[str, Any] | None = None
|
if profile_login and profile:
|
||||||
profile_login = resolve_github_login(config)
|
overridden_model, overridden_effort = normalize_profile_overrides(profile)
|
||||||
if profile_login:
|
if overridden_model:
|
||||||
profile = await load_profile(profile_login)
|
logger.info(
|
||||||
if profile:
|
"Applying dashboard profile override for %s: model=%s effort=%s",
|
||||||
overridden_model, overridden_effort = normalize_profile_overrides(profile)
|
profile_login,
|
||||||
if overridden_model:
|
overridden_model,
|
||||||
logger.info(
|
overridden_effort,
|
||||||
"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 overridden_subagent_model:
|
model_id = overridden_model
|
||||||
logger.info(
|
profile_effort = overridden_effort
|
||||||
"Applying dashboard profile subagent override for %s: model=%s effort=%s",
|
subagent_model_id = overridden_model
|
||||||
profile_login,
|
subagent_effort = overridden_effort
|
||||||
overridden_subagent_model,
|
overridden_subagent_model, overridden_subagent_effort = (
|
||||||
overridden_subagent_effort,
|
normalize_profile_subagent_overrides(profile)
|
||||||
)
|
)
|
||||||
subagent_model_id = overridden_subagent_model
|
if overridden_subagent_model:
|
||||||
subagent_effort = overridden_subagent_effort
|
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 {}
|
configurable = (config or {}).get("configurable") or {}
|
||||||
per_thread_model = configurable.get("agent_model_id")
|
per_thread_model = configurable.get("agent_model_id")
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,32 @@
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import Callable
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
from deepagents.backends.protocol import SandboxBackendProtocol
|
from deepagents.backends.protocol import SandboxBackendProtocol
|
||||||
|
|
||||||
from agent.integrations.daytona import create_daytona_sandbox
|
SandboxFactory = Callable[[str | None], SandboxBackendProtocol]
|
||||||
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
|
|
||||||
|
|
||||||
SANDBOX_FACTORIES = {
|
SANDBOX_FACTORIES: dict[str, tuple[str, str]] = {
|
||||||
"langsmith": create_langsmith_sandbox,
|
"langsmith": ("agent.integrations.langsmith", "create_langsmith_sandbox"),
|
||||||
"daytona": create_daytona_sandbox,
|
"daytona": ("agent.integrations.daytona", "create_daytona_sandbox"),
|
||||||
"modal": create_modal_sandbox,
|
"modal": ("agent.integrations.modal", "create_modal_sandbox"),
|
||||||
"runloop": create_runloop_sandbox,
|
"runloop": ("agent.integrations.runloop", "create_runloop_sandbox"),
|
||||||
"local": create_local_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:
|
def create_sandbox(sandbox_id: str | None = None) -> SandboxBackendProtocol:
|
||||||
"""Create or reconnect to a sandbox using the configured provider.
|
"""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.
|
A sandbox backend implementing SandboxBackendProtocol.
|
||||||
"""
|
"""
|
||||||
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
|
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
|
||||||
factory = SANDBOX_FACTORIES.get(sandbox_type)
|
factory = _load_sandbox_factory(sandbox_type)
|
||||||
if not factory:
|
|
||||||
supported = ", ".join(sorted(SANDBOX_FACTORIES))
|
|
||||||
raise ValueError(f"Invalid sandbox type: {sandbox_type}. Supported types: {supported}")
|
|
||||||
return factory(sandbox_id)
|
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.linear_team_repo_map import LINEAR_TEAM_TO_REPO
|
||||||
from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block
|
from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block
|
||||||
from .utils.repo import extract_repo_from_text
|
from .utils.repo import extract_repo_from_text
|
||||||
from .utils.sandbox import validate_sandbox_startup_config
|
|
||||||
from .utils.slack import (
|
from .utils.slack import (
|
||||||
GitHubPrRef,
|
GitHubPrRef,
|
||||||
fetch_slack_thread_messages,
|
fetch_slack_thread_messages,
|
||||||
|
|
@ -110,6 +109,8 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
from .utils.sandbox import validate_sandbox_startup_config
|
||||||
|
|
||||||
validate_sandbox_startup_config()
|
validate_sandbox_startup_config()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,14 +48,9 @@ async def test_agent_uses_profile_subagent_model_override() -> None:
|
||||||
return_value="/workspace",
|
return_value="/workspace",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.server.get_team_default_model",
|
"agent.server.get_team_default_model_pair",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=("openai:gpt-5.5", "medium"),
|
return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")),
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"agent.server.get_team_default_subagent_model",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=("openai:gpt-5.5", "low"),
|
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.server.load_profile",
|
"agent.server.load_profile",
|
||||||
|
|
@ -126,14 +121,9 @@ async def test_agent_subagent_inherits_profile_model_override_without_explicit_p
|
||||||
return_value="/workspace",
|
return_value="/workspace",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.server.get_team_default_model",
|
"agent.server.get_team_default_model_pair",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=("openai:gpt-5.5", "medium"),
|
return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")),
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"agent.server.get_team_default_subagent_model",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=("openai:gpt-5.5", "low"),
|
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"agent.server.load_profile",
|
"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
|
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:
|
class TestConfigureGithubProxy:
|
||||||
"""Tests for _configure_github_proxy payload shape and error handling."""
|
"""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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_reviewer_raises_when_app_installation_token_unavailable() -> None:
|
async def test_reviewer_raises_when_app_installation_token_unavailable() -> None:
|
||||||
config: RunnableConfig = {
|
config: RunnableConfig = {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue