mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 13:53:15 +00:00
* feat: activate PR babysitting UI toggles for autofix and trigger mode Remove the "coming soon" gating on the Autofix Mode, Autofix Severity Threshold, and Trigger Mode controls in the review settings page so admins can enable CI auto-fix and review-comment resolution on PRs that Open SWE opens. The backend (ci_autofix.py, webapp.py webhook routing) was already fully wired — only the UI was disabled. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * feat: simplify autofix to on/off toggle, remove severity threshold Replace the four-level AutofixMode (off/low/medium/high) and the autofix_severity_threshold setting with a single boolean autofix_enabled toggle. The severity threshold was leftover from the reviewer finding-severity model and does not apply to CI autofix; the agent should fix any failing CI and resolve any comments on PRs it opens. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * feat: move autofix toggle to per-user profile, remove team-level setting The autofix toggle is now per-user (auto_fix_ci in the user profile) instead of team-level (admin-only). This uses the existing auto_fix_ci field that was already in ProfileUpdate but never wired up. Changes: - ci_autofix.py: check per-user auto_fix_ci profile flag after resolving the agent thread's github_login, instead of checking team-level autofix_enabled before knowing the PR - webapp.py: removed early is_autofix_enabled() webhook gates; the per-user check now happens in ci_autofix.py once the thread is found - team_settings.py: removed autofix_enabled field, is_autofix_enabled() - cloud-agents.tsx: enabled the auto_fix_ci toggle (was comingSoon) - review.tsx: removed the admin-level autofix switch - Updated tests and AGENTS.md The agent graph (not the reviewer) is what gets dispatched - this was already correct in ci_autofix.py line 223: client.runs.create( thread_id, "agent", ...). Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * feat: batch PR babysitting events Remove the leftover trigger-mode gate from PR babysitting and batch new CI/review events while an agent run is already active so the running agent can handle the latest PR state before finishing. Also moves review-feedback permission checks behind the per-user opt-out and applies the auto-fix profile gate to merge-conflict babysitting. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: consume batched babysitting events Teach the agent queue middleware to turn pending PR babysitting metadata into an injected instruction for the active run, so batched CI/review events are not dropped while still avoiding duplicate run creation. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: address review findings in PR babysitting batching - Route batched events through the LangGraph store (read in-process by the message-queue middleware) instead of a per-model-call threads.get on every agent thread. - Only record an attempt / mark the head SHA handled on a real dispatch, not on a batch, so an event isn't permanently dropped if the in-flight run ends before consuming it. - Carry the reviewer's comment through batched review feedback instead of replacing it with a generic re-check nudge. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
341 lines
13 KiB
Python
341 lines
13 KiB
Python
"""Team-wide Open SWE Review (Bugbot) settings stored in LangGraph Store.
|
|
|
|
A single record keyed ``"default"`` keeps all instance-wide reviewer
|
|
configuration in one place. Per-repo style prompts live in
|
|
:mod:`agent.dashboard.review_styles`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from datetime import UTC, datetime
|
|
from typing import Any, Literal
|
|
|
|
from langgraph_sdk import get_client
|
|
from pydantic import BaseModel, field_validator, model_validator
|
|
|
|
from .options import (
|
|
SUPPORTED_MODEL_IDS,
|
|
default_model_pair,
|
|
model_supports_effort,
|
|
provider_fallback_pair,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TEAM_SETTINGS_NAMESPACE: list[str] = ["team_settings"]
|
|
TEAM_SETTINGS_KEY = "default"
|
|
|
|
# Cap the org-wide guidelines so a runaway value can't dominate the reviewer
|
|
# prompt. Generous enough for a detailed policy, small enough to stay bounded.
|
|
ORG_GUIDELINES_MAX_CHARS = 10_000
|
|
|
|
|
|
class TeamSettingsUpdate(BaseModel):
|
|
review_draft_prs: bool = False
|
|
pr_summaries: bool = True
|
|
review_trace_links: bool = True
|
|
org_guidelines: str | None = None
|
|
default_agent_model: str | None = None
|
|
default_agent_reasoning_effort: str | None = None
|
|
default_agent_subagent_model: str | None = None
|
|
default_agent_subagent_reasoning_effort: str | None = None
|
|
default_repo: str | None = None
|
|
default_reviewer_model: str | None = None
|
|
default_reviewer_reasoning_effort: str | None = None
|
|
default_reviewer_subagent_model: str | None = None
|
|
default_reviewer_subagent_reasoning_effort: str | None = None
|
|
default_grouping_model: str | None = None
|
|
default_grouping_reasoning_effort: str | None = None
|
|
default_chat_model: str | None = None
|
|
default_chat_reasoning_effort: str | None = None
|
|
|
|
@field_validator("org_guidelines", mode="before")
|
|
@classmethod
|
|
def _normalize_org_guidelines(cls, v: object) -> str | None:
|
|
if v is None:
|
|
return None
|
|
if not isinstance(v, str):
|
|
raise ValueError("org_guidelines must be a string")
|
|
text = v.strip()
|
|
if not text:
|
|
return None
|
|
if len(text) > ORG_GUIDELINES_MAX_CHARS:
|
|
raise ValueError(
|
|
f"org_guidelines must be at most {ORG_GUIDELINES_MAX_CHARS} characters"
|
|
)
|
|
return text
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_model_pairs(self) -> TeamSettingsUpdate:
|
|
_validate_model_effort_pair(
|
|
self.default_agent_model, self.default_agent_reasoning_effort, "agent"
|
|
)
|
|
_validate_model_effort_pair(
|
|
self.default_agent_subagent_model,
|
|
self.default_agent_subagent_reasoning_effort,
|
|
"agent subagent",
|
|
)
|
|
_validate_model_effort_pair(
|
|
self.default_reviewer_model, self.default_reviewer_reasoning_effort, "reviewer"
|
|
)
|
|
_validate_model_effort_pair(
|
|
self.default_reviewer_subagent_model,
|
|
self.default_reviewer_subagent_reasoning_effort,
|
|
"reviewer subagent",
|
|
)
|
|
_validate_model_effort_pair(
|
|
self.default_grouping_model,
|
|
self.default_grouping_reasoning_effort,
|
|
"review diff grouping",
|
|
)
|
|
_validate_model_effort_pair(
|
|
self.default_chat_model, self.default_chat_reasoning_effort, "review chat"
|
|
)
|
|
return self
|
|
|
|
|
|
def _validate_model_effort_pair(model: str | None, effort: str | None, role: str) -> None:
|
|
if model is None and effort is None:
|
|
return
|
|
if model is None:
|
|
raise ValueError(f"{role} reasoning effort set without a model")
|
|
if model not in SUPPORTED_MODEL_IDS:
|
|
raise ValueError(f"unsupported {role} model: {model}")
|
|
if effort is None or not model_supports_effort(model, effort):
|
|
raise ValueError(f"effort {effort!r} not supported by {role} model {model!r}")
|
|
|
|
|
|
def _client():
|
|
return get_client()
|
|
|
|
|
|
def _env_default_repo() -> str | None:
|
|
owner = os.environ.get("DEFAULT_REPO_OWNER", "").strip()
|
|
name = os.environ.get("DEFAULT_REPO_NAME", "").strip()
|
|
return f"{owner}/{name}" if owner and name else None
|
|
|
|
|
|
def _parse_repo(value: object) -> dict[str, str] | None:
|
|
if not isinstance(value, str):
|
|
return None
|
|
owner, sep, name = value.strip().partition("/")
|
|
if not sep or not owner.strip() or not name.strip():
|
|
return None
|
|
return {"owner": owner.strip(), "name": name.strip()}
|
|
|
|
|
|
def _default_settings() -> dict[str, Any]:
|
|
fallback_model, fallback_effort = default_model_pair()
|
|
return {
|
|
"review_draft_prs": False,
|
|
"pr_summaries": True,
|
|
"review_trace_links": True,
|
|
"org_guidelines": None,
|
|
"default_agent_model": fallback_model,
|
|
"default_agent_reasoning_effort": fallback_effort,
|
|
"default_agent_subagent_model": fallback_model,
|
|
"default_agent_subagent_reasoning_effort": fallback_effort,
|
|
"default_repo": _env_default_repo(),
|
|
"default_reviewer_model": fallback_model,
|
|
"default_reviewer_reasoning_effort": fallback_effort,
|
|
"default_reviewer_subagent_model": fallback_model,
|
|
"default_reviewer_subagent_reasoning_effort": fallback_effort,
|
|
# No hardcoded grouping default: unset means "inherit the Reviewer
|
|
# subagent default".
|
|
"default_grouping_model": None,
|
|
"default_grouping_reasoning_effort": None,
|
|
# No hardcoded chat default: unset means "inherit the Agent default".
|
|
"default_chat_model": None,
|
|
"default_chat_reasoning_effort": None,
|
|
"updated_at": None,
|
|
}
|
|
|
|
|
|
async def get_team_settings() -> dict[str, Any]:
|
|
defaults = _default_settings()
|
|
try:
|
|
item = await _client().store.get_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY)
|
|
except Exception as e:
|
|
logger.debug("team settings lookup failed: %s", e)
|
|
return defaults
|
|
if item is None:
|
|
return defaults
|
|
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
|
|
if not isinstance(value, dict):
|
|
return defaults
|
|
# Skip None-valued model fields so legacy records (or PUTs that cleared the
|
|
# selection) still surface the hardcoded default instead of a null.
|
|
overlay = {k: v for k, v in value.items() if v is not None}
|
|
merged = {**defaults, **overlay}
|
|
for stale_field in (
|
|
"trigger_mode",
|
|
"autofix_mode",
|
|
"autofix_severity_threshold",
|
|
"autofix_enabled",
|
|
):
|
|
merged.pop(stale_field, None)
|
|
return merged
|
|
|
|
|
|
async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]:
|
|
value: dict[str, Any] = {
|
|
"review_draft_prs": update.review_draft_prs,
|
|
"pr_summaries": update.pr_summaries,
|
|
"review_trace_links": update.review_trace_links,
|
|
"org_guidelines": update.org_guidelines,
|
|
"default_agent_model": update.default_agent_model,
|
|
"default_agent_reasoning_effort": update.default_agent_reasoning_effort,
|
|
"default_agent_subagent_model": update.default_agent_subagent_model,
|
|
"default_agent_subagent_reasoning_effort": update.default_agent_subagent_reasoning_effort,
|
|
"default_repo": update.default_repo,
|
|
"default_reviewer_model": update.default_reviewer_model,
|
|
"default_reviewer_reasoning_effort": update.default_reviewer_reasoning_effort,
|
|
"default_reviewer_subagent_model": update.default_reviewer_subagent_model,
|
|
"default_reviewer_subagent_reasoning_effort": update.default_reviewer_subagent_reasoning_effort,
|
|
"default_grouping_model": update.default_grouping_model,
|
|
"default_grouping_reasoning_effort": update.default_grouping_reasoning_effort,
|
|
"default_chat_model": update.default_chat_model,
|
|
"default_chat_reasoning_effort": update.default_chat_reasoning_effort,
|
|
"updated_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
await _client().store.put_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY, value)
|
|
return value
|
|
|
|
|
|
async def get_team_default_repo() -> dict[str, str] | None:
|
|
settings = await get_team_settings()
|
|
return _parse_repo(settings.get("default_repo"))
|
|
|
|
|
|
async def get_team_default_model(
|
|
role: Literal["agent", "reviewer", "chat"],
|
|
) -> tuple[str, str]:
|
|
"""Return the team-wide default ``(model_id, reasoning_effort)`` for ``role``.
|
|
|
|
Always returns a valid pair, resolved in order: the admin-configured pair if
|
|
still supported; otherwise the newest supported model for the same provider
|
|
(so a stale Anthropic/OpenAI selection stays on its provider rather than
|
|
jumping cross-provider); otherwise the hardcoded global default from
|
|
:func:`agent.dashboard.options.default_model_pair`.
|
|
|
|
``"chat"`` (the review-page PR chat) has no hardcoded default: when its
|
|
admin setting is unset/invalid it inherits the team **agent** default.
|
|
"""
|
|
settings = await get_team_settings()
|
|
if role == "chat":
|
|
model = settings.get("default_chat_model")
|
|
effort = settings.get("default_chat_reasoning_effort")
|
|
if (
|
|
isinstance(model, str)
|
|
and isinstance(effort, str)
|
|
and model in SUPPORTED_MODEL_IDS
|
|
and model_supports_effort(model, effort)
|
|
):
|
|
return _resolve_default_pair(model, effort)
|
|
# Inherit the Agent default when no chat-specific model is configured.
|
|
model = settings.get("default_agent_model")
|
|
effort = settings.get("default_agent_reasoning_effort")
|
|
elif role == "agent":
|
|
model = settings.get("default_agent_model")
|
|
effort = settings.get("default_agent_reasoning_effort")
|
|
else:
|
|
model = settings.get("default_reviewer_model")
|
|
effort = settings.get("default_reviewer_reasoning_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_default_grouping_model() -> tuple[str, str]:
|
|
"""Return the team-wide default ``(model_id, reasoning_effort)`` for the
|
|
review diff-grouping pass.
|
|
|
|
When no grouping-specific model is configured (or it's no longer
|
|
supported), inherit the team **reviewer subagent** default — the grouping
|
|
pass is a cheap, fast companion to the reviewer, so it should track that
|
|
cheaper tier rather than the primary reviewer model.
|
|
"""
|
|
settings = await get_team_settings()
|
|
model = settings.get("default_grouping_model")
|
|
effort = settings.get("default_grouping_reasoning_effort")
|
|
if (
|
|
isinstance(model, str)
|
|
and isinstance(effort, str)
|
|
and model in SUPPORTED_MODEL_IDS
|
|
and model_supports_effort(model, effort)
|
|
):
|
|
return _resolve_default_pair(model, effort)
|
|
return _resolve_default_pair(
|
|
settings.get("default_reviewer_subagent_model"),
|
|
settings.get("default_reviewer_subagent_reasoning_effort"),
|
|
)
|
|
|
|
|
|
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()
|
|
return bool(settings.get("review_trace_links", True))
|
|
|
|
|
|
async def get_org_review_guidelines() -> str | None:
|
|
"""Return the org-wide reviewer guidelines supplement, if configured."""
|
|
settings = await get_team_settings()
|
|
value = settings.get("org_guidelines")
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return None
|
|
|
|
|
|
async def get_team_default_subagent_model(
|
|
role: Literal["agent", "reviewer"],
|
|
) -> tuple[str, str]:
|
|
"""Return the team-wide default subagent ``(model_id, reasoning_effort)`` for ``role``."""
|
|
settings = await get_team_settings()
|
|
if role == "agent":
|
|
model = settings.get("default_agent_subagent_model")
|
|
effort = settings.get("default_agent_subagent_reasoning_effort")
|
|
else:
|
|
model = settings.get("default_reviewer_subagent_model")
|
|
effort = settings.get("default_reviewer_subagent_reasoning_effort")
|
|
return _resolve_default_pair(model, effort)
|
|
|
|
|
|
def _resolve_default_pair(model: object, effort: object) -> tuple[str, str]:
|
|
"""Supported pair if valid, else same-provider fallback, else global default."""
|
|
if (
|
|
isinstance(model, str)
|
|
and isinstance(effort, str)
|
|
and model in SUPPORTED_MODEL_IDS
|
|
and model_supports_effort(model, effort)
|
|
):
|
|
return model, effort
|
|
provider_pair = provider_fallback_pair(model, effort)
|
|
if provider_pair is not None:
|
|
return provider_pair
|
|
return default_model_pair()
|