Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 238 additions & 34 deletions libs/code/deepagents_code/app.py

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion libs/code/deepagents_code/model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,9 +562,18 @@ class ProviderConfig(TypedDict, total=False):
name, so it gets special handling beyond a plain key copy.
"""

TAVILY_SERVICE = "tavily"
"""Service name for Tavily web search in `SERVICE_API_KEY_ENV`.

Storing a key for this service via `/auth` gates the spawn-time `web_search`
tool (see `server_graph._build_tools`), so a key added to a running server
takes effect only after a respawn — the app offers that restart, and this
constant is the single name its `/auth` handling compares against.
"""

SERVICE_API_KEY_ENV: dict[str, str] = {
LANGSMITH_SERVICE: "LANGSMITH_API_KEY",
"tavily": "TAVILY_API_KEY",
TAVILY_SERVICE: "TAVILY_API_KEY",
}
"""Non-model services configurable via `/auth`, mapped to their API-key env var.

Expand Down
52 changes: 46 additions & 6 deletions libs/code/deepagents_code/tui/widgets/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import logging
import os
from enum import StrEnum
from functools import partial
from typing import TYPE_CHECKING, ClassVar, NamedTuple
from urllib.parse import urlsplit

Expand Down Expand Up @@ -1364,7 +1365,39 @@ class AuthManagerScreen(ModalScreen[None]):
"""

class CredentialSaved(Message):
"""Posted when a key prompt successfully persists credentials."""
"""Posted when a key prompt successfully persists credentials.

Carries the `/auth` config key that was saved so the app can react to
credentials that gate spawn-time behavior — e.g. a Tavily key that
enables the `web_search` tool only after the server respawns.
"""

def __init__(self, provider: str) -> None:
"""Store the saved provider/service identifier.

Args:
provider: The `/auth` config key that was saved (a model
provider name or a service key such as `"tavily"`).
"""
super().__init__()
self.provider = provider

class CredentialDeleted(Message):
"""Posted when a key prompt deletes stored credentials.

Carries the `/auth` config key that was deleted so the app can clear
any in-memory state derived from the now-removed credential.
"""

def __init__(self, provider: str) -> None:
"""Store the deleted provider/service identifier.

Args:
provider: The `/auth` config key that was deleted (a model
provider name or a service key such as `"tavily"`).
"""
super().__init__()
self.provider = provider

BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "cancel", "Close", show=False, priority=True),
Expand Down Expand Up @@ -1561,13 +1594,13 @@ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> No
# same way as a model-provider key.
self.app.push_screen(
AuthPromptScreen(provider, SERVICE_API_KEY_ENV[provider]),
self._on_prompt_closed,
partial(self._on_prompt_closed, provider),
)
return
env_var = get_credential_env_var(provider)
self.app.push_screen(
AuthPromptScreen(provider, env_var),
self._on_prompt_closed,
partial(self._on_prompt_closed, provider),
)

def _prompt_install_provider(self, provider: str, extra: str) -> None:
Expand Down Expand Up @@ -1664,11 +1697,18 @@ def action_cursor_up(self) -> None:
"""Move the option-list cursor up."""
self.query_one("#auth-manager-options", OptionList).action_cursor_up()

def _on_prompt_closed(self, result: AuthResult | None) -> None:
"""Refresh the option list once the prompt dismisses."""
def _on_prompt_closed(self, provider: str, result: AuthResult | None) -> None:
"""Refresh the option list once the prompt dismisses.

Args:
provider: The provider/service whose prompt just closed.
result: Outcome of the prompt interaction.
"""
self._refresh_options()
if result is AuthResult.SAVED:
self.post_message(self.CredentialSaved())
self.post_message(self.CredentialSaved(provider))
elif result is AuthResult.DELETED:
self.post_message(self.CredentialDeleted(provider))

def _refresh_options(self) -> None:
"""Rebuild option labels from current store state."""
Expand Down
47 changes: 35 additions & 12 deletions libs/code/deepagents_code/tui/widgets/restart_prompt.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Confirmation modal offered after a restart-capable `/install`.

Provider and sandbox extras (and `--package` installs) are imported by the
app-owned LangGraph server subprocess, so a `/restart` loads them without
exiting the TUI. Rather than make the user type `/restart` by hand, this
modal offers to run that restart immediately while leaving deferral one
keypress away.
"""Confirmation modal offered when a change needs an owned-server respawn.

Some changes take effect only when the app-owned LangGraph server subprocess
spawns: provider/sandbox extras and `--package` installs are imported at spawn
time, and a Tavily key saved via `/auth` binds the `web_search` tool only at
spawn time. A `/restart` respawns the subprocess without exiting the TUI, so
rather than make the user type `/restart` by hand, this modal offers to run
that restart immediately while leaving deferral one keypress away. The title
`verb` and `body` are caller-supplied so one modal serves each flow.
"""

from __future__ import annotations
Expand All @@ -28,7 +30,10 @@


class RestartPromptScreen(ModalScreen[RestartChoice]):
"""Modal asking whether to restart the server after a successful install.
"""Modal asking whether to restart the server for a spawn-time change.

Serves both the post-install offer and the post-`/auth` web-search offer;
the caller supplies the title `verb` and `body` copy.

Dismisses with `"restart"` when the user accepts and `"later"` when the
user defers. Esc is treated as "later" so the user is never forced into a
Expand Down Expand Up @@ -75,14 +80,31 @@ class RestartPromptScreen(ModalScreen[RestartChoice]):
}
"""

def __init__(self, label: str) -> None:
_DEFAULT_BODY = "Restart the server to load it now, or defer with `/restart`."

def __init__(
self,
label: str,
*,
verb: str,
body: str | None = None,
) -> None:
"""Initialize the prompt.

Args:
label: Installed extra/package name, surfaced in the title.
label: The subject surfaced in the title (e.g. an installed extra
name, or a saved credential like ``"Tavily API key"``).
verb: Past-tense action shown before `label` in the title — e.g.
`"Installed"` for the post-install flow or `"Saved"` for a
saved credential. Required (no default) so each flow states its
own intent and a future caller can't inherit install-only copy.
body: Optional override for the explanatory line under the title.
Defaults to the generic restart copy.
"""
super().__init__()
self._label = label
self._verb = verb
self._body = body or self._DEFAULT_BODY

def compose(self) -> ComposeResult:
"""Compose the confirmation dialog.
Expand All @@ -94,15 +116,16 @@ def compose(self) -> ComposeResult:
with Vertical():
yield Static(
Content.from_markup(
"$check Installed [bold]$name[/bold]",
"$check $verb [bold]$name[/bold]",
check=glyphs.checkmark,
verb=self._verb,
name=self._label,
),
classes="restart-prompt-title",
markup=False,
)
yield Static(
"Restart the server to load it now, or defer with `/restart`.",
self._body,
classes="restart-prompt-body",
markup=False,
)
Expand Down
17 changes: 14 additions & 3 deletions libs/code/tests/unit_tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11564,10 +11564,16 @@ async def test_reopens_auth_after_installed_extra_even_when_restart_fails(
async def test_does_not_reopen_auth_when_install_failed(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""A failed install leaves the user in chat and logs the dead-end at DEBUG."""
"""A failed install leaves the user in chat and logs the dead-end at DEBUG.

This non-reopen path must still consume an armed web-search restart so
the flag never strands onto a later, unrelated `/auth` close.
"""
app = DeepAgentsApp()
app._install_extra = AsyncMock(return_value=False) # ty: ignore
app._show_auth_manager = AsyncMock() # ty: ignore
app.call_after_refresh = MagicMock() # ty: ignore
app._pending_web_search_restart = True

with (
patch("deepagents_code.app._extra_is_ready", return_value=False),
Expand All @@ -11578,18 +11584,22 @@ async def test_does_not_reopen_auth_when_install_failed(
app._install_extra.assert_awaited_once_with("baseten", auto_restart=True) # ty: ignore
app._show_auth_manager.assert_not_awaited() # ty: ignore
assert any("baseten" in record.message for record in caplog.records)
assert app._pending_web_search_restart is False

async def test_surfaces_hint_when_install_state_unverifiable(self) -> None:
"""An unknown post-install state points the user back to `/auth`.

When the extra can't be introspected (`_extra_is_ready` returns `None`)
the manager must not reopen, but the flow must not dead-end silently
either — a message tells the user how to finish.
either — a message tells the user how to finish. This path also consumes
an armed web-search restart so the flag is never stranded.
"""
app = DeepAgentsApp()
app._install_extra = AsyncMock(return_value=False) # ty: ignore
app._show_auth_manager = AsyncMock() # ty: ignore
app._mount_message = AsyncMock() # ty: ignore
app.call_after_refresh = MagicMock() # ty: ignore
app._pending_web_search_restart = True

with patch("deepagents_code.app._extra_is_ready", return_value=None):
await app._install_provider_then_reopen_auth("baseten", provider="baseten")
Expand All @@ -11598,6 +11608,7 @@ async def test_surfaces_hint_when_install_state_unverifiable(self) -> None:
app._mount_message.assert_awaited_once() # ty: ignore
message = app._mount_message.await_args.args[0] # ty: ignore
assert "baseten" in message._content
assert app._pending_web_search_restart is False


class TestExtraIsReady:
Expand Down Expand Up @@ -13087,7 +13098,7 @@ async def test_auth_saved_event_resumes_startup_immediately(self) -> None:
app._resume_server_after_auth_change = resume # ty: ignore

app.on_auth_manager_screen_credential_saved(
AuthManagerScreen.CredentialSaved()
AuthManagerScreen.CredentialSaved("openai")
)
await asyncio.sleep(0)

Expand Down
Loading
Loading