mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 20:53:15 +00:00
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Typecheck (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run
CI / Triage ledger up to date (push) Waiting to run
CI / ui bun.lock in sync (push) Waiting to run
* feat(models): re-add Fable 5 with admin disable toggle (port of upstream #1677) * refactor(models): convert re-added Fable 5 to Bedrock model IDs * fix(open-swe): correct Fable copy to describe provider data sharing, not ZDR The ported admin toggle description and code comments described Fable 5 as incompatible with Zero Data Retention. That is backwards: Fable 5 requires the account to opt into Bedrock provider_data_share — prompts/completions are retained and shared with Anthropic (up to 30 days, incl. human review). The old UI copy would lead an admin to believe the opposite of what enabling the toggle does. Reword the toggle description and the gate_fable_model / team_settings comments accordingly. Still off by default. Refs #171.
437 lines
19 KiB
Python
437 lines
19 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 ..utils.gateway import resolve_gateway_enabled
|
|
from .options import (
|
|
FABLE_MODEL_IDS,
|
|
SUPPORTED_MODEL_IDS,
|
|
default_model_pair,
|
|
gate_fable_model,
|
|
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
|
|
REVIEW_TRACING_PROJECT_MAX_CHARS = 256
|
|
# Sea Haven review baseline seeded as the org-wide guidelines default. Surfaces
|
|
# in the reviewer prompt for every repo until an admin overrides it with a
|
|
# non-empty value via the dashboard (PUT /team-settings). Keep it stack-agnostic
|
|
# and well under ORG_GUIDELINES_MAX_CHARS.
|
|
DEFAULT_ORG_REVIEW_GUIDELINES = """\
|
|
Sea Haven review baseline (applies to every repo unless a repo-specific guideline overrides it):
|
|
|
|
- Severity: map findings to critical / high / medium / low. Reserve critical and high for correctness bugs, security issues, data loss, or broken contracts — not style.
|
|
- Secrets & config: flag any hardcoded secret, credential, or real `.env`/config value committed to source, and any sensitive value placed outside the platform's secrets manager.
|
|
- Security surface (raise as high): changes to authentication/authorization or access checks; IAM/policy/permission or infrastructure-access changes; changes to the exported signature or contract of a public handler/endpoint; and untrusted-input handling (request parsing, deserialization, file uploads, SSRF-prone fetches, and template/SQL/command construction — flag unescaped interpolation of dynamic or user-controlled data).
|
|
- Tests: flag new behavior that ships without a corresponding test, and "fixes" that only silence a check (added excludes, `noqa` / `# type: ignore`, skipped or `xfail`ed tests).
|
|
- Naming & conventions: flag resources or code that break the repo's established naming and layout conventions.
|
|
- Deferred work: a finding the author chooses to defer must be captured in a tracked issue, not dropped silently.
|
|
|
|
Only file a finding that anchors to a changed line and names a concrete failure mode. Do not police pre-existing issues outside the diff or raise pure style nits."""
|
|
|
|
|
|
class TeamSettingsUpdate(BaseModel):
|
|
review_draft_prs: bool = False
|
|
pr_summaries: bool = True
|
|
review_trace_links: bool = True
|
|
# Tri-state LLM Gateway toggle: True/False is authoritative, None inherits the
|
|
# LANGSMITH_GATEWAY_ENABLED deployment default.
|
|
gateway_enabled: bool | None = None
|
|
fable_enabled: bool = False
|
|
review_tracing_project: str | None = None
|
|
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
|
|
|
|
@field_validator("review_tracing_project", mode="before")
|
|
@classmethod
|
|
def _normalize_review_tracing_project(cls, v: object) -> str | None:
|
|
if v is None:
|
|
return None
|
|
if not isinstance(v, str):
|
|
raise ValueError("review_tracing_project must be a string")
|
|
text = v.strip()
|
|
if not text:
|
|
return None
|
|
if len(text) > REVIEW_TRACING_PROJECT_MAX_CHARS:
|
|
raise ValueError(
|
|
"review_tracing_project must be at most "
|
|
f"{REVIEW_TRACING_PROJECT_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"
|
|
)
|
|
if not self.fable_enabled:
|
|
# Disabling Fable is the provider-data-share kill switch and must always succeed: rather
|
|
# than reject a payload that still carries a Fable default, swap each
|
|
# Fable default to its safe non-Fable fallback (mirrors the runtime
|
|
# gate_fable_model guard) so the stored record can't advertise Fable.
|
|
for model_field, effort_field in (
|
|
("default_agent_model", "default_agent_reasoning_effort"),
|
|
("default_agent_subagent_model", "default_agent_subagent_reasoning_effort"),
|
|
("default_reviewer_model", "default_reviewer_reasoning_effort"),
|
|
("default_reviewer_subagent_model", "default_reviewer_subagent_reasoning_effort"),
|
|
("default_grouping_model", "default_grouping_reasoning_effort"),
|
|
("default_chat_model", "default_chat_reasoning_effort"),
|
|
):
|
|
model = getattr(self, model_field)
|
|
if model in FABLE_MODEL_IDS:
|
|
new_model, new_effort = gate_fable_model(
|
|
model, getattr(self, effort_field), fable_enabled=False
|
|
)
|
|
setattr(self, model_field, new_model)
|
|
setattr(self, effort_field, new_effort)
|
|
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,
|
|
"gateway_enabled": None,
|
|
"fable_enabled": False,
|
|
"review_tracing_project": None,
|
|
"org_guidelines": DEFAULT_ORG_REVIEW_GUIDELINES,
|
|
"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",
|
|
"review_author_context_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,
|
|
"gateway_enabled": update.gateway_enabled,
|
|
"fable_enabled": update.fable_enabled,
|
|
"review_tracing_project": update.review_tracing_project,
|
|
"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_team_gateway_enabled() -> bool | None:
|
|
"""Return the stored LLM Gateway toggle (``None`` means inherit the env default)."""
|
|
settings = await get_team_settings()
|
|
value = settings.get("gateway_enabled")
|
|
return value if isinstance(value, bool) else None
|
|
|
|
|
|
async def get_team_fable_enabled() -> bool:
|
|
"""Return whether Fable models are enabled for the team."""
|
|
settings = await get_team_settings()
|
|
value = settings.get("fable_enabled")
|
|
return bool(value) if isinstance(value, bool) else False
|
|
|
|
|
|
async def get_effective_gateway_enabled() -> bool:
|
|
"""Resolve whether LLM Gateway routing is on: team setting, else env default."""
|
|
return resolve_gateway_enabled(await get_team_gateway_enabled())
|
|
|
|
|
|
async def get_team_review_tracing_project() -> str | None:
|
|
"""Return the LangSmith tracing project used for PR trace resolution."""
|
|
settings = await get_team_settings()
|
|
value = settings.get("review_tracing_project")
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return None
|
|
|
|
|
|
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()
|