mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
90cc099b09
Tavily web-search API keys can now be entered and managed from the `/auth` Manage API keys screen instead of only via the `TAVILY_API_KEY` environment variable. --- Setting up Tavily web search was a dead end: the only path was a notification that pointed at `tavily.com`, with no way to enter a key the way model-provider keys are entered. This surfaces non-model services (starting with Tavily) in the `/auth` Manage API keys screen so a key can be entered and stored on disk like any provider key. - A new `SERVICE_API_KEY_ENV` registry (currently just `tavily` → `TAVILY_API_KEY`) drives a service-aware `get_service_auth_status`, so Tavily renders the same `[stored]` / `[env: …]` / `[missing]` badges and reuses the existing `AuthPromptScreen`. - `apply_stored_service_credentials` bridges a stored key onto its canonical env var during bootstrap, so a key entered in the TUI activates web search on the next launch without exporting anything. - The "Web search disabled" notification now offers a primary `Enter API key` action that opens the prompt directly. Note: web search tools are wired at server start from `settings.has_tavily`, so a freshly entered key takes effect on the next launch/restart rather than mid-session — called out in the notification copy. Made by [Open SWE](https://openswe.vercel.app/agents/cbecdb67-7ab2-8ada-6cf3-8e619c734f59) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
248 lines
8.2 KiB
Python
248 lines
8.2 KiB
Python
"""Registry of pending actionable notifications.
|
|
|
|
Stores plain data for notices the user can act on from a dedicated
|
|
modal screen. The registry is deliberately UI-agnostic: UI routing
|
|
(toast click, keybinds) lives in the app layer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ActionId(StrEnum):
|
|
"""Stable identifiers for notification actions dispatched by the app."""
|
|
|
|
SUPPRESS = "suppress"
|
|
"""Persist a suppression entry so the notice isn't re-raised."""
|
|
|
|
COPY_INSTALL = "copy_install"
|
|
"""Copy the install command to the system clipboard."""
|
|
|
|
OPEN_WEBSITE = "open_website"
|
|
"""Open the associated URL in the user's browser."""
|
|
|
|
ENTER_API_KEY = "enter_api_key"
|
|
"""Open the `/auth` manager so the user can store an API key."""
|
|
|
|
INSTALL = "install"
|
|
"""Run the upgrade command via `perform_upgrade`."""
|
|
|
|
SKIP_ONCE = "skip_once"
|
|
"""Clear the notified marker so the update modal re-opens next launch."""
|
|
|
|
SKIP_VERSION = "skip_version"
|
|
"""Mark this version as notified; silence until a newer version ships."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NotificationAction:
|
|
"""One button/action row in the notification modal."""
|
|
|
|
action_id: ActionId
|
|
label: str
|
|
primary: bool = False
|
|
"""Whether to render this action in bold.
|
|
|
|
`UpdateAvailableScreen` also uses this flag to place the initial
|
|
cursor on the primary row; `NotificationCenterScreen` always starts
|
|
on the first row regardless.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MissingDepPayload:
|
|
"""Typed payload for a missing-dependency notification."""
|
|
|
|
tool: str
|
|
"""Name of the missing tool (e.g. `"ripgrep"`, `"tavily"`)."""
|
|
|
|
install_command: str | None = None
|
|
"""Shell command that installs the tool, when one is known."""
|
|
|
|
url: str | None = None
|
|
"""Install guide or sign-up URL, used when no direct command exists."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UpdateAvailablePayload:
|
|
"""Typed payload for an update-available notification."""
|
|
|
|
latest: str
|
|
"""PyPI version string the user is being prompted to install."""
|
|
|
|
upgrade_cmd: str
|
|
"""Shell command that upgrades to `latest`."""
|
|
|
|
|
|
Payload = MissingDepPayload | UpdateAvailablePayload
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PendingNotification:
|
|
"""A single notice waiting for user action.
|
|
|
|
Immutable value object: the registry owns the
|
|
key-to-toast-identity binding (see `NotificationRegistry`) so
|
|
external callers cannot corrupt click-routing indices by mutating
|
|
notifications after construction.
|
|
"""
|
|
|
|
key: str
|
|
"""Stable identifier used to dedupe and to remove the notice once handled."""
|
|
|
|
title: str
|
|
"""One-line heading shown in the modal."""
|
|
|
|
body: str
|
|
"""Longer description shown below the title.
|
|
|
|
May contain install instructions, links, or version info.
|
|
"""
|
|
|
|
actions: tuple[NotificationAction, ...]
|
|
"""Available actions, rendered as rows in the modal."""
|
|
|
|
payload: Payload
|
|
"""Kind-specific typed data consumed by the action dispatcher."""
|
|
|
|
def __post_init__(self) -> None:
|
|
"""Enforce basic invariants at construction time.
|
|
|
|
Raises:
|
|
ValueError: If `key` is empty, `actions` is empty, or more
|
|
than one action is marked `primary=True`.
|
|
"""
|
|
if not self.key:
|
|
msg = "PendingNotification.key must be non-empty"
|
|
raise ValueError(msg)
|
|
if not self.actions:
|
|
msg = f"PendingNotification {self.key!r} must declare at least one action"
|
|
raise ValueError(msg)
|
|
primaries = sum(1 for a in self.actions if a.primary)
|
|
if primaries > 1:
|
|
msg = (
|
|
f"PendingNotification {self.key!r} has {primaries} primary actions; "
|
|
"at most one is allowed"
|
|
)
|
|
raise ValueError(msg)
|
|
|
|
|
|
class NotificationRegistry:
|
|
"""In-memory store of pending notifications.
|
|
|
|
Instance-scoped (one per app) so test apps don't pollute each other.
|
|
Owns the bidirectional key-to-toast-identity binding so callers
|
|
cannot accidentally desynchronize the click-routing indices.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize an empty registry."""
|
|
self._entries: dict[str, PendingNotification] = {}
|
|
self._key_to_toast: dict[str, str] = {}
|
|
self._toast_to_key: dict[str, str] = {}
|
|
|
|
def add(self, notification: PendingNotification) -> None:
|
|
"""Register a new notification or replace an existing one with the same key.
|
|
|
|
Replacing is intentional: re-registering with the same key
|
|
refreshes the entry rather than stacking duplicates. Any
|
|
previously bound toast identity is dropped, since the old toast
|
|
has been dismissed or replaced by the caller.
|
|
|
|
Args:
|
|
notification: Entry to add or replace.
|
|
"""
|
|
old_identity = self._key_to_toast.pop(notification.key, None)
|
|
if old_identity is not None:
|
|
self._toast_to_key.pop(old_identity, None)
|
|
self._entries[notification.key] = notification
|
|
|
|
def remove(self, key: str) -> PendingNotification | None:
|
|
"""Remove a notification by key.
|
|
|
|
Args:
|
|
key: Registry key of the entry to remove.
|
|
|
|
Returns:
|
|
The removed entry, or `None` when *key* was not registered.
|
|
"""
|
|
entry = self._entries.pop(key, None)
|
|
identity = self._key_to_toast.pop(key, None)
|
|
if identity is not None:
|
|
self._toast_to_key.pop(identity, None)
|
|
return entry
|
|
|
|
def get(self, key: str) -> PendingNotification | None:
|
|
"""Return the notification for *key*, or `None` when not registered."""
|
|
return self._entries.get(key)
|
|
|
|
def bind_toast(self, key: str, toast_identity: str) -> None:
|
|
"""Attach a Textual toast identity to an existing notification.
|
|
|
|
Logs a warning when *key* is unknown — this only happens if a
|
|
caller binds a toast without first `add`-ing the entry, which is
|
|
a programming error.
|
|
|
|
Args:
|
|
key: Registry key of the entry.
|
|
toast_identity: `Notification.identity` of the originating toast.
|
|
"""
|
|
if key not in self._entries:
|
|
logger.warning("bind_toast called for unknown key %r; ignoring", key)
|
|
return
|
|
prev = self._key_to_toast.get(key)
|
|
if prev is not None:
|
|
self._toast_to_key.pop(prev, None)
|
|
self._key_to_toast[key] = toast_identity
|
|
self._toast_to_key[toast_identity] = key
|
|
|
|
def toast_identity_for(self, key: str) -> str | None:
|
|
"""Return the toast identity bound to *key*, or `None`."""
|
|
return self._key_to_toast.get(key)
|
|
|
|
def unbind_toast(self, toast_identity: str) -> None:
|
|
"""Drop the binding for *toast_identity*, if any.
|
|
|
|
Does not remove the underlying notification entry; used when the
|
|
toast is being dismissed (e.g. the user opened the notification
|
|
center) but the entry should stay in the registry.
|
|
|
|
Args:
|
|
toast_identity: `Notification.identity` of the toast to unbind.
|
|
Unknown identities are a no-op.
|
|
"""
|
|
key = self._toast_to_key.pop(toast_identity, None)
|
|
if key is not None:
|
|
self._key_to_toast.pop(key, None)
|
|
|
|
def key_for_toast(self, toast_identity: str) -> str | None:
|
|
"""Return the registered key for *toast_identity*, or `None`."""
|
|
return self._toast_to_key.get(toast_identity)
|
|
|
|
def is_actionable_toast(self, toast_identity: str) -> bool:
|
|
"""Return whether a click on *toast_identity* should open the modal."""
|
|
return toast_identity in self._toast_to_key
|
|
|
|
def list_all(self) -> list[PendingNotification]:
|
|
"""Return all pending notifications in insertion order."""
|
|
return list(self._entries.values())
|
|
|
|
def __len__(self) -> int:
|
|
"""Return the number of pending notifications."""
|
|
return len(self._entries)
|
|
|
|
def __bool__(self) -> bool:
|
|
"""Return `True` when at least one notification is pending."""
|
|
return bool(self._entries)
|
|
|
|
def clear(self) -> None:
|
|
"""Remove all entries. Primarily useful for tests."""
|
|
self._entries.clear()
|
|
self._key_to_toast.clear()
|
|
self._toast_to_key.clear()
|