"""Profile lookup + override helpers consumed by ``agent.server.get_agent``.""" from __future__ import annotations import logging from typing import Any import httpx from langgraph_sdk import get_client from .options import SUPPORTED_MODEL_IDS, model_supports_effort, provider_fallback_pair from .profiles import PROFILES_NAMESPACE from .team_settings import get_team_default_model from .user_mappings import cached_login_for_email, login_for_email logger = logging.getLogger(__name__) def resolve_login_from_email(email: str | None) -> str | None: """Reverse-lookup the user-mapping store for the GitHub login of an email. Reads the in-process mapping cache (sync). When the cache is cold the lookup misses; the webhook path that triggers a run primes the cache via :func:`agent.dashboard.user_mappings.refresh_cache` beforehand. """ return cached_login_for_email(email) async def resolve_login_from_email_async(email: str | None) -> str | None: """Async reverse-lookup that falls through to the Store on a cold cache. Use this from webhook/repo-resolution paths that may run on a freshly started worker before the user-mapping cache has been primed, so a mapped user still resolves to their GitHub login (and dashboard ``default_repo``). """ return await login_for_email(email if isinstance(email, str) else None) def resolve_github_login(config: dict[str, Any]) -> str | None: """Best-effort resolution of the triggering user's GitHub login from config.""" configurable = (config or {}).get("configurable") or {} login = configurable.get("github_login") if isinstance(login, str) and login.strip(): return login.strip() slack_thread = configurable.get("slack_thread") or {} email = configurable.get("user_email") or slack_thread.get("triggering_user_email") return resolve_login_from_email(email if isinstance(email, str) else None) async def get_profile_default_repo(login: str | None) -> dict[str, str] | None: """Return ``{"owner", "name"}`` for the user's profile default_repo, if set.""" if not login: return None profile = await load_profile(login) if not profile: return None default_repo = profile.get("default_repo") if not isinstance(default_repo, str): return None parts = default_repo.strip().split("/", 1) if len(parts) != 2: return None owner, name = parts[0].strip(), parts[1].strip() if not owner or not name: return None return {"owner": owner, "name": name} async def load_profile(login: str) -> dict[str, Any] | None: try: item = await get_client().store.get_item(PROFILES_NAMESPACE, login) except httpx.HTTPStatusError as e: if e.response.status_code == 404: return None logger.warning("profile lookup failed for %s: %s", login, e) return None if item is None: return None value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None) return value if isinstance(value, dict) else None def profile_create_prs(profile: dict[str, Any] | None) -> bool: """Return whether the agent should always open a PR. Defaults to False.""" if not isinstance(profile, dict): return False value = profile.get("create_prs") if isinstance(value, bool): return value return False def profile_author_prs_as_user(profile: dict[str, Any] | None) -> bool: """Return whether Slack/dashboard/schedule PRs should be authored as the triggering user (their per-user OAuth token) instead of the App bot. Defaults to False: by default these PRs (and the run's git/gh operations) are authored as the GitHub App `seahaven-openswe[bot]`, matching GitHub-issue runs and making the self-review 422 impossible. Set the profile flag ``author_prs_as_user: true`` to opt back into per-user attribution. """ if not isinstance(profile, dict): return False value = profile.get("author_prs_as_user") if isinstance(value, bool): return value return False def _normalize_profile_model_pair( profile: dict[str, Any], *, model_key: str, effort_key: str, ) -> tuple[str | None, str | None]: model_id = profile.get(model_key) effort = profile.get(effort_key) if ( isinstance(model_id, str) and model_id in SUPPORTED_MODEL_IDS and isinstance(effort, str) and model_supports_effort(model_id, effort) ): return model_id, effort # A stored selection whose exact id dropped out of the supported set (e.g. an # Opus minor-version bump) stays on its provider rather than being discarded # and silently deferring to the team default. An absent/unknown-provider # selection still returns (None, None) so the team default applies. if isinstance(model_id, str): provider_pair = provider_fallback_pair(model_id, effort) if provider_pair is not None: return provider_pair return None, None def normalize_profile_overrides(profile: dict[str, Any]) -> tuple[str | None, str | None]: """Return ``(model_id, reasoning_effort)`` if both are valid, else ``(None, None)``.""" return _normalize_profile_model_pair( profile, model_key="default_model", effort_key="reasoning_effort", ) def normalize_profile_subagent_overrides( profile: dict[str, Any], ) -> tuple[str | None, str | None]: """Return the profile's subagent model pair if valid, else ``(None, None)``.""" return _normalize_profile_model_pair( profile, model_key="default_subagent_model", effort_key="subagent_reasoning_effort", ) async def resolve_agent_model_id( github_login: str | None, per_thread_model_id: str | None = None, ) -> str: """Resolve the agent model ID using the same precedence as ``get_agent``. Order: per-thread override → profile override → team default. """ model_id, _effort = await get_team_default_model("agent") if github_login: profile = await load_profile(github_login) if profile: overridden_model, _ = normalize_profile_overrides(profile) if overridden_model: model_id = overridden_model if isinstance(per_thread_model_id, str) and per_thread_model_id in SUPPORTED_MODEL_IDS: model_id = per_thread_model_id return model_id