open-swe/agent/dashboard/agent_overrides.py
Johannes du Plessis b19804536c
feat: handle images sent to non-vision models in Slack, Linear, and web UI (#1560)
* feat: handle images sent to non-vision models in Slack, Linear, and web UI

Add vision capability checks across all image input paths. When a user
sends images to a text-only model (e.g. GLM 5.2, DeepSeek V4 Pro), the
images are now skipped and a warning is injected into the prompt instead
of sending unsupported content to the model.

- Slack: resolve model at webhook time, skip image fetch + add warning
- Linear: same pattern as Slack
- Queued message middleware: read resolved model from thread metadata,
  strip images from queued payloads for text-only models
- Web UI: disable submit + show inline warning when images are attached
  to a non-vision model selection
- Shared: resolve_agent_model_id helper + vision_not_supported_warning

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* test: mock resolve_agent_model_id in Slack mention test

The test_process_slack_mention_queues_active_thread_message test was
missing a mock for the new resolve_agent_model_id call added to the
Slack webhook handler, causing a TypeError when image URLs triggered
the model resolution path.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: include vision warning in queued payload for text-only models

Update the prompt variable (not just content_blocks) before clearing
image_urls so the queued payload also carries the warning text when a
Slack/Linear follow-up arrives while the thread is busy.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-17 09:12:52 -07:00

158 lines
5.6 KiB
Python

"""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 _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