mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-27 02:41:20 -04:00
7ffaa93dca
Adds a first-class way to install optional extras into an existing `dcode` install without dropping back to a shell or guessing package names. Surfaces it as both the `/install <extra>` slash command (in-app) and a `dcode --install EXTRA` headless flag, and rewrites the ecosystem of `pip install …` recovery hints to point at the new flow.
9928 lines
390 KiB
Python
9928 lines
390 KiB
Python
"""Textual UI application."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import shlex
|
|
import signal
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
import webbrowser
|
|
from collections import deque
|
|
from contextlib import suppress
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
|
|
|
|
from textual import on
|
|
from textual.app import App, ScreenStackError
|
|
from textual.binding import Binding, BindingType
|
|
from textual.containers import Container, VerticalScroll
|
|
from textual.content import Content
|
|
from textual.css.query import NoMatches
|
|
from textual.events import Click
|
|
from textual.message import Message
|
|
from textual.notifications import Notification as _Notification, Notify as _Notify
|
|
from textual.screen import ModalScreen
|
|
from textual.style import Style as TStyle
|
|
from textual.theme import Theme
|
|
from textual.widgets import Header, Static
|
|
from textual.widgets._toast import (
|
|
Toast as _Toast, # noqa: PLC2701 # for Toast click routing
|
|
)
|
|
|
|
# Applied as an import-time side effect; must come before any App is created.
|
|
from deepagents_code import (
|
|
_textual_patches, # noqa: F401
|
|
theme,
|
|
)
|
|
from deepagents_code._cli_context import CLIContext
|
|
from deepagents_code._constants import DEFAULT_AGENT_NAME as DEFAULT_ASSISTANT_ID
|
|
from deepagents_code._debug import configure_debug_logging
|
|
from deepagents_code._git import (
|
|
read_git_branch_from_filesystem,
|
|
read_git_branch_via_subprocess,
|
|
)
|
|
from deepagents_code._session_stats import (
|
|
SessionStats,
|
|
SpinnerStatus,
|
|
format_token_count,
|
|
)
|
|
|
|
# Only is_ascii_mode is needed before first paint (on_mount scrollbar config).
|
|
# All other config imports — settings, create_model, detect_provider, etc. — are
|
|
# deferred to local imports at their call sites since they are only accessed
|
|
# after user interaction begins.
|
|
from deepagents_code._version import CHANGELOG_URL, DOCS_URL
|
|
from deepagents_code.config import is_ascii_mode
|
|
from deepagents_code.iterm_cursor_guide import restore_iterm_cursor_guide
|
|
from deepagents_code.notifications import (
|
|
ActionId,
|
|
MissingDepPayload,
|
|
NotificationAction,
|
|
NotificationRegistry,
|
|
PendingNotification,
|
|
UpdateAvailablePayload,
|
|
)
|
|
from deepagents_code.widgets._links import open_url_async
|
|
from deepagents_code.widgets.chat_input import ChatInput
|
|
from deepagents_code.widgets.loading import LoadingWidget
|
|
from deepagents_code.widgets.message_store import (
|
|
MessageData,
|
|
MessageStore,
|
|
MessageType,
|
|
ToolStatus,
|
|
)
|
|
from deepagents_code.widgets.messages import (
|
|
AppMessage,
|
|
AssistantMessage,
|
|
ErrorMessage,
|
|
QueuedUserMessage,
|
|
SkillMessage,
|
|
ToolCallMessage,
|
|
UserMessage,
|
|
)
|
|
from deepagents_code.widgets.status import StatusBar
|
|
from deepagents_code.widgets.welcome import WelcomeBanner
|
|
|
|
logger = logging.getLogger(__name__)
|
|
configure_debug_logging(logger)
|
|
_monotonic = time.monotonic
|
|
|
|
_DEFERRED_START_NOTICE = (
|
|
"No model is configured yet. Run `/model` to choose one. "
|
|
"Deep Agents will ask for credentials for the selected provider."
|
|
)
|
|
|
|
# Serializes process-local read-modify-write operations for `config.toml`.
|
|
# Without this, overlapping global-theme and per-terminal-theme saves can each
|
|
# read the same pre-mutation state and then clobber the other's keys.
|
|
_CONFIG_WRITE_LOCK = threading.Lock()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _ConfigWriteResult:
|
|
"""Result of a config write with TUI-facing failure context."""
|
|
|
|
ok: bool
|
|
"""Whether the write completed successfully."""
|
|
|
|
message: str | None = None
|
|
"""Optional user-facing detail for repairs or failures."""
|
|
|
|
severity: Literal["warning", "error"] = "warning"
|
|
"""Toast severity to use when `message` is shown."""
|
|
|
|
|
|
ScreenResultT = TypeVar("ScreenResultT")
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Awaitable, Callable, Mapping
|
|
|
|
from deepagents.backends import CompositeBackend
|
|
from langchain_core.runnables import RunnableConfig
|
|
from langgraph.pregel import Pregel
|
|
from textual.app import ComposeResult
|
|
from textual.events import MouseUp, Paste
|
|
from textual.scrollbar import ScrollUp
|
|
from textual.widget import Widget
|
|
from textual.worker import Worker
|
|
|
|
from deepagents_code._ask_user_types import AskUserWidgetResult, Question
|
|
from deepagents_code.event_bus import EventSource, ExternalEvent
|
|
from deepagents_code.mcp_tools import MCPServerInfo
|
|
from deepagents_code.model_config import MissingProviderPackageError
|
|
from deepagents_code.remote_client import RemoteAgent
|
|
from deepagents_code.server import ServerProcess
|
|
from deepagents_code.skills.load import ExtendedSkillMetadata
|
|
from deepagents_code.textual_adapter import TextualUIAdapter
|
|
from deepagents_code.widgets.approval import ApprovalMenu
|
|
from deepagents_code.widgets.ask_user import AskUserMenu
|
|
from deepagents_code.widgets.notification_center import (
|
|
NotificationSuppressRequested,
|
|
)
|
|
from deepagents_code.widgets.update_progress import UpdateProgressScreen
|
|
|
|
_LAUNCH_INIT_CONNECTION_TIMEOUT_SECONDS = 60.0
|
|
"""Upper bound on waiting for server readiness during onboarding model switch.
|
|
|
|
Server startup is normally seconds; this ceiling exists only so a stuck
|
|
backend cannot trap the user inside a finished onboarding modal forever.
|
|
"""
|
|
|
|
_UPDATE_RECHECK_INTERVAL_SECONDS = 60 * 60
|
|
"""How often long-running TUI sessions quietly re-check for app updates."""
|
|
|
|
|
|
def _resolve_theme_name(value: object) -> str | None:
|
|
"""Resolve a user-supplied theme name to a canonical registry key.
|
|
|
|
Accepts the registry key or the human-readable label, case-insensitive
|
|
on both, with surrounding whitespace stripped — config values
|
|
(especially `[ui.terminal_themes]`) and the `DEEPAGENTS_CODE_THEME`
|
|
env var are commonly hand-edited. Also applies the legacy
|
|
`textual-ansi` → `ansi-light` migration (pre-Textual 8.2.5).
|
|
|
|
Args:
|
|
value: Raw value read from TOML or an environment variable.
|
|
|
|
Returns:
|
|
The canonical registry key, or `None` if the value is not a string or
|
|
does not match any registered theme by key or label
|
|
(case-insensitive).
|
|
"""
|
|
if not isinstance(value, str):
|
|
return None
|
|
name = value.strip()
|
|
if name == "textual-ansi":
|
|
name = "ansi-light"
|
|
registry = theme.get_registry()
|
|
if name in registry:
|
|
return name
|
|
folded = name.casefold()
|
|
for registered, entry in registry.items():
|
|
if registered.casefold() == folded or entry.label.casefold() == folded:
|
|
return registered
|
|
return None
|
|
|
|
|
|
def _as_toml_table(value: object) -> dict[str, object] | None:
|
|
"""Return `value` as a TOML table when it has the expected runtime shape."""
|
|
if not isinstance(value, dict):
|
|
return None
|
|
# `tomllib` parses TOML tables as string-keyed dicts; `ty` cannot infer
|
|
# that from a runtime `dict` check. Keep the cast at this boundary so it
|
|
# does not become a general-purpose escape hatch.
|
|
from typing import cast
|
|
|
|
return cast("dict[str, object]", value)
|
|
|
|
|
|
def _resolve_terminal_mapping(ui: Mapping[str, object]) -> str | None:
|
|
"""Resolve `[ui.terminal_themes][TERM_PROGRAM]` to a registered theme.
|
|
|
|
Centralizes both the lookup and the misconfiguration warnings shared by
|
|
`_load_theme_preference` (startup) and `_load_terminal_default` (picker
|
|
badge). Misconfiguration is logged exactly once per call.
|
|
|
|
Args:
|
|
ui: The `[ui]` table parsed from `config.toml`.
|
|
|
|
Returns:
|
|
The canonical registry key, or `None` if `terminal_themes` is absent,
|
|
malformed, references an unknown theme, or `TERM_PROGRAM` is unset
|
|
despite a non-empty mapping.
|
|
"""
|
|
terminal_themes = ui.get("terminal_themes")
|
|
if terminal_themes is None:
|
|
return None
|
|
terminal_themes_table = _as_toml_table(terminal_themes)
|
|
if terminal_themes_table is None:
|
|
logger.warning(
|
|
"[ui.terminal_themes] should be a table mapping TERM_PROGRAM "
|
|
"values to theme names; got %s",
|
|
type(terminal_themes).__name__,
|
|
)
|
|
return None
|
|
term_program = os.environ.get("TERM_PROGRAM", "").strip()
|
|
if not term_program:
|
|
if terminal_themes_table:
|
|
logger.warning(
|
|
"[ui.terminal_themes] is configured but TERM_PROGRAM is unset; "
|
|
"no per-terminal theme will be applied",
|
|
)
|
|
return None
|
|
mapped = terminal_themes_table.get(term_program)
|
|
resolved = _resolve_theme_name(mapped)
|
|
if resolved is not None:
|
|
return resolved
|
|
if isinstance(mapped, str):
|
|
logger.warning(
|
|
"Unknown theme '%s' mapped to TERM_PROGRAM='%s' "
|
|
"in [ui.terminal_themes]; ignoring",
|
|
mapped,
|
|
term_program,
|
|
)
|
|
elif mapped is not None:
|
|
logger.warning(
|
|
"Expected string theme name for TERM_PROGRAM='%s' in "
|
|
"[ui.terminal_themes], got %s; ignoring",
|
|
term_program,
|
|
type(mapped).__name__,
|
|
)
|
|
return None
|
|
|
|
|
|
def _load_terminal_default() -> str | None:
|
|
"""Return the saved default theme for the current `TERM_PROGRAM`.
|
|
|
|
Reads `[ui.terminal_themes][TERM_PROGRAM]` from `config.toml` and
|
|
resolves the value via `_resolve_theme_name`, so labels and case variants
|
|
are accepted. Used by `ThemeSelectorScreen` to badge the matching option
|
|
with `(default)`.
|
|
|
|
Returns:
|
|
The canonical registry key, or `None` if `TERM_PROGRAM` is unset, the
|
|
file is missing/unreadable, no mapping is set, or the mapped value
|
|
doesn't match a registered theme. Read errors and misconfigurations
|
|
are logged at WARNING.
|
|
"""
|
|
if not os.environ.get("TERM_PROGRAM", "").strip():
|
|
return None
|
|
|
|
import tomllib
|
|
|
|
from deepagents_code.model_config import DEFAULT_CONFIG_PATH
|
|
|
|
if not DEFAULT_CONFIG_PATH.exists():
|
|
return None
|
|
try:
|
|
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
except (tomllib.TOMLDecodeError, PermissionError, OSError) as exc:
|
|
logger.warning("Could not read config for terminal theme default: %s", exc)
|
|
return None
|
|
|
|
ui = data.get("ui")
|
|
if not isinstance(ui, dict):
|
|
if ui is not None:
|
|
logger.warning(
|
|
"[ui] should be a table; got %s while loading terminal theme default",
|
|
type(ui).__name__,
|
|
)
|
|
return None
|
|
return _resolve_terminal_mapping(ui)
|
|
|
|
|
|
def _load_theme_preference() -> str:
|
|
"""Load the forced or saved theme name, or return the default.
|
|
|
|
Resolution order:
|
|
|
|
1. `DEEPAGENTS_CODE_THEME` env var (explicit override). If it is set but
|
|
cannot be resolved, the default theme is used immediately.
|
|
2. `[ui.terminal_themes]` mapping keyed by `TERM_PROGRAM` — wins over the
|
|
saved preference so a user moving between terminals (e.g. dark iTerm,
|
|
light Apple Terminal) gets the right theme automatically.
|
|
3. `[ui].theme` in `~/.deepagents/config.toml` (saved preference, used
|
|
when no terminal mapping matches).
|
|
4. `theme.DEFAULT_THEME`.
|
|
|
|
Returns:
|
|
A Textual theme name (e.g., `'langchain'`, `'langchain-light'`).
|
|
"""
|
|
from deepagents_code._env_vars import THEME
|
|
|
|
env_name = os.environ.get(THEME)
|
|
if env_name is not None:
|
|
resolved = _resolve_theme_name(env_name)
|
|
if resolved is not None:
|
|
return resolved
|
|
logger.warning(
|
|
"Unknown theme '%s' in %s; falling back to default",
|
|
env_name,
|
|
THEME,
|
|
)
|
|
return theme.DEFAULT_THEME
|
|
|
|
import tomllib
|
|
|
|
from deepagents_code.model_config import DEFAULT_CONFIG_PATH
|
|
|
|
if not DEFAULT_CONFIG_PATH.exists():
|
|
return theme.DEFAULT_THEME
|
|
try:
|
|
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
except (tomllib.TOMLDecodeError, PermissionError, OSError) as exc:
|
|
logger.warning("Could not read config for theme preference: %s", exc)
|
|
return theme.DEFAULT_THEME
|
|
|
|
ui = data.get("ui", {})
|
|
if not isinstance(ui, dict):
|
|
logger.warning(
|
|
"[ui] should be a table; got %s while loading theme preference",
|
|
type(ui).__name__,
|
|
)
|
|
return theme.DEFAULT_THEME
|
|
|
|
resolved = _resolve_terminal_mapping(ui)
|
|
if resolved is not None:
|
|
return resolved
|
|
|
|
saved = ui.get("theme")
|
|
resolved = _resolve_theme_name(saved)
|
|
if resolved is not None:
|
|
return resolved
|
|
if isinstance(saved, str):
|
|
logger.warning(
|
|
"Unknown theme '%s' in config; falling back to default",
|
|
saved,
|
|
)
|
|
|
|
return theme.DEFAULT_THEME
|
|
|
|
|
|
def _replace_malformed_ui(
|
|
data: dict[str, object],
|
|
) -> tuple[dict[str, object], str | None]:
|
|
"""Return a writable `[ui]` table, replacing malformed values if needed."""
|
|
ui = data.get("ui")
|
|
table = _as_toml_table(ui)
|
|
if table is not None:
|
|
return table, None
|
|
replaced_malformed = ui is not None
|
|
if ui is not None:
|
|
logger.warning(
|
|
"Existing [ui] is not a table (got %r); replacing with a fresh table",
|
|
ui,
|
|
)
|
|
ui = {}
|
|
data["ui"] = ui
|
|
return ui, (
|
|
"Existing [ui] was not a table and was replaced while saving the theme "
|
|
"configuration."
|
|
if replaced_malformed
|
|
else None
|
|
)
|
|
|
|
|
|
def _save_theme_preference_result(name: str) -> _ConfigWriteResult:
|
|
"""Persist theme preference and return TUI-facing status details.
|
|
|
|
Returns:
|
|
Write status and a message suitable for a toast when the user needs to
|
|
know about a repair or failure.
|
|
"""
|
|
if name not in theme.get_registry():
|
|
logger.warning("Refusing to save unknown theme '%s'", name)
|
|
return _ConfigWriteResult(False, f"Unknown theme '{name}' was not saved.")
|
|
|
|
import contextlib
|
|
import tempfile
|
|
import tomllib
|
|
|
|
try:
|
|
import tomli_w
|
|
|
|
from deepagents_code.model_config import DEFAULT_CONFIG_PATH
|
|
|
|
DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
with _CONFIG_WRITE_LOCK:
|
|
if DEFAULT_CONFIG_PATH.exists():
|
|
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
else:
|
|
data = {}
|
|
|
|
ui, repair_message = _replace_malformed_ui(data)
|
|
ui["theme"] = name
|
|
|
|
fd, tmp_path = tempfile.mkstemp(
|
|
dir=DEFAULT_CONFIG_PATH.parent,
|
|
suffix=".tmp",
|
|
)
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(DEFAULT_CONFIG_PATH)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except (
|
|
OSError,
|
|
tomllib.TOMLDecodeError,
|
|
ImportError,
|
|
TypeError,
|
|
ValueError,
|
|
) as exc:
|
|
logger.exception("Could not save theme preference")
|
|
return _ConfigWriteResult(
|
|
False,
|
|
f"Theme applied for this session but could not be saved "
|
|
f"({type(exc).__name__}).",
|
|
"error",
|
|
)
|
|
return _ConfigWriteResult(True, repair_message)
|
|
|
|
|
|
def save_theme_preference(name: str) -> bool:
|
|
"""Persist theme preference to `~/.deepagents/config.toml`.
|
|
|
|
Args:
|
|
name: Textual theme name to save.
|
|
|
|
Returns:
|
|
`True` if the preference was saved, `False` if any error occurred.
|
|
"""
|
|
return _save_theme_preference_result(name).ok
|
|
|
|
|
|
def _save_terminal_theme_mapping_result(
|
|
term_program: str,
|
|
name: str,
|
|
) -> _ConfigWriteResult:
|
|
"""Persist a terminal theme mapping and return TUI-facing status details.
|
|
|
|
Returns:
|
|
Write status and a message suitable for a toast when the user needs to
|
|
know about a repair or failure.
|
|
"""
|
|
if name not in theme.get_registry():
|
|
logger.warning("Refusing to map unknown theme '%s'", name)
|
|
return _ConfigWriteResult(False, f"Unknown theme '{name}' was not saved.")
|
|
term_program = term_program.strip()
|
|
if not term_program:
|
|
logger.warning("Refusing to save terminal mapping with empty TERM_PROGRAM")
|
|
return _ConfigWriteResult(
|
|
False,
|
|
"TERM_PROGRAM is unset; can't set a per-terminal default.",
|
|
)
|
|
|
|
import contextlib
|
|
import tempfile
|
|
import tomllib
|
|
|
|
try:
|
|
import tomli_w
|
|
|
|
from deepagents_code.model_config import DEFAULT_CONFIG_PATH
|
|
|
|
DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
repair_messages: list[str] = []
|
|
with _CONFIG_WRITE_LOCK:
|
|
if DEFAULT_CONFIG_PATH.exists():
|
|
with DEFAULT_CONFIG_PATH.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
else:
|
|
data = {}
|
|
|
|
ui, repair_message = _replace_malformed_ui(data)
|
|
if repair_message is not None:
|
|
repair_messages.append(repair_message)
|
|
terminal_themes = ui.get("terminal_themes")
|
|
terminal_themes_table = _as_toml_table(terminal_themes)
|
|
if terminal_themes_table is None:
|
|
if terminal_themes is not None:
|
|
logger.warning(
|
|
"Existing [ui.terminal_themes] is not a table (got %r); "
|
|
"replacing with a fresh table",
|
|
terminal_themes,
|
|
)
|
|
repair_messages.append(
|
|
"Existing [ui.terminal_themes] was not a table and was "
|
|
"replaced while saving this terminal default.",
|
|
)
|
|
terminal_themes_table = {}
|
|
ui["terminal_themes"] = terminal_themes_table
|
|
terminal_themes_table[term_program] = name
|
|
|
|
fd, tmp_path = tempfile.mkstemp(
|
|
dir=DEFAULT_CONFIG_PATH.parent,
|
|
suffix=".tmp",
|
|
)
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
tomli_w.dump(data, f)
|
|
Path(tmp_path).replace(DEFAULT_CONFIG_PATH)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
Path(tmp_path).unlink()
|
|
raise
|
|
except (
|
|
OSError,
|
|
tomllib.TOMLDecodeError,
|
|
ImportError,
|
|
TypeError,
|
|
ValueError,
|
|
) as exc:
|
|
logger.exception("Could not save terminal theme mapping")
|
|
return _ConfigWriteResult(
|
|
False,
|
|
f"Could not save terminal mapping ({type(exc).__name__}).",
|
|
"error",
|
|
)
|
|
return _ConfigWriteResult(True, " ".join(repair_messages) or None)
|
|
|
|
|
|
def save_terminal_theme_mapping(term_program: str, name: str) -> bool:
|
|
"""Persist a `[ui.terminal_themes][term_program] = name` entry.
|
|
|
|
The write is atomic (temp file + `Path.replace`) to avoid corrupting
|
|
`config.toml` on crash or SIGINT. Mirrors `save_theme_preference`.
|
|
|
|
Args:
|
|
term_program: Value of the `TERM_PROGRAM` environment variable to key
|
|
on. Whitespace is stripped; the trimmed value is matched verbatim
|
|
against `os.environ["TERM_PROGRAM"]` at lookup time.
|
|
name: Theme name to map. Validated as an exact registry-key match —
|
|
labels and case variants are rejected here because the picker
|
|
writes canonical keys.
|
|
|
|
Returns:
|
|
`True` if the mapping was saved, `False` if `name` isn't a registered
|
|
theme, `term_program` is empty after stripping, or any error
|
|
occurred.
|
|
"""
|
|
return _save_terminal_theme_mapping_result(term_program, name).ok
|
|
|
|
|
|
def _extract_model_params_flag(raw_arg: str) -> tuple[str, dict[str, Any] | None]:
|
|
"""Extract `--model-params` and its JSON value from a `/model` arg string.
|
|
|
|
Handles quoted (`'...'` / `"..."`) and bare `{...}` values with balanced
|
|
braces so that JSON containing spaces works without quoting.
|
|
|
|
Note:
|
|
The bare-brace mode counts `{` / `}` characters without awareness of
|
|
JSON string contents. Values that contain literal braces inside strings
|
|
(e.g., `{"stop": "end}here"}`) will mis-parse. Users should quote the
|
|
value in that case.
|
|
|
|
Args:
|
|
raw_arg: The argument string after `/model `.
|
|
|
|
Returns:
|
|
Tuple of `(remaining_args, parsed_dict | None)`. Returns `None` for the
|
|
dict when the flag is absent.
|
|
|
|
Raises:
|
|
ValueError: If the value is missing, has unclosed quotes,
|
|
unbalanced braces, or is not valid JSON.
|
|
TypeError: If the parsed JSON is not a dict.
|
|
"""
|
|
flag = "--model-params"
|
|
idx = raw_arg.find(flag)
|
|
if idx == -1:
|
|
return raw_arg, None
|
|
|
|
before = raw_arg[:idx].rstrip()
|
|
after = raw_arg[idx + len(flag) :].lstrip()
|
|
|
|
if not after:
|
|
msg = "--model-params requires a JSON object value"
|
|
raise ValueError(msg)
|
|
|
|
# Determine the JSON string boundaries.
|
|
if after[0] in {"'", '"'}:
|
|
quote = after[0]
|
|
end = -1
|
|
backslash_count = 0
|
|
for i, ch in enumerate(after[1:], start=1):
|
|
if ch == "\\":
|
|
backslash_count += 1
|
|
continue
|
|
if ch == quote and backslash_count % 2 == 0:
|
|
end = i
|
|
break
|
|
backslash_count = 0
|
|
if end == -1:
|
|
msg = f"Unclosed {quote} in --model-params value"
|
|
raise ValueError(msg)
|
|
# Parse the quoted token with shlex so escaped quotes are unescaped.
|
|
json_str = shlex.split(after[: end + 1], posix=True)[0]
|
|
rest = after[end + 1 :].lstrip()
|
|
elif after[0] == "{":
|
|
# Walk forward to find the matching closing brace.
|
|
depth = 0
|
|
end = -1
|
|
for i, ch in enumerate(after):
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
end = i
|
|
break
|
|
if end == -1:
|
|
msg = "Unbalanced braces in --model-params value"
|
|
raise ValueError(msg)
|
|
json_str = after[: end + 1]
|
|
rest = after[end + 1 :].lstrip()
|
|
else:
|
|
# Non-brace, non-quoted — take the next whitespace-delimited token.
|
|
parts = after.split(None, 1)
|
|
json_str = parts[0]
|
|
rest = parts[1] if len(parts) > 1 else ""
|
|
|
|
remaining = f"{before} {rest}".strip()
|
|
try:
|
|
params = json.loads(json_str)
|
|
except json.JSONDecodeError:
|
|
msg = (
|
|
f"Invalid JSON in --model-params: {json_str!r}. "
|
|
'Expected format: --model-params \'{"key": "value"}\''
|
|
)
|
|
raise ValueError(msg) from None
|
|
if not isinstance(params, dict):
|
|
msg = "--model-params must be a JSON object, got " + type(params).__name__
|
|
raise TypeError(msg)
|
|
return remaining, params
|
|
|
|
|
|
def _format_model_params(extra_kwargs: dict[str, Any] | None) -> str:
|
|
"""Render `--model-params` as a stable, key-sorted JSON suffix.
|
|
|
|
Args:
|
|
extra_kwargs: The parsed `--model-params` payload, or `None`.
|
|
|
|
Returns:
|
|
` with model params {json}` when `extra_kwargs` is non-empty;
|
|
otherwise an empty string so callers can unconditionally concatenate.
|
|
"""
|
|
if not extra_kwargs:
|
|
return ""
|
|
return f" with model params {json.dumps(extra_kwargs, sort_keys=True)}"
|
|
|
|
|
|
InputMode = Literal["normal", "shell", "shell_incognito", "command"]
|
|
|
|
_RECONNECT_FORCE_TOKENS: frozenset[str] = frozenset({"force", "--force", "-f"})
|
|
|
|
|
|
def _parse_reconnect_args(rest: str) -> tuple[bool, bool]:
|
|
"""Parse the argument tail of `/mcp reconnect [force]`.
|
|
|
|
Trailing tokens after `force` reject because the user's intent is
|
|
unclear.
|
|
|
|
Args:
|
|
rest: Everything after `/mcp reconnect` (already stripped).
|
|
|
|
Returns:
|
|
`(force, valid)`. `valid=False` means the caller should surface
|
|
the usage message and skip the handler.
|
|
"""
|
|
tokens = rest.split()
|
|
if not tokens:
|
|
return False, True
|
|
if len(tokens) == 1 and tokens[0].lower() in _RECONNECT_FORCE_TOKENS:
|
|
return True, True
|
|
return False, False
|
|
|
|
|
|
_TYPING_IDLE_THRESHOLD_SECONDS: float = 2.0
|
|
"""Seconds since the last keystroke after which the user is considered idle and
|
|
a pending approval widget can be shown.
|
|
|
|
Two seconds balances responsiveness with avoiding accidental approval
|
|
key presses.
|
|
"""
|
|
|
|
_DEFERRED_APPROVAL_TIMEOUT_SECONDS: float = 30.0
|
|
"""Maximum seconds the deferred-approval worker will wait for the user to stop
|
|
typing before showing the approval widget regardless."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class QueuedMessage:
|
|
"""Represents a queued user message awaiting processing."""
|
|
|
|
text: str
|
|
"""The message text content."""
|
|
|
|
mode: InputMode
|
|
"""The input mode that determines message routing."""
|
|
|
|
|
|
class ExternalInput(Message):
|
|
"""Textual message carrying an external prompt or command."""
|
|
|
|
def __init__(self, event: ExternalEvent) -> None:
|
|
"""Create an external input message.
|
|
|
|
Args:
|
|
event: Transport-independent external event.
|
|
"""
|
|
super().__init__()
|
|
self.event = event
|
|
|
|
|
|
DeferredActionKind = Literal[
|
|
"model_switch",
|
|
"thread_switch",
|
|
"chat_output",
|
|
"agent_switch",
|
|
"mcp_login",
|
|
]
|
|
"""Valid `DeferredAction.kind` values for type-checked deduplication."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class DeferredAction:
|
|
"""An action deferred until the current busy state resolves."""
|
|
|
|
kind: DeferredActionKind
|
|
"""Identity key for deduplication — one of `DeferredActionKind`."""
|
|
|
|
execute: Callable[[], Awaitable[None]]
|
|
"""Async callable that performs the actual work."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _ThreadHistoryPayload:
|
|
"""Data returned by `_fetch_thread_history_data`."""
|
|
|
|
messages: list[MessageData]
|
|
"""Converted message data ready for bulk loading."""
|
|
|
|
context_tokens: int
|
|
"""Persisted `_context_tokens` from the checkpoint (0 if absent)."""
|
|
|
|
|
|
def _new_thread_id() -> str:
|
|
"""Deferred-import wrapper around `sessions.generate_thread_id`.
|
|
|
|
Returns:
|
|
UUID7 string.
|
|
"""
|
|
from deepagents_code.sessions import generate_thread_id
|
|
|
|
return generate_thread_id()
|
|
|
|
|
|
def _action_label(entry: PendingNotification, action_id: ActionId) -> str:
|
|
"""Return the user-facing label for *action_id* on *entry*, or the id itself."""
|
|
for action in entry.actions:
|
|
if action.action_id == action_id:
|
|
return action.label
|
|
return action_id.value
|
|
|
|
|
|
def _truncate(text: str, *, limit: int) -> str:
|
|
"""Return *text* truncated to *limit* characters with an ellipsis suffix."""
|
|
text = text.strip()
|
|
if len(text) <= limit:
|
|
return text
|
|
return text[: limit - 1].rstrip() + "…"
|
|
|
|
|
|
def _log_task_exception(task: asyncio.Task[Any]) -> None:
|
|
"""Done-callback that surfaces unhandled exceptions from fire-and-forget tasks.
|
|
|
|
Default `asyncio` behavior is to log "Task exception was never retrieved"
|
|
only when the task is GC'd — easy to miss. This callback runs at task
|
|
completion and routes failures through `logger.warning` with `exc_info`,
|
|
matching the codebase pattern at `_finalize_git_branch_refresh`. Use
|
|
when scheduling a coroutine via `asyncio.create_task` whose result is
|
|
not awaited (e.g. event-handler cleanup, single-fire mounts).
|
|
"""
|
|
try:
|
|
task.result()
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception:
|
|
logger.warning("Background task failed unexpectedly", exc_info=True)
|
|
|
|
|
|
def _build_model_switch_error_body(exc: BaseException) -> str | Content:
|
|
"""Format a model-switch failure for `ErrorMessage`.
|
|
|
|
Args:
|
|
exc: Exception raised by `create_model`.
|
|
|
|
Returns:
|
|
A `Content` with the docs URL as a clickable span when `exc` is
|
|
`UnknownProviderError`; a plain string otherwise.
|
|
"""
|
|
from deepagents_code.model_config import UnknownProviderError
|
|
|
|
if isinstance(exc, UnknownProviderError):
|
|
return Content.assemble(
|
|
"Failed to switch model: unable to infer a provider for ",
|
|
(exc.model_spec, TStyle(bold=True)),
|
|
".\n\nSpecify one explicitly (e.g. ",
|
|
(f"anthropic:{exc.model_spec}", TStyle(italic=True)),
|
|
") or see the provider reference: ",
|
|
(exc.docs_url, TStyle(underline=True, link=exc.docs_url)),
|
|
)
|
|
return f"Failed to switch model: {exc}"
|
|
|
|
|
|
def _build_whats_new_message(heading: str) -> Content:
|
|
"""Build the post-upgrade banner with a clickable changelog URL.
|
|
|
|
Args:
|
|
heading: First line of the post-upgrade banner.
|
|
|
|
Returns:
|
|
Styled banner content with the changelog URL embedded as a link.
|
|
"""
|
|
return Content.assemble(
|
|
(heading, TStyle(dim=True, italic=True)),
|
|
"\n",
|
|
("See what's new: ", TStyle(dim=True, italic=True)),
|
|
(
|
|
CHANGELOG_URL,
|
|
TStyle(dim=True, italic=True, underline=True, link=CHANGELOG_URL),
|
|
),
|
|
)
|
|
|
|
|
|
def _format_startup_error(error: BaseException) -> str:
|
|
"""Format a server-startup exception for the welcome banner.
|
|
|
|
`wait_for_server_healthy` appends a tail of the subprocess log to its
|
|
`RuntimeError` message (see `_LOG_TAIL_CHARS` in `server.py`), which
|
|
would overwhelm the banner. Trim to the headline so the user sees an
|
|
actionable line instead of a scrolling traceback; `DEEPAGENTS_CODE_DEBUG=1`
|
|
preserves the full log on disk for triage.
|
|
|
|
Args:
|
|
error: The exception raised during server startup.
|
|
|
|
Returns:
|
|
A single-line `Type: message` summary suitable for the banner.
|
|
"""
|
|
first_line = str(error).splitlines()[0].strip() if str(error) else ""
|
|
if not first_line:
|
|
first_line = error.__class__.__name__
|
|
return f"{type(error).__name__}: {_truncate(first_line, limit=300)}"
|
|
|
|
|
|
class TextualSessionState:
|
|
"""Session state for the Textual app."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
auto_approve: bool = False,
|
|
thread_id: str | None = None,
|
|
) -> None:
|
|
"""Initialize session state.
|
|
|
|
Args:
|
|
auto_approve: Whether to auto-approve tool calls
|
|
thread_id: Optional thread ID (generates UUID7 if not provided)
|
|
"""
|
|
self.auto_approve = auto_approve
|
|
self.thread_id = thread_id or _new_thread_id()
|
|
|
|
def reset_thread(self) -> str:
|
|
"""Reset to a new thread.
|
|
|
|
Returns:
|
|
The new thread_id.
|
|
"""
|
|
self.thread_id = _new_thread_id()
|
|
return self.thread_id
|
|
|
|
|
|
_COMMAND_URLS: dict[str, str] = {
|
|
"/changelog": CHANGELOG_URL,
|
|
"/docs": DOCS_URL,
|
|
"/feedback": "https://github.com/langchain-ai/deepagents/issues/new/choose",
|
|
}
|
|
"""Slash-command to URL mapping for commands that just open a browser."""
|
|
|
|
_SANDBOX_DISPLAY_NAMES: dict[str, str] = {
|
|
"agentcore": "AgentCore",
|
|
"daytona": "Daytona",
|
|
"langsmith": "LangSmith",
|
|
"modal": "Modal",
|
|
"runloop": "Runloop",
|
|
}
|
|
"""Human-readable display names for sandbox providers."""
|
|
|
|
|
|
_toast_internals_warned: list[bool] = [False]
|
|
"""Single-slot flag; once `_Toast._notification` is missing, log warning once.
|
|
|
|
Tests reset this directly (`_toast_internals_warned[0] = False`) when
|
|
they need to exercise the one-shot semantics deterministically.
|
|
"""
|
|
|
|
|
|
def _toast_identity(
|
|
widget: _Toast,
|
|
*,
|
|
app: App | None = None,
|
|
) -> str | None:
|
|
"""Return the identity of the notification backing *widget*, or `None`.
|
|
|
|
`_Toast._notification` is a Textual internal. If a future upgrade
|
|
renames it, toast-click routing silently becomes inert. Logs a
|
|
single warning, and — when *app* is supplied — also posts a
|
|
one-shot user-visible toast pointing users at the `ctrl+n`
|
|
fallback so the regression isn't invisible outside the debug log.
|
|
"""
|
|
notif = getattr(widget, "_notification", None)
|
|
if notif is None:
|
|
if not _toast_internals_warned[0]:
|
|
logger.warning(
|
|
"Textual Toast no longer exposes `_notification`; "
|
|
"toast-click routing is disabled.",
|
|
)
|
|
if app is not None:
|
|
app.notify(
|
|
"Toast click routing disabled after a Textual upgrade. "
|
|
"Press ctrl+n to view notifications.",
|
|
severity="warning",
|
|
timeout=10,
|
|
markup=False,
|
|
)
|
|
_toast_internals_warned[0] = True
|
|
return None
|
|
return getattr(notif, "identity", None)
|
|
|
|
|
|
class _StaticHeader(Header):
|
|
"""`Header` variant that doesn't toggle tall mode on click.
|
|
|
|
Textual's default `Header._on_click` toggles a `-tall` class to expand the
|
|
header from 1 to 3 lines. Subclassing alone isn't enough: Textual's message
|
|
dispatch walks the full MRO and invokes every matching handler, so the
|
|
parent's `_on_click` still fires unless we call `event.prevent_default()`,
|
|
which sets `_no_default_action` and breaks the MRO walk
|
|
(see `MessagePump._get_dispatch_methods`).
|
|
"""
|
|
|
|
@on(Click)
|
|
def _suppress_header_click(self, event: Click) -> None: # noqa: PLR6301
|
|
event.prevent_default()
|
|
event.stop()
|
|
|
|
|
|
class DeepAgentsApp(App):
|
|
"""Main Textual application for deepagents-code."""
|
|
|
|
TITLE = "Deep Agents"
|
|
"""Textual application title."""
|
|
|
|
CSS_PATH = "app.tcss"
|
|
"""Path to the Textual CSS stylesheet for the app layout."""
|
|
|
|
ENABLE_COMMAND_PALETTE = False
|
|
"""Disable Textual's built-in command palette in favor of the custom slash
|
|
command system."""
|
|
|
|
SCROLL_SENSITIVITY_Y = 1.0
|
|
"""Vertical scroll speed (reduced from Textual default for finer control)."""
|
|
|
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
|
|
Binding(
|
|
"ctrl+c",
|
|
"quit_or_interrupt",
|
|
"Quit/Interrupt",
|
|
show=False,
|
|
priority=True,
|
|
),
|
|
Binding("ctrl+d", "quit_app", "Quit", show=False, priority=True),
|
|
Binding("ctrl+t", "toggle_auto_approve", "Toggle Auto-Approve", show=False),
|
|
Binding(
|
|
"shift+tab",
|
|
"toggle_auto_approve",
|
|
"Toggle Auto-Approve",
|
|
show=False,
|
|
priority=True,
|
|
),
|
|
Binding(
|
|
"ctrl+o",
|
|
"toggle_tool_output",
|
|
"Toggle Tool Output",
|
|
show=False,
|
|
priority=True,
|
|
),
|
|
Binding(
|
|
"ctrl+x",
|
|
"open_editor",
|
|
"Open Editor",
|
|
show=False,
|
|
priority=True,
|
|
),
|
|
Binding(
|
|
"ctrl+n",
|
|
"open_notifications",
|
|
"Notifications",
|
|
show=False,
|
|
priority=True,
|
|
),
|
|
# Approval menu keys (handled at App level for reliability)
|
|
Binding("up", "approval_up", "Up", show=False),
|
|
Binding("k", "approval_up", "Up", show=False),
|
|
Binding("down", "approval_down", "Down", show=False),
|
|
Binding("j", "approval_down", "Down", show=False),
|
|
Binding("enter", "approval_select", "Select", show=False),
|
|
Binding("y", "approval_yes", "Yes", show=False),
|
|
Binding("1", "approval_yes", "Yes", show=False),
|
|
Binding("2", "approval_auto", "Auto", show=False),
|
|
Binding("a", "approval_auto", "Auto", show=False),
|
|
Binding("3", "approval_no", "No", show=False),
|
|
Binding("n", "approval_no", "No", show=False),
|
|
]
|
|
"""App-level keybindings for interrupt, quit, toggles, and approval menu
|
|
navigation."""
|
|
|
|
class ServerReady(Message):
|
|
"""Posted by the background server-startup worker on success."""
|
|
|
|
def __init__( # noqa: D107
|
|
self,
|
|
agent: Any, # noqa: ANN401
|
|
server_proc: Any, # noqa: ANN401
|
|
mcp_server_info: list[Any] | None,
|
|
) -> None:
|
|
super().__init__()
|
|
self.agent = agent
|
|
self.server_proc = server_proc
|
|
self.mcp_server_info = mcp_server_info
|
|
|
|
class ServerStartFailed(Message):
|
|
"""Posted by the background server-startup worker on failure."""
|
|
|
|
def __init__(self, error: Exception) -> None: # noqa: D107
|
|
super().__init__()
|
|
self.error = error
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
agent: Pregel | None = None,
|
|
assistant_id: str | None = None,
|
|
backend: CompositeBackend | None = None,
|
|
auto_approve: bool = False,
|
|
cwd: str | Path | None = None,
|
|
thread_id: str | None = None,
|
|
resume_thread: str | None = None,
|
|
initial_prompt: str | None = None,
|
|
initial_skill: str | None = None,
|
|
startup_cmd: str | None = None,
|
|
launch_init: bool = False,
|
|
mcp_server_info: list[MCPServerInfo] | None = None,
|
|
profile_override: dict[str, Any] | None = None,
|
|
server_proc: ServerProcess | None = None,
|
|
server_kwargs: dict[str, Any] | None = None,
|
|
mcp_preload_kwargs: dict[str, Any] | None = None,
|
|
model_kwargs: dict[str, Any] | None = None,
|
|
defer_server_start: bool = False,
|
|
title: str | None = None,
|
|
sub_title: str | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
"""Initialize the Deep Agents application.
|
|
|
|
Args:
|
|
agent: Pre-configured LangGraph agent, or `None` when server
|
|
startup is deferred via `server_kwargs`.
|
|
assistant_id: Agent identifier for memory storage
|
|
backend: Backend for file operations
|
|
auto_approve: Whether to start with auto-approve enabled
|
|
cwd: Current working directory to display
|
|
thread_id: Thread ID for the session.
|
|
|
|
`None` when `resume_thread` is provided (resolved asynchronously).
|
|
resume_thread: Raw resume intent from `-r` flag.
|
|
|
|
`'__MOST_RECENT__'` for bare `-r`, a thread ID string for
|
|
`-r <id>`, or `None` for new sessions.
|
|
|
|
Resolved via `_resolve_resume_thread`
|
|
during `_start_server_background`.
|
|
|
|
Requires `server_kwargs` to be set; ignored otherwise.
|
|
initial_prompt: Optional prompt to auto-submit when session starts
|
|
initial_skill: Optional skill name to invoke when session starts.
|
|
startup_cmd: Optional shell command to run at startup before the
|
|
first prompt is accepted.
|
|
|
|
Output is rendered in the transcript and non-zero exits warn but
|
|
do not abort the session.
|
|
launch_init: Whether to run the onboarding setup flow
|
|
before accepting the first prompt.
|
|
mcp_server_info: MCP server metadata for the `/mcp` viewer.
|
|
profile_override: Extra profile fields from `--profile-override`,
|
|
retained so later profile-aware behavior stays consistent with
|
|
the app override, including model selection details,
|
|
offload budget display, and on-demand `create_model()`
|
|
calls such as `/offload`.
|
|
server_proc: LangGraph server process for the interactive session.
|
|
server_kwargs: When provided, server startup is deferred.
|
|
|
|
The app shows a "Connecting..." state and starts the server in
|
|
the background using these kwargs
|
|
for `start_server_and_get_agent`.
|
|
mcp_preload_kwargs: Kwargs for `_preload_session_mcp_server_info`,
|
|
run concurrently with server startup when `server_kwargs` is set.
|
|
model_kwargs: Kwargs for deferred `create_model()`.
|
|
|
|
When provided, model creation runs in a background worker after
|
|
first paint instead of blocking startup.
|
|
defer_server_start: Whether to keep app-owned server startup paused
|
|
until the user configures credentials or explicitly picks a model.
|
|
title: Override the Textual `App.title` shown in the optional
|
|
header bar.
|
|
|
|
When `None`, the class-level `TITLE` is used.
|
|
|
|
Reassigning `app.title` at runtime updates the header live.
|
|
sub_title: Override the Textual `App.sub_title` shown in the
|
|
optional header bar.
|
|
|
|
When `None`, the parent default is used.
|
|
|
|
Reassigning `app.sub_title` at runtime updates the header live.
|
|
**kwargs: Additional arguments passed to parent
|
|
"""
|
|
super().__init__(**kwargs)
|
|
if title is not None:
|
|
self.title = title
|
|
if sub_title is not None:
|
|
self.sub_title = sub_title
|
|
|
|
self._register_custom_themes()
|
|
|
|
self.theme = _load_theme_preference()
|
|
"""Active Textual theme name.
|
|
|
|
Loaded from the user's saved preference (or the default) so the app
|
|
boots with consistent colors before `/theme` runs.
|
|
"""
|
|
self.sync_terminal_background()
|
|
|
|
# Injected session config
|
|
self._agent = agent
|
|
"""Pre-configured agent (local `Pregel` or `RemoteAgent`).
|
|
|
|
`None` when server startup is deferred via `server_kwargs`; filled in
|
|
by the server-ready handler (and by the agent-swap worker when
|
|
`/agents` restarts the subprocess).
|
|
"""
|
|
|
|
self._assistant_id = assistant_id
|
|
"""Current session agent identity.
|
|
|
|
Scopes per-agent memory (`~/.deepagents/<id>/`) and skill discovery,
|
|
keys `FileOpTracker` file-op history, and is attached to LangSmith
|
|
traces as `assistant_id` / `agent_name`. Mutated by `/agents` swaps
|
|
and by `-r` resume when the resumed thread belongs to a different
|
|
agent.
|
|
"""
|
|
|
|
self._default_assistant_id = assistant_id
|
|
"""User-intended default agent — persisted as `[agents].recent`.
|
|
|
|
Tracks only explicit user choice (`-a`, picker, recent fallback);
|
|
never mutated by `-r` resume. Mirrors `recent_model`'s invariant —
|
|
a one-off thread resume must not redefine the default.
|
|
"""
|
|
|
|
self._backend = backend
|
|
"""Filesystem/storage backend for agent file operations."""
|
|
|
|
self._auto_approve = auto_approve
|
|
"""Current auto-approve state for tool calls.
|
|
|
|
Initialized from `--auto-approve` and toggled at runtime via
|
|
Ctrl+T / Shift+Tab or the approval menu's 'Auto' option; kept in
|
|
sync with `_session_state.auto_approve`.
|
|
"""
|
|
|
|
self._cwd = str(cwd) if cwd else str(Path.cwd())
|
|
"""Session cwd.
|
|
|
|
Shown in the status bar; used as the root for `@` file-mention
|
|
completion in the chat input.
|
|
"""
|
|
|
|
self._lc_thread_id = thread_id
|
|
"""LangChain thread identifier.
|
|
|
|
Named `_lc_thread_id` to avoid collision with Textual's `App._thread_id`.
|
|
"""
|
|
|
|
self._resume_thread_intent = resume_thread
|
|
"""Raw `-r` intent (`None`, `'__MOST_RECENT__'`, or a thread id).
|
|
|
|
Resolved into a concrete `_lc_thread_id` by `_resolve_resume_thread`
|
|
during background startup.
|
|
"""
|
|
|
|
self._resume_thread_resolved_event = asyncio.Event()
|
|
"""Set once `-r` resume resolution has completed or is unnecessary."""
|
|
if resume_thread is None:
|
|
self._resume_thread_resolved_event.set()
|
|
|
|
self._initial_prompt = initial_prompt
|
|
"""Prompt to auto-submit after first paint (from `-m`)."""
|
|
|
|
self._initial_skill = (
|
|
initial_skill.strip().lower()
|
|
if initial_skill and initial_skill.strip()
|
|
else None
|
|
)
|
|
"""Skill name to auto-invoke after first paint (from `--skill`).
|
|
|
|
Normalized to lowercase; `None` when not provided.
|
|
"""
|
|
|
|
self._startup_cmd = (
|
|
startup_cmd.strip() if startup_cmd and startup_cmd.strip() else None
|
|
)
|
|
"""Shell command to run once before the first prompt, from
|
|
`--startup-cmd`.
|
|
|
|
Cleared to `None` after it runs so later server swaps cannot re-run it.
|
|
"""
|
|
|
|
self._launch_init_requested = launch_init
|
|
"""Whether startup should show onboarding during the initial paint."""
|
|
|
|
self._launch_init_running = False
|
|
"""Re-entry guard for launch init modals."""
|
|
|
|
self._launch_init_task: asyncio.Task[None] | None = None
|
|
"""Active onboarding task while the multi-screen flow is in progress."""
|
|
|
|
self._session_start_waiting_for_launch_init = False
|
|
"""Whether session startup is scheduled to resume after onboarding."""
|
|
|
|
self._launch_user_name: str | None = None
|
|
"""Name captured from the onboarding flow for the current session."""
|
|
|
|
self._mcp_server_info = mcp_server_info
|
|
"""MCP server metadata surfaced in the `/mcp` viewer."""
|
|
|
|
self._mcp_optimistic_original_server_info: dict[str, MCPServerInfo] = {}
|
|
"""Pre-disable server metadata for optimistic viewer toggles."""
|
|
|
|
self._pending_mcp_login_reconnect = False
|
|
"""Whether a successful MCP login is waiting for reconnect."""
|
|
|
|
self._pending_mcp_disable_reconnect_servers: set[str] = set()
|
|
"""MCP servers with disable-state changes waiting for reconnect."""
|
|
|
|
self._mcp_tool_count = sum(len(s.tools) for s in (mcp_server_info or []))
|
|
"""Total tool count across MCP servers, displayed in the status bar."""
|
|
|
|
self._mcp_unauthenticated = sum(
|
|
1 for s in (mcp_server_info or []) if s.status == "unauthenticated"
|
|
)
|
|
"""MCP servers awaiting a `dcode mcp login` run."""
|
|
|
|
self._mcp_errored = sum(
|
|
1 for s in (mcp_server_info or []) if s.status == "error"
|
|
)
|
|
"""MCP servers that failed to load (config or network error)."""
|
|
|
|
self._active_mcp_viewer: Any = None
|
|
"""Handle to the `/mcp` modal so server-ready events can refresh it."""
|
|
|
|
self._pending_mcp_reconnect: bool = False
|
|
"""Set after a successful MCP login when the user defers the server
|
|
restart. Cleared by the next reconnect or restart so multiple deferred
|
|
logins coalesce into a single user-driven restart."""
|
|
|
|
self._profile_override = profile_override
|
|
"""Extra profile fields from `--profile-override`, retained so later
|
|
profile-aware behavior (model selection, offload budget display,
|
|
on-demand `create_model()`) stays consistent with the app override."""
|
|
|
|
self._server_proc = server_proc
|
|
"""Handle to the langgraph dev subprocess, when the app owns one.
|
|
|
|
`None` in remote-server mode (the app connects to an external server
|
|
and cannot restart it).
|
|
"""
|
|
|
|
self._server_kwargs = server_kwargs
|
|
"""Cached kwargs for `start_server_and_get_agent`.
|
|
|
|
When non-`None`, startup is deferred and the UI begins in
|
|
"Connecting..." state unless `_server_startup_deferred` is set.
|
|
|
|
Re-used so downstream features that restart the server (e.g. `/agents`)
|
|
start from the same config.
|
|
"""
|
|
|
|
self._server_startup_deferred = defer_server_start
|
|
"""True when no model can be selected yet, usually first launch with
|
|
no credentials. The TUI is usable, but server startup waits for
|
|
`/auth`, `/reload`, or `/model`.
|
|
"""
|
|
|
|
self._server_startup_deferred_notice_shown = False
|
|
"""Whether the first-launch no-model guidance has been mounted."""
|
|
|
|
self._mcp_preload_kwargs = mcp_preload_kwargs
|
|
"""Kwargs for `_preload_session_mcp_server_info`, run concurrently
|
|
with server startup when `server_kwargs` is set."""
|
|
|
|
self._model_kwargs = model_kwargs
|
|
"""Kwargs for deferred `create_model()`.
|
|
|
|
When non-`None`, model creation runs in a background worker after
|
|
first paint; consumed by the startup worker and reset to `None`.
|
|
"""
|
|
|
|
raw = (server_kwargs or {}).get("sandbox_type")
|
|
"""Raw argparse sandbox value from `server_kwargs` before normalization.
|
|
|
|
`ServerConfig.__post_init__` maps `"none"` to `None`, but
|
|
`server_kwargs` still carries the argparse string, so `_sandbox_type`
|
|
below guards against both representations.
|
|
"""
|
|
|
|
self._sandbox_type: str | None = raw if raw and raw != "none" else None
|
|
"""Normalized sandbox type (or `None`), attached to trace metadata."""
|
|
|
|
if sub_title is None and self._sandbox_type is not None:
|
|
display = _SANDBOX_DISPLAY_NAMES.get(
|
|
self._sandbox_type,
|
|
self._sandbox_type.title(),
|
|
)
|
|
self.sub_title = f"Sandbox: {display}"
|
|
|
|
# Per-turn model overrides
|
|
self._model_override: str | None = None
|
|
"""Per-turn model override set via `/model`; `None` uses session default."""
|
|
|
|
self._model_params_override: dict[str, Any] | None = None
|
|
"""Per-turn model params override set via `/model --model-params`."""
|
|
|
|
# Widget refs (populated in compose/on_mount)
|
|
self._status_bar: StatusBar | None = None
|
|
"""Status bar widget; populated in `on_mount`."""
|
|
|
|
self._chat_input: ChatInput | None = None
|
|
"""Chat input widget; populated in `on_mount`."""
|
|
|
|
self._loading_widget: LoadingWidget | None = None
|
|
"""Active spinner widget; populated by `_set_spinner(status)` and
|
|
cleared when status resolves to `None`."""
|
|
|
|
self._ui_adapter: TextualUIAdapter | None = None
|
|
"""Bridge that renders agent events into widgets; set in `on_mount`."""
|
|
|
|
self._approval_placeholder: Static | None = None
|
|
"""'Waiting for typing to finish...' placeholder mounted in place of
|
|
the approval menu while the user is mid-type, so stray keys (`y`,
|
|
`n`, `1`-`3`) can't trigger approval decisions. Swapped for the real
|
|
`ApprovalMenu` by `_deferred_show_approval` once typing settles."""
|
|
|
|
self._pending_approval_widget: ApprovalMenu | None = None
|
|
"""Currently-mounted HITL approval widget awaiting a decision."""
|
|
|
|
self._pending_ask_user_widget: AskUserMenu | None = None
|
|
"""Currently-mounted `ask_user` prompt awaiting an answer."""
|
|
|
|
# Agent & shell run state
|
|
self._agent_worker: Worker[None] | None = None
|
|
"""Active `_run_agent_task` worker, tracked so it can be cancelled
|
|
on interrupt (`Ctrl+C`) or exit."""
|
|
|
|
self._agent_running = False
|
|
"""True while the agent worker is streaming a response."""
|
|
|
|
self._shell_process: asyncio.subprocess.Process | None = None
|
|
"""Shell command process tracking for interruption (! commands)."""
|
|
|
|
self._shell_worker: Worker[None] | None = None
|
|
"""Active `!` shell-command worker, tracked for interruption."""
|
|
|
|
self._shell_running = False
|
|
"""True while a `!` shell command is executing."""
|
|
|
|
self._prewarm_worker: Worker[None] | None = None
|
|
"""Background worker that prewarms `deepagents`/LangChain imports.
|
|
|
|
Awaited via `_await_prewarm_imports` before any caller on the event
|
|
loop re-enters the same module graph (see that method for why).
|
|
"""
|
|
|
|
self._deepagents_import_lock = asyncio.Lock()
|
|
"""Serializes cold imports into the Deep Agents graph after prewarm.
|
|
|
|
Startup model creation and skill discovery can both be triggered during
|
|
first paint. If prewarm failed or did not cover a transitive module,
|
|
both paths may cold-import overlapping modules at the same time.
|
|
"""
|
|
|
|
# Lifecycle flags & re-entry guards
|
|
self._connecting = (
|
|
server_kwargs is not None and not self._server_startup_deferred
|
|
)
|
|
"""True while the backing server is being started or restarted.
|
|
|
|
Gates message handling so user input is queued until the agent is
|
|
actually reachable.
|
|
"""
|
|
|
|
self._connection_ready_event = asyncio.Event()
|
|
"""Set once the initial server connection has either succeeded or failed."""
|
|
if not self._connecting:
|
|
self._connection_ready_event.set()
|
|
|
|
self._server_startup_error: str | None = None
|
|
"""Set when the background server fails to start; persists for the
|
|
session lifetime (server failure is terminal).
|
|
|
|
Shown in place of the generic 'Agent not configured' message.
|
|
"""
|
|
|
|
self._server_startup_missing_credentials_provider: str | None = None
|
|
"""Set to the offending provider name when startup failed with
|
|
`MissingCredentialsError`; `None` otherwise. Gates the `/model`
|
|
recovery hint without string-matching on the formatted error.
|
|
"""
|
|
|
|
self._server_startup_missing_provider_package: (
|
|
MissingProviderPackageError | None
|
|
) = None
|
|
"""The exception itself when startup failed with
|
|
`MissingProviderPackageError`; `None` otherwise. Stashing the exception
|
|
rather than a tuple gives the hint builder named access to `.provider`
|
|
and `.package`, and gates the `/install` / `/model` recovery hint
|
|
without string-matching on the formatted error.
|
|
"""
|
|
|
|
self._retry_status_widget: AppMessage | None = None
|
|
"""Transient "Retrying startup with X…" breadcrumb. Mounted via
|
|
`_mount_before_queued` (not `_mount_message`) because it is ephemeral
|
|
state and must not appear in scrollback or serialized history.
|
|
"""
|
|
|
|
self._startup_failure_widget: ErrorMessage | None = None
|
|
"""Transient chat surface for the most recent server-startup failure.
|
|
Mounted by `on_deep_agents_app_server_start_failed`; removed on
|
|
`ServerReady` so a successful `/model` retry doesn't leave the stale
|
|
error dangling in scrollback.
|
|
"""
|
|
|
|
self._quit_pending = False
|
|
"""True after a first `Ctrl+C` so a second press within the window quits."""
|
|
|
|
self._thread_switching = False
|
|
"""Re-entry guard for `/threads` switches; blocks message handling
|
|
until the new thread's history finishes loading."""
|
|
|
|
self._model_switching = False
|
|
"""Re-entry guard for `/model` switches while the new model is being
|
|
resolved."""
|
|
|
|
self._agent_switching = False
|
|
"""Re-entry guard for `/agents` switches while the backing server is
|
|
being restarted with a new `assistant_id`."""
|
|
|
|
self._processing_pending = False
|
|
"""Re-entry guard for `_process_next_from_queue` so only one drain
|
|
loop runs at a time."""
|
|
|
|
self._startup_sequence_running = False
|
|
"""True while post-connect startup work is still being sequenced.
|
|
|
|
Covers resumed-history hydration, `--startup-cmd`, and the handoff to
|
|
the first queued or initial submission so user input stays serialized
|
|
until the session reaches its first stable busy/idle state.
|
|
"""
|
|
|
|
# Message queue & store
|
|
self._pending_messages: deque[QueuedMessage] = deque()
|
|
"""User message queue for sequential processing."""
|
|
|
|
self._queued_widgets: deque[QueuedUserMessage] = deque()
|
|
"""Placeholder widgets mounted for messages still sitting in
|
|
`_pending_messages`, removed as the queue drains."""
|
|
|
|
self._message_store = MessageStore()
|
|
"""Message virtualization store."""
|
|
|
|
self._deferred_actions: list[DeferredAction] = []
|
|
"""Deferred actions executed after the current busy state resolves."""
|
|
|
|
# Session stats & tokens
|
|
self._session_stats: SessionStats = SessionStats()
|
|
"""Cumulative usage stats across all turns in this session."""
|
|
|
|
self._inflight_turn_stats: SessionStats | None = None
|
|
"""Stats for the currently executing turn.
|
|
|
|
Held here so `exit()` can merge them synchronously before the event loop
|
|
tears down (e.g. `Ctrl+D` during a pending tool call).
|
|
"""
|
|
|
|
self._inflight_turn_start: float = 0.0
|
|
"""Monotonic timestamp when the current turn started."""
|
|
|
|
self._context_tokens: int = 0
|
|
"""Local cache of the last total-context token count.
|
|
|
|
Source of truth is `_context_tokens` in graph state; this is a sync
|
|
copy for the status bar.
|
|
"""
|
|
|
|
self._tokens_approximate: bool = False
|
|
"""Whether the cached token count is stale (interrupted generation)."""
|
|
|
|
# Session lazy state & startup
|
|
self._session_state: TextualSessionState | None = None
|
|
"""Auto-approve + thread state shared with `execute_task_textual`.
|
|
|
|
Lazily constructed by the session-init worker so we don't block
|
|
startup on it.
|
|
"""
|
|
|
|
self._startup_task: asyncio.Task[None] | None = None
|
|
"""Startup task reference (set in on_mount)."""
|
|
|
|
self._external_event_source: EventSource | None = None
|
|
"""External event source created when its env var is enabled.
|
|
|
|
Cleared back to `None` if the listener fails to start so callers can
|
|
distinguish a configured-and-running listener from a no-op.
|
|
"""
|
|
|
|
self._external_event_source_task: asyncio.Task[None] | None = None
|
|
"""Lifecycle task for `_external_event_source`; cleared together."""
|
|
|
|
self._git_branch_refresh_task: asyncio.Task[None] | None = None
|
|
"""Latest background git-branch refresh task, if one is running."""
|
|
|
|
self._last_typed_at: float | None = None
|
|
"""Typing-aware approval deferral state."""
|
|
|
|
self._update_available: tuple[bool, str | None] = (False, None)
|
|
"""Update availability state.
|
|
|
|
Set by `_check_for_updates` when PyPI reports a newer version;
|
|
read at shutdown (for the exit banner), by `_handle_version_command`
|
|
(for the `/version` update hint), and by downstream callers. Does
|
|
*not* drive missing-dep toast suppression — that's gated on
|
|
`_update_modal_pending`.
|
|
"""
|
|
|
|
self._update_check_done = asyncio.Event()
|
|
"""Set by `_check_for_updates` when it returns (success, failure, or
|
|
no-op). Lets `_check_optional_tools_background` defer posting
|
|
missing-dep toasts until we know whether the update modal is about
|
|
to clear them."""
|
|
|
|
self._update_modal_pending = asyncio.Event()
|
|
"""Set only immediately before the update modal is scheduled.
|
|
|
|
Used by `_check_optional_tools_background` to decide whether to
|
|
suppress missing-dep toasts: we only suppress when a modal is
|
|
actually about to open, not merely when an update was detected.
|
|
A detected-but-throttled update (already notified within
|
|
`CACHE_TTL`) leaves this clear so missing-dep toasts still fire.
|
|
"""
|
|
|
|
self._update_install_running = False
|
|
"""True while a self-update command is running."""
|
|
|
|
# Skills cache
|
|
self._discovered_skills: list[ExtendedSkillMetadata] = []
|
|
"""Cached skill metadata (populated by startup discovery worker,
|
|
refreshed on `/reload`).
|
|
|
|
Used by `_invoke_skill` to skip re-walking all skill directories on
|
|
every invocation.
|
|
"""
|
|
|
|
self._skill_allowed_roots: list[Path] = []
|
|
"""Pre-resolved skill root directories for containment checks in
|
|
`load_skill_content`.
|
|
|
|
Built alongside `_discovered_skills`.
|
|
"""
|
|
|
|
# Media
|
|
# Lazily imported here to avoid pulling image dependencies into
|
|
# argument parsing paths.
|
|
from deepagents_code.input import MediaTracker
|
|
|
|
self._image_tracker = MediaTracker()
|
|
"""Tracks image/media pastes in the chat input so they can be
|
|
attached to outgoing messages and cleared after submission."""
|
|
|
|
self._notice_registry = NotificationRegistry()
|
|
"""Pending actionable notifications.
|
|
|
|
Startup workers register notices (missing deps, update available)
|
|
here; the user opens them via toast click or `ctrl+n`.
|
|
"""
|
|
|
|
def _remote_agent(self) -> RemoteAgent | None:
|
|
"""Return the agent narrowed to `RemoteAgent`, or `None`.
|
|
|
|
Returns `None` when:
|
|
|
|
- No agent is configured (`self._agent is None`).
|
|
- The agent is a local `Pregel` graph (e.g. ACP mode, test harnesses).
|
|
|
|
Used to gate features that require a server-backed agent (e.g. model
|
|
switching via `ConfigurableModelMiddleware`, thread registration).
|
|
Checks the agent type rather than server ownership so this works for
|
|
both app-spawned servers and externally managed ones.
|
|
|
|
Returns:
|
|
The `RemoteAgent` instance, or `None` for local agents.
|
|
"""
|
|
from deepagents_code.remote_client import RemoteAgent
|
|
|
|
return self._agent if isinstance(self._agent, RemoteAgent) else None
|
|
|
|
def get_theme_variable_defaults(self) -> dict[str, str]:
|
|
"""Return custom CSS variable defaults for the current theme.
|
|
|
|
Most styling uses Textual's built-in variables (`$primary`,
|
|
`$text-muted`, `$error-muted`, etc.). This override injects the
|
|
app-specific variables (`$mode-bash`, `$mode-command`,
|
|
`$mode-incognito`, `$skill`, `$skill-hover`, `$tool`, `$tool-hover`)
|
|
that have no Textual equivalent.
|
|
|
|
Returns:
|
|
Dict of CSS variable names to hex color values.
|
|
"""
|
|
colors = theme.get_theme_colors(self)
|
|
return theme.get_css_variable_defaults(colors=colors)
|
|
|
|
def _fatal_error(self) -> None:
|
|
"""Render an unhandled-exception traceback without leaking secrets.
|
|
|
|
Textual's default `_fatal_error` renders with `show_locals=True`,
|
|
which prints local variables — including resolved API keys carried
|
|
in `kwargs` dicts on the call path through `create_model`. Locals
|
|
are only re-enabled when `DEEPAGENTS_CODE_DEBUG` matches a truthy
|
|
token (`"1"`, `"true"`, `"yes"`); any other value, including `"0"`
|
|
and `"false"`, leaves them disabled.
|
|
"""
|
|
try:
|
|
import rich
|
|
from rich.segment import Segments
|
|
from rich.traceback import Traceback
|
|
|
|
from deepagents_code._env_vars import DEBUG
|
|
except Exception: # noqa: BLE001 # mid-teardown import errors fall through to Textual's default rather than double-fault and swallow the original crash
|
|
super()._fatal_error()
|
|
return
|
|
|
|
self.bell()
|
|
show_locals = os.environ.get(DEBUG, "").lower() in {"1", "true", "yes"}
|
|
traceback = Traceback(
|
|
show_locals=show_locals,
|
|
width=None,
|
|
locals_max_length=5,
|
|
suppress=[rich],
|
|
)
|
|
self._exit_renderables.append(
|
|
Segments(self.console.render(traceback, self.console.options)),
|
|
)
|
|
self._close_messages_no_wait()
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose the application layout.
|
|
|
|
Yields:
|
|
UI components for the main chat area and status bar.
|
|
"""
|
|
from deepagents_code._env_vars import SHOW_HEADER, is_env_truthy
|
|
|
|
if is_env_truthy(SHOW_HEADER):
|
|
yield _StaticHeader(id="app-header")
|
|
# Main chat area with scrollable messages
|
|
# VerticalScroll tracks user scroll intent for better auto-scroll behavior
|
|
with VerticalScroll(id="chat"):
|
|
yield WelcomeBanner(
|
|
thread_id=self._lc_thread_id,
|
|
mcp_tool_count=self._mcp_tool_count,
|
|
mcp_unauthenticated=self._mcp_unauthenticated,
|
|
mcp_errored=self._mcp_errored,
|
|
connecting=self._connecting,
|
|
resuming=self._resume_thread_intent is not None,
|
|
local_server=self._server_kwargs is not None,
|
|
defer_connecting_display=self._connecting
|
|
and self._resume_thread_intent is None,
|
|
id="welcome-banner",
|
|
)
|
|
yield Container(id="messages")
|
|
with Container(id="bottom-app-container"):
|
|
yield ChatInput(
|
|
cwd=self._cwd,
|
|
image_tracker=self._image_tracker,
|
|
id="input-area",
|
|
)
|
|
|
|
# Status bar at bottom
|
|
yield StatusBar(cwd=self._cwd, id="status-bar")
|
|
|
|
async def on_mount(self) -> None:
|
|
"""Initialize components after mount.
|
|
|
|
Only widget queries and lightweight config go here. Anything that
|
|
would delay the first rendered frame (subprocess calls, heavy
|
|
imports) is deferred to `_post_paint_init` via `call_after_refresh`.
|
|
The optional onboarding setup starts here so its first modal participates
|
|
in the initial TUI render instead of appearing after the first frame.
|
|
"""
|
|
# Move all objects allocated during import/compose into the permanent
|
|
# generation so the cyclic GC skips them during first-paint rendering.
|
|
import gc
|
|
|
|
gc.freeze()
|
|
|
|
chat = self.query_one("#chat", VerticalScroll)
|
|
chat.anchor()
|
|
if is_ascii_mode():
|
|
chat.styles.scrollbar_size_vertical = 0
|
|
|
|
self._status_bar = self.query_one("#status-bar", StatusBar)
|
|
self._chat_input = self.query_one("#input-area", ChatInput)
|
|
|
|
# Apply any skill commands discovered before the widget was mounted
|
|
if self._discovered_skills:
|
|
from deepagents_code.command_registry import (
|
|
SLASH_COMMANDS,
|
|
build_skill_commands,
|
|
)
|
|
|
|
cmds = build_skill_commands(self._discovered_skills)
|
|
merged = list(SLASH_COMMANDS) + cmds
|
|
self._chat_input.update_slash_commands(merged)
|
|
|
|
# Set initial auto-approve state
|
|
if self._auto_approve:
|
|
self._status_bar.set_auto_approve(enabled=True)
|
|
|
|
# Focus the input immediately so the cursor is visible on first paint
|
|
self._chat_input.focus_input()
|
|
|
|
if self._launch_init_requested:
|
|
from deepagents_code.widgets.launch_init import LaunchNameScreen
|
|
|
|
name_result = self._push_screen_result_future(LaunchNameScreen())
|
|
self._ensure_launch_init_task(name_result=name_result)
|
|
|
|
# Pre-import `html.entities` on the main thread before the worker
|
|
# starts. Python 3.14 replaced the global import lock with per-module
|
|
# locks; a worker importing `markdown_it` (which transitively pulls
|
|
# `html.entities`) can race main-thread code looking up `html` *while
|
|
# `html` itself is still being initialized*, raising `KeyError: 'html'`
|
|
# from `_find_and_load_unlocked`.
|
|
import html.entities # noqa: F401
|
|
|
|
# Prewarm heavy imports in a thread while the first frame renders.
|
|
# The user can't type yet, so GIL contention is harmless. By the
|
|
# time _post_paint_init fires its inline imports are dict lookups.
|
|
# Handle is captured so `_await_prewarm_imports` can block on it.
|
|
self._prewarm_worker = self.run_worker(
|
|
asyncio.to_thread(self._prewarm_deferred_imports),
|
|
exclusive=True,
|
|
group="startup-import-prewarm",
|
|
)
|
|
|
|
# Start branch resolution immediately — the thread launches now
|
|
# (during on_mount) so by the time the first frame finishes painting
|
|
# the filesystem probe is already done. _post_paint_init fires the
|
|
# heavier workers (server, model creation) afterward.
|
|
self._startup_task = asyncio.create_task(
|
|
self._resolve_git_branch_and_continue(),
|
|
)
|
|
self._maybe_start_external_event_source()
|
|
|
|
def _maybe_start_external_event_source(self) -> None:
|
|
"""Start the external event listener when explicitly enabled."""
|
|
from deepagents_code._env_vars import (
|
|
EXTERNAL_EVENT_SOCKET,
|
|
EXTERNAL_EVENT_SOCKET_PATH,
|
|
is_env_truthy,
|
|
)
|
|
|
|
if not is_env_truthy(EXTERNAL_EVENT_SOCKET):
|
|
return
|
|
|
|
from deepagents_code.event_bus import UnixSocketEventSource
|
|
|
|
raw_path = os.environ.get(EXTERNAL_EVENT_SOCKET_PATH)
|
|
path = Path(raw_path).expanduser() if raw_path else None
|
|
source = UnixSocketEventSource(path)
|
|
self._external_event_source = source
|
|
self._external_event_source_task = asyncio.create_task(
|
|
self._run_external_event_source(source),
|
|
)
|
|
|
|
async def _run_external_event_source(self, source: EventSource) -> None:
|
|
"""Drive `source` from start to shutdown, surfacing failures to the user.
|
|
|
|
Args:
|
|
source: External event source whose lifecycle this task owns.
|
|
|
|
Raises:
|
|
asyncio.CancelledError: Re-raised when the task is cancelled
|
|
during app shutdown so the cleanup path runs to completion.
|
|
"""
|
|
|
|
async def sink(event: ExternalEvent) -> None: # noqa: RUF029 # protocol requires async callable; post_message is sync
|
|
self.post_message(ExternalInput(event))
|
|
|
|
try:
|
|
await source.start(sink)
|
|
await source.serve_forever()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
logger.exception("External event source failed to start")
|
|
self._external_event_source = None
|
|
with suppress(Exception):
|
|
self.notify(
|
|
f"External event listener failed: {exc}",
|
|
severity="error",
|
|
timeout=8,
|
|
markup=False,
|
|
)
|
|
finally:
|
|
try:
|
|
await source.stop()
|
|
except Exception:
|
|
logger.exception("Error while stopping external event source")
|
|
|
|
async def _refresh_git_branch(self) -> None:
|
|
"""Resolve the current git branch and update the status bar.
|
|
|
|
Reads repository metadata from `self._cwd` inline so the common path is
|
|
just local file I/O. Falls back to a thread-offloaded `git rev-parse`
|
|
only for unusual repository layouts. Swallows all errors — the status
|
|
bar simply stays empty (or keeps its prior value on unexpected failure)
|
|
if git is unavailable.
|
|
"""
|
|
try:
|
|
cwd = self._cwd
|
|
branch = read_git_branch_from_filesystem(cwd)
|
|
if branch is None:
|
|
branch = await asyncio.to_thread(read_git_branch_via_subprocess, cwd)
|
|
if self._status_bar:
|
|
self._status_bar.branch = branch
|
|
except Exception:
|
|
logger.warning("Git branch resolution failed", exc_info=True)
|
|
|
|
async def _refresh_git_branch_subprocess_fallback(self, cwd: str) -> None:
|
|
"""Run the `git rev-parse` fallback off-thread for unusual repo layouts."""
|
|
try:
|
|
branch = await asyncio.to_thread(read_git_branch_via_subprocess, cwd)
|
|
except Exception:
|
|
logger.warning("Git branch subprocess fallback failed", exc_info=True)
|
|
return
|
|
if self._status_bar:
|
|
self._status_bar.branch = branch
|
|
|
|
def _cancel_git_branch_refresh_task(self) -> None:
|
|
"""Cancel and clear any in-flight background branch refresh task."""
|
|
prior_task = self._git_branch_refresh_task
|
|
if prior_task is not None and not prior_task.done():
|
|
prior_task.cancel()
|
|
self._git_branch_refresh_task = None
|
|
|
|
def _schedule_git_branch_refresh(self) -> None:
|
|
"""Refresh the git branch, inline when possible.
|
|
|
|
The filesystem probe is sub-millisecond for the common repo layout, so
|
|
we run it synchronously and only spawn a background task for the
|
|
`git rev-parse` fallback. Keeping the hot path inline avoids an
|
|
event-loop tick plus a reactive watcher hop between a tool exiting and
|
|
the footer updating.
|
|
"""
|
|
if self._exit:
|
|
return
|
|
|
|
cwd = self._cwd
|
|
try:
|
|
branch = read_git_branch_from_filesystem(cwd)
|
|
except Exception:
|
|
logger.warning("Git branch filesystem probe failed", exc_info=True)
|
|
return
|
|
|
|
if branch is not None:
|
|
if self._status_bar:
|
|
self._status_bar.branch = branch
|
|
self._cancel_git_branch_refresh_task()
|
|
return
|
|
|
|
# Unusual repo layout — hop to a thread for `git rev-parse`.
|
|
self._cancel_git_branch_refresh_task()
|
|
refresh_task = asyncio.create_task(
|
|
self._refresh_git_branch_subprocess_fallback(cwd),
|
|
)
|
|
self._git_branch_refresh_task = refresh_task
|
|
|
|
def _finalize_git_branch_refresh(task: asyncio.Task[None]) -> None:
|
|
if self._git_branch_refresh_task is task:
|
|
self._git_branch_refresh_task = None
|
|
try:
|
|
task.result()
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception:
|
|
logger.warning(
|
|
"Background git branch refresh failed unexpectedly",
|
|
exc_info=True,
|
|
)
|
|
|
|
refresh_task.add_done_callback(_finalize_git_branch_refresh)
|
|
|
|
async def _resolve_git_branch_and_continue(self) -> None:
|
|
"""Resolve git branch, then schedule remaining init workers.
|
|
|
|
Launched via `asyncio.create_task()` during `on_mount` so branch
|
|
detection runs concurrently with first-paint rendering.
|
|
`_post_paint_init` is scheduled via `call_after_refresh` regardless
|
|
of whether branch resolution succeeds.
|
|
"""
|
|
try:
|
|
await self._refresh_git_branch()
|
|
finally:
|
|
# Always schedule post-paint init — even if branch resolution
|
|
# fails, the app must still start the server, session, etc.
|
|
self.call_after_refresh(self._post_paint_init)
|
|
|
|
async def _post_paint_init(self) -> None:
|
|
"""Fire background workers for remaining startup work.
|
|
|
|
Everything here is non-blocking: workers and thread-offloaded calls
|
|
so the UI stays responsive.
|
|
"""
|
|
# Create UI adapter unconditionally — it only holds UI callbacks and
|
|
# doesn't depend on the agent. The agent is injected later at
|
|
# execute_task_textual() call time.
|
|
from deepagents_code.textual_adapter import TextualUIAdapter
|
|
|
|
self._ui_adapter = TextualUIAdapter(
|
|
mount_message=self._mount_message,
|
|
update_status=self._update_status,
|
|
request_approval=self._request_approval,
|
|
on_auto_approve_enabled=self._on_auto_approve_enabled,
|
|
set_spinner=self._set_spinner,
|
|
set_active_message=self._set_active_message,
|
|
sync_message_content=self._sync_message_content,
|
|
request_ask_user=self._request_ask_user,
|
|
on_tool_complete=self._schedule_git_branch_refresh,
|
|
)
|
|
# Wire token display callbacks
|
|
self._ui_adapter._on_tokens_update = self._on_tokens_update
|
|
self._ui_adapter._on_tokens_pending = self._show_pending_tokens
|
|
self._ui_adapter._on_tokens_show = self._show_tokens
|
|
|
|
if self._server_startup_deferred:
|
|
await self._mount_deferred_start_notice()
|
|
|
|
# Fire-and-forget workers — none of these block the event loop.
|
|
|
|
# Discover skills first so /skill: autocomplete is ready as early
|
|
# as possible. The heavy filesystem scan runs in a thread.
|
|
self.run_worker(
|
|
self._discover_skills(),
|
|
exclusive=True,
|
|
group="startup-skill-discovery",
|
|
)
|
|
|
|
self.run_worker(self._init_session_state, exclusive=True, group="session-init")
|
|
|
|
# Server startup (model creation + server process)
|
|
if self._server_kwargs is not None and not self._server_startup_deferred:
|
|
self.run_worker(
|
|
self._start_server_background,
|
|
exclusive=True,
|
|
group="server-startup",
|
|
)
|
|
|
|
# Background update check and what's-new banner
|
|
# (opt-out via env var or config.toml [update].check)
|
|
from deepagents_code.update_check import is_update_check_enabled
|
|
|
|
if is_update_check_enabled():
|
|
self.run_worker(
|
|
self._check_for_updates,
|
|
exclusive=True,
|
|
group="startup-update-check",
|
|
)
|
|
self.set_interval(
|
|
_UPDATE_RECHECK_INTERVAL_SECONDS,
|
|
lambda: self.run_worker(
|
|
self._check_for_updates(periodic=True),
|
|
exclusive=True,
|
|
group="periodic-update-check",
|
|
),
|
|
)
|
|
self.run_worker(
|
|
self._show_whats_new,
|
|
exclusive=True,
|
|
group="startup-whats-new",
|
|
)
|
|
|
|
# Prewarm model discovery and profile caches unconditionally so
|
|
# /model opens instantly even before the agent/server is ready.
|
|
self.run_worker(
|
|
self._prewarm_model_caches,
|
|
exclusive=True,
|
|
group="startup-model-prewarm",
|
|
)
|
|
|
|
# Prewarm thread message counts so /threads opens instantly.
|
|
self.run_worker(
|
|
self._prewarm_threads_cache,
|
|
exclusive=True,
|
|
group="startup-thread-prewarm",
|
|
)
|
|
|
|
# Optional tool warnings in a thread (shutil.which is sync I/O)
|
|
self.run_worker(
|
|
self._check_optional_tools_background,
|
|
exclusive=True,
|
|
group="startup-tool-check",
|
|
)
|
|
|
|
# Debug helpers: exercise the notification center and update-modal
|
|
# flows without waiting for real conditions. The two env vars are
|
|
# independent so missing-dep notices can be surfaced without auto-
|
|
# stealing focus into the update modal.
|
|
from deepagents_code._env_vars import DEBUG_NOTIFICATIONS, DEBUG_UPDATE
|
|
|
|
if os.environ.get(DEBUG_NOTIFICATIONS):
|
|
self.call_after_refresh(self._inject_debug_notifications)
|
|
if os.environ.get(DEBUG_UPDATE):
|
|
self.call_after_refresh(self._inject_debug_update)
|
|
|
|
# Session-start sequence (history -> `--startup-cmd` -> initial prompt/
|
|
# skill -> queue drain). When connecting, defer until
|
|
# `on_deep_agents_app_server_ready` fires; otherwise run it now so the
|
|
# non-connecting path (pre-built agent) also honors `--startup-cmd` and
|
|
# serializes startup against user input.
|
|
if not self._connecting and not self._server_startup_deferred:
|
|
self.call_after_refresh(
|
|
lambda: asyncio.create_task(self._run_session_start_sequence()),
|
|
)
|
|
|
|
async def _init_session_state(self) -> None:
|
|
"""Create session state in a thread (imports deepagents_code.sessions)."""
|
|
|
|
def _create() -> TextualSessionState:
|
|
return TextualSessionState(
|
|
auto_approve=self._auto_approve,
|
|
thread_id=self._lc_thread_id,
|
|
)
|
|
|
|
try:
|
|
self._session_state = await asyncio.to_thread(_create)
|
|
except Exception:
|
|
logger.exception("Failed to create session state")
|
|
self.notify(
|
|
"Session initialization failed. Some features may be unavailable.",
|
|
severity="error",
|
|
timeout=10,
|
|
)
|
|
|
|
async def _check_optional_tools_background(self) -> None:
|
|
"""Check for optional tools and register actionable notices.
|
|
|
|
Missing tools are added to the notifications registry. Toasts
|
|
are posted only if no update modal is actually about to open;
|
|
otherwise the modal's `clear_notifications` call would
|
|
immediately drop them and cause visible flicker. Entries remain
|
|
reachable via ctrl+n either way.
|
|
"""
|
|
try:
|
|
from deepagents_code.main import (
|
|
build_missing_tool_notification,
|
|
check_optional_tools,
|
|
)
|
|
from deepagents_code.update_check import is_update_check_enabled
|
|
except ImportError:
|
|
logger.warning(
|
|
"Could not import optional tools checker",
|
|
exc_info=True,
|
|
)
|
|
return
|
|
|
|
try:
|
|
missing = await asyncio.to_thread(check_optional_tools)
|
|
except OSError:
|
|
logger.debug("Failed to check for optional tools", exc_info=True)
|
|
return
|
|
except Exception:
|
|
# Defensive: surface regressions (e.g. future refactors of
|
|
# check_optional_tools raising an unexpected exception type)
|
|
# instead of silently returning.
|
|
logger.warning("Optional-tools check failed unexpectedly", exc_info=True)
|
|
self.notify(
|
|
"Could not check optional tools — see logs.",
|
|
severity="warning",
|
|
timeout=6,
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
if not missing:
|
|
return
|
|
|
|
# Wait for the update check so we know whether the update
|
|
# modal is about to clear any toasts we post. Bounded by a
|
|
# short timeout to avoid blocking indefinitely if PyPI hangs.
|
|
if is_update_check_enabled():
|
|
try:
|
|
await asyncio.wait_for(self._update_check_done.wait(), timeout=5.0)
|
|
except TimeoutError:
|
|
logger.debug("Update check timed out; posting tool toasts anyway")
|
|
|
|
# Suppress only when a modal is actually going to open — not
|
|
# just when an update was detected. A detected-but-throttled
|
|
# update (already notified within CACHE_TTL) does not open the
|
|
# modal, so toasts must still fire or returning users never
|
|
# see the warning.
|
|
suppress_toasts = self._update_modal_pending.is_set()
|
|
|
|
for tool in missing:
|
|
notification = build_missing_tool_notification(tool)
|
|
if suppress_toasts:
|
|
# Register silently; the update modal's dismissal
|
|
# leaves these reachable via ctrl+n (notification center).
|
|
self._notice_registry.add(notification)
|
|
else:
|
|
self._notify_actionable(
|
|
notification,
|
|
severity="warning",
|
|
timeout=15,
|
|
)
|
|
|
|
async def _discover_skills(self) -> bool:
|
|
"""Discover skills, cache metadata, and update autocomplete.
|
|
|
|
Caches the full `ExtendedSkillMetadata` list and pre-resolved
|
|
containment roots so that `/skill:<name>` invocations can skip
|
|
re-walking every skill directory.
|
|
|
|
Runs filesystem I/O in a thread to avoid blocking the event loop.
|
|
|
|
On failure, prior cache is preserved so a transient error (e.g.,
|
|
a single unreadable subdir) doesn't wipe a known-good skill list.
|
|
Callers that need to distinguish "no skills" from "discovery
|
|
failed" can check the return value.
|
|
|
|
Returns:
|
|
`True` on success, `False` if discovery raised. Callers that
|
|
don't care (fire-and-forget startup/agent-switch workers)
|
|
simply ignore the result.
|
|
"""
|
|
from deepagents_code.command_registry import (
|
|
SLASH_COMMANDS,
|
|
build_skill_commands,
|
|
)
|
|
|
|
try:
|
|
# Discovery and prewarm import overlapping parts of the Deep Agents
|
|
# graph in separate workers. Let prewarm finish first so CPython's
|
|
# per-module import locks cannot form a cycle.
|
|
await self._await_prewarm_imports()
|
|
async with self._deepagents_import_lock:
|
|
skills, roots = await asyncio.to_thread(self._discover_skills_and_roots)
|
|
except OSError:
|
|
logger.warning(
|
|
"Filesystem error during skill discovery",
|
|
exc_info=True,
|
|
)
|
|
self.notify(
|
|
"Could not scan skill directories. "
|
|
"Some /skill: commands may be unavailable.",
|
|
severity="warning",
|
|
timeout=6,
|
|
markup=False,
|
|
)
|
|
return False
|
|
except Exception as exc:
|
|
logger.exception("Unexpected error during skill discovery")
|
|
self.notify(
|
|
f"Skill discovery failed unexpectedly ({type(exc).__name__}). "
|
|
"/skill: commands may not work. "
|
|
"Set DEEPAGENTS_CODE_DEBUG=1 for details.",
|
|
severity="warning",
|
|
timeout=8,
|
|
markup=False,
|
|
)
|
|
return False
|
|
|
|
self._discovered_skills = skills
|
|
self._skill_allowed_roots = roots
|
|
if skills:
|
|
skill_commands = build_skill_commands(skills)
|
|
if self._chat_input:
|
|
merged = list(SLASH_COMMANDS) + skill_commands
|
|
self._chat_input.update_slash_commands(merged)
|
|
else:
|
|
logger.debug(
|
|
"Skill discovery completed (%d skills) but chat input "
|
|
"not yet mounted; autocomplete deferred",
|
|
len(skills),
|
|
)
|
|
return True
|
|
|
|
def _discover_skills_and_roots(
|
|
self,
|
|
) -> tuple[list[ExtendedSkillMetadata], list[Path]]:
|
|
"""Discover skills and build pre-resolved containment roots.
|
|
|
|
Shared by `_discover_skills` (startup/reload) and the cache-miss
|
|
fallback in `_invoke_skill` to avoid duplicating the
|
|
`list_skills` call and root-resolution logic.
|
|
|
|
Returns:
|
|
Tuple of `(skill metadata list, pre-resolved containment roots)`.
|
|
"""
|
|
from deepagents_code.skills.invocation import discover_skills_and_roots
|
|
|
|
assistant_id = self._assistant_id or DEFAULT_ASSISTANT_ID
|
|
return discover_skills_and_roots(assistant_id)
|
|
|
|
async def _resolve_resume_thread(self) -> None:
|
|
"""Resolve a `-r` resume intent into a concrete thread ID.
|
|
|
|
Consumes `self._resume_thread_intent` and resolves it into a concrete
|
|
thread ID. Mutates `self._lc_thread_id` and optionally
|
|
`self._assistant_id` / `self._server_kwargs`. Does NOT touch
|
|
`self._default_assistant_id` — a one-off resume should not redefine
|
|
the user's persisted default agent. Falls back to a fresh thread on
|
|
any DB error.
|
|
"""
|
|
from deepagents_code.sessions import (
|
|
find_similar_threads,
|
|
generate_thread_id,
|
|
get_most_recent,
|
|
get_thread_agent,
|
|
thread_exists,
|
|
)
|
|
|
|
try:
|
|
resume = self._resume_thread_intent
|
|
self._resume_thread_intent = None # consumed
|
|
|
|
if not resume:
|
|
return
|
|
|
|
default_agent = DEFAULT_ASSISTANT_ID
|
|
|
|
if resume == "__MOST_RECENT__":
|
|
agent_filter = (
|
|
self._assistant_id if self._assistant_id != default_agent else None
|
|
)
|
|
thread_id = await get_most_recent(agent_filter)
|
|
if thread_id:
|
|
agent_name = await get_thread_agent(thread_id)
|
|
if agent_name:
|
|
self._assistant_id = agent_name
|
|
if self._server_kwargs:
|
|
self._server_kwargs["assistant_id"] = agent_name
|
|
self._lc_thread_id = thread_id
|
|
else:
|
|
self._lc_thread_id = generate_thread_id()
|
|
if agent_filter:
|
|
msg = f"No previous threads for '{agent_filter}', starting new."
|
|
else:
|
|
msg = "No previous threads, starting new."
|
|
self.notify(msg, severity="warning", markup=False)
|
|
elif await thread_exists(resume):
|
|
self._lc_thread_id = resume
|
|
if self._assistant_id == default_agent:
|
|
agent_name = await get_thread_agent(resume)
|
|
if agent_name:
|
|
self._assistant_id = agent_name
|
|
if self._server_kwargs:
|
|
self._server_kwargs["assistant_id"] = agent_name
|
|
else:
|
|
# Thread not found — notify + fall back to new thread
|
|
self._lc_thread_id = generate_thread_id()
|
|
similar = await find_similar_threads(resume)
|
|
hint = f"Thread '{resume}' not found."
|
|
if similar:
|
|
hint += f" Did you mean: {', '.join(str(t) for t in similar)}?"
|
|
self.notify(hint, severity="warning", timeout=6, markup=False)
|
|
except Exception:
|
|
logger.exception("Failed to resolve resume thread %r", resume)
|
|
self._lc_thread_id = generate_thread_id()
|
|
self.notify(
|
|
"Could not look up thread history. Starting new session.",
|
|
severity="warning",
|
|
)
|
|
finally:
|
|
self._resume_thread_resolved_event.set()
|
|
|
|
# Update session state if ready (may still be initializing in a
|
|
# concurrent worker)
|
|
if self._session_state:
|
|
self._session_state.thread_id = self._lc_thread_id
|
|
|
|
async def _start_server_background(self) -> None:
|
|
"""Background worker: resolve resume-thread intent, start server + MCP preload.
|
|
|
|
Also runs deferred model creation if `model_kwargs` was provided,
|
|
so the langchain import + init doesn't block first paint.
|
|
"""
|
|
# Phase 1: Resolve resume thread (if any) before server startup
|
|
if self._resume_thread_intent:
|
|
await self._resolve_resume_thread()
|
|
|
|
# Run deferred model creation. settings.model_name / model_provider
|
|
# are already set eagerly for the status bar display; this call
|
|
# does the heavy langchain import + SDK init and may refine them
|
|
# (e.g., context_limit from the model profile).
|
|
# Persist the user-chosen default so a later bare `deepagents`
|
|
# relaunch brings the user back to it. See
|
|
# `_restart_server_for_agent_swap` for why one-off resumes don't
|
|
# mutate `_default_assistant_id` and why the persisted default
|
|
# is decoupled from the per-session `_assistant_id`.
|
|
# Runs BEFORE deferred model creation so a `ModelConfigError`
|
|
# (e.g., missing API key) doesn't prevent the recent-agent write
|
|
# — the user's intent to use this agent shouldn't depend on
|
|
# whether their credentials happened to be valid this launch.
|
|
if self._default_assistant_id:
|
|
from deepagents_code.model_config import save_recent_agent
|
|
|
|
saved = await asyncio.to_thread(
|
|
save_recent_agent,
|
|
self._default_assistant_id,
|
|
)
|
|
if not saved:
|
|
logger.warning(
|
|
"Could not persist recent agent %r to config at startup",
|
|
self._default_assistant_id,
|
|
)
|
|
# Mirror the visibility of the picker-swap path: if the
|
|
# write fails here, the user has no way to know unless
|
|
# we surface it. Toast severity matches the swap path.
|
|
self.notify(
|
|
"Could not save recent agent to config at startup; "
|
|
"next bare launch will not return to it.",
|
|
severity="warning",
|
|
timeout=6,
|
|
markup=False,
|
|
)
|
|
|
|
if self._model_kwargs is not None:
|
|
# Block on prewarm before re-entering the import graph; see
|
|
# `_await_prewarm_imports` for the deadlock rationale.
|
|
await self._await_prewarm_imports()
|
|
|
|
from deepagents_code.config import create_model
|
|
from deepagents_code.model_config import (
|
|
ModelConfigError,
|
|
save_recent_model,
|
|
touch_recent_model,
|
|
)
|
|
|
|
try:
|
|
async with self._deepagents_import_lock:
|
|
result = create_model(**self._model_kwargs)
|
|
except ModelConfigError as exc:
|
|
self.post_message(self.ServerStartFailed(error=exc))
|
|
return
|
|
result.apply_to_settings()
|
|
resolved_spec = f"{result.provider}:{result.model_name}"
|
|
save_recent_model(resolved_spec)
|
|
touch_recent_model(resolved_spec)
|
|
self._model_kwargs = None # consumed
|
|
|
|
from deepagents_code.server_manager import start_server_and_get_agent
|
|
|
|
coros: list[Any] = [start_server_and_get_agent(**self._server_kwargs)] # type: ignore[arg-type]
|
|
|
|
if self._mcp_preload_kwargs is not None:
|
|
from deepagents_code.main import _preload_session_mcp_server_info
|
|
|
|
coros.append(_preload_session_mcp_server_info(**self._mcp_preload_kwargs))
|
|
|
|
try:
|
|
results = await asyncio.gather(*coros, return_exceptions=True)
|
|
except Exception as exc: # noqa: BLE001 # defensive catch around gather
|
|
self.post_message(self.ServerStartFailed(error=exc))
|
|
return
|
|
|
|
server_result = results[0]
|
|
if isinstance(server_result, BaseException):
|
|
self.post_message(
|
|
self.ServerStartFailed(
|
|
error=server_result
|
|
if isinstance(server_result, Exception)
|
|
else RuntimeError(str(server_result)),
|
|
),
|
|
)
|
|
return
|
|
|
|
agent, server_proc, _ = server_result
|
|
|
|
# Assign immediately so the finally block in run_textual_app can
|
|
# clean up the server even if the ServerReady message is never
|
|
# processed (e.g. user quits during startup).
|
|
self._server_proc = server_proc
|
|
|
|
mcp_info = None
|
|
if len(results) > 1 and not isinstance(results[1], BaseException):
|
|
mcp_info = results[1]
|
|
elif len(results) > 1 and isinstance(results[1], BaseException):
|
|
logger.warning(
|
|
"MCP metadata preload failed: %s",
|
|
results[1],
|
|
exc_info=results[1],
|
|
)
|
|
|
|
self.post_message(
|
|
self.ServerReady(
|
|
agent=agent,
|
|
server_proc=server_proc,
|
|
mcp_server_info=mcp_info,
|
|
),
|
|
)
|
|
|
|
def on_deep_agents_app_server_ready(self, event: ServerReady) -> None:
|
|
"""Handle successful background server startup."""
|
|
self._connecting = False
|
|
self._connection_ready_event.set()
|
|
self._agent = event.agent
|
|
self._server_proc = event.server_proc
|
|
self._mcp_server_info = event.mcp_server_info
|
|
self._mcp_optimistic_original_server_info.clear()
|
|
self._pending_mcp_login_reconnect = False
|
|
self._pending_mcp_disable_reconnect_servers.clear()
|
|
self._sync_pending_mcp_reconnect()
|
|
|
|
# Drop transient failure-state widgets — banner state and the agent
|
|
# response now convey "connected", so the prior error and breadcrumb
|
|
# would just dangle in scrollback.
|
|
for attr in ("_retry_status_widget", "_startup_failure_widget"):
|
|
widget = getattr(self, attr)
|
|
if widget is None:
|
|
continue
|
|
setattr(self, attr, None)
|
|
|
|
async def _drop(w: Widget = widget) -> None:
|
|
# Mount may still be in flight when `ServerReady` arrives;
|
|
# short-circuit on un-attached widgets instead of raising.
|
|
# `NoMatches`/`ScreenStackError` cover later-stage detach
|
|
# races (screen torn down mid-removal).
|
|
if not w.is_attached:
|
|
return
|
|
with suppress(NoMatches, ScreenStackError):
|
|
await w.remove()
|
|
|
|
task = asyncio.create_task(_drop())
|
|
task.add_done_callback(_log_task_exception)
|
|
self._mcp_tool_count = sum(len(s.tools) for s in (event.mcp_server_info or []))
|
|
self._mcp_unauthenticated = sum(
|
|
1 for s in (event.mcp_server_info or []) if s.status == "unauthenticated"
|
|
)
|
|
self._mcp_errored = sum(
|
|
1 for s in (event.mcp_server_info or []) if s.status == "error"
|
|
)
|
|
|
|
# Update welcome banner to show ready state
|
|
try:
|
|
banner = self.query_one("#welcome-banner", WelcomeBanner)
|
|
banner.set_connected(
|
|
self._mcp_tool_count,
|
|
mcp_unauthenticated=self._mcp_unauthenticated,
|
|
mcp_errored=self._mcp_errored,
|
|
)
|
|
except NoMatches:
|
|
logger.warning("Welcome banner not found during server ready transition")
|
|
|
|
# Refresh the status bar model so a successful retry after a failed
|
|
# startup (e.g. `/model` switching providers after `ModelConfigError`)
|
|
# surfaces the now-active model. `StatusBar.on_mount` only runs once,
|
|
# and `_retry_startup_with_model` updates `settings` via
|
|
# `apply_to_settings` without pushing into the widget.
|
|
if self._status_bar is None:
|
|
logger.warning("Status bar not found during server ready transition")
|
|
else:
|
|
from deepagents_code.config import settings
|
|
|
|
provider = settings.model_provider or ""
|
|
model = settings.model_name or ""
|
|
if not provider or not model:
|
|
logger.warning(
|
|
"Settings missing model identity at server ready "
|
|
"(provider=%r, model=%r); status bar will render blank",
|
|
provider,
|
|
model,
|
|
)
|
|
self._status_bar.set_model(provider=provider, model=model)
|
|
|
|
if self._active_mcp_viewer is not None:
|
|
viewer = self._active_mcp_viewer
|
|
|
|
async def _refresh_viewer() -> None:
|
|
# No local `suppress` — the `_log_task_exception` done
|
|
# callback is the single error sink. Silencing here
|
|
# would make that callback dead code (its `task.result()`
|
|
# call could never see a raised exception) and a real
|
|
# `DuplicateIds` / `AttributeError` would leave the
|
|
# viewer stuck on the connecting placeholder with no
|
|
# signal in the logs.
|
|
await viewer.refresh_server_info(self._mcp_server_info or [])
|
|
|
|
task = asyncio.create_task(_refresh_viewer())
|
|
task.add_done_callback(_log_task_exception)
|
|
|
|
# Session-start sequence: load resumed history, run `--startup-cmd`
|
|
# (if any), then dispatch the initial prompt/skill and drain
|
|
# user-typed messages. Sequenced through a single task so the
|
|
# startup command always resolves before the agent sees any user
|
|
# input.
|
|
self.call_after_refresh(
|
|
lambda: asyncio.create_task(self._run_session_start_sequence()),
|
|
)
|
|
|
|
# Drain deferred actions (e.g. model/thread switch queued during connection)
|
|
# if the agent is not actively running. Wrapped in a helper so that
|
|
# exceptions are logged rather than becoming unhandled task errors.
|
|
if self._deferred_actions and not self._agent_running:
|
|
|
|
async def _safe_drain() -> None:
|
|
try:
|
|
await self._maybe_drain_deferred()
|
|
except Exception:
|
|
logger.exception("Unhandled error while draining deferred actions")
|
|
with suppress(Exception):
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
"A deferred action failed during startup. "
|
|
"You may need to retry the operation.",
|
|
),
|
|
)
|
|
|
|
self.call_after_refresh(lambda: asyncio.create_task(_safe_drain()))
|
|
|
|
def on_deep_agents_app_server_start_failed(self, event: ServerStartFailed) -> None:
|
|
"""Handle background server startup failure."""
|
|
from deepagents_code.mcp_tools import MCPConfigError
|
|
from deepagents_code.model_config import (
|
|
MissingCredentialsError,
|
|
MissingProviderPackageError,
|
|
)
|
|
|
|
self._connecting = False
|
|
self._connection_ready_event.set()
|
|
if isinstance(event.error, MCPConfigError):
|
|
# Already carries the path + hint; showing the class name is noise.
|
|
self._server_startup_error = str(event.error)
|
|
else:
|
|
self._server_startup_error = _format_startup_error(event.error)
|
|
|
|
# Stash the provider for the `/model` recovery hint. Reset on every
|
|
# failure so a non-credentials retry-failure clears the prior flag.
|
|
self._server_startup_missing_credentials_provider = (
|
|
event.error.provider
|
|
if isinstance(event.error, MissingCredentialsError)
|
|
else None
|
|
)
|
|
self._server_startup_missing_provider_package = (
|
|
event.error
|
|
if isinstance(event.error, MissingProviderPackageError)
|
|
else None
|
|
)
|
|
logger.error("Server startup failed: %s", event.error, exc_info=event.error)
|
|
|
|
# Drop the banner's connecting spinner — chat surface owns the error.
|
|
try:
|
|
banner = self.query_one("#welcome-banner", WelcomeBanner)
|
|
banner.set_idle()
|
|
except NoMatches:
|
|
logger.warning("Welcome banner not found during server failure transition")
|
|
|
|
# Keep any queued messages and widgets in place — `/model` retry can
|
|
# bring the server up, at which point `_run_session_start_sequence`
|
|
# drains them. Deferred actions (model/thread switches queued during
|
|
# the initial connect) are dropped because the failure invalidates
|
|
# their assumptions; the user can re-issue them after recovery.
|
|
self._deferred_actions.clear()
|
|
|
|
# Failure surfaces only in chat — keeps recovery hint adjacent to the
|
|
# input. Banner is set to idle above to drop the connecting spinner.
|
|
text = f"Server failed to start: {self._server_startup_error}"
|
|
if (
|
|
self._server_startup_missing_credentials_provider is not None
|
|
and self._server_kwargs is not None
|
|
):
|
|
text += (
|
|
"\n\nHint: run `/auth` to add a key for this provider, then "
|
|
"`/model <provider>:<model>` to retry startup. Or pick a "
|
|
"different provider directly with `/model`."
|
|
)
|
|
elif (
|
|
self._server_startup_missing_provider_package is not None
|
|
and self._server_kwargs is not None
|
|
):
|
|
missing = self._server_startup_missing_provider_package
|
|
from deepagents_code.extras_info import extra_for_package
|
|
|
|
extra = extra_for_package(missing.package)
|
|
if extra is not None:
|
|
text += (
|
|
f"\n\nHint: install the package with `/install {extra}`, "
|
|
f"then run `/model {missing.provider}:<model>` to retry. "
|
|
"Or pick a different provider with `/model`."
|
|
)
|
|
else:
|
|
from deepagents_code.update_check import install_package_command
|
|
|
|
try:
|
|
install_cmd = install_package_command(missing.package)
|
|
except ValueError:
|
|
install_hint = f"install the `{missing.package}` package manually"
|
|
else:
|
|
install_hint = f"run `{install_cmd}`"
|
|
text += (
|
|
f"\n\nHint: {install_hint}, then run "
|
|
f"`/model {missing.provider}:<model>` "
|
|
"to retry. Or pick a different provider with `/model`."
|
|
)
|
|
|
|
async def _mount_failure() -> None:
|
|
# Drop any prior failure widget (re-entrant on retry-then-fail).
|
|
prior = self._startup_failure_widget
|
|
self._startup_failure_widget = None
|
|
if prior is not None and prior.is_attached:
|
|
with suppress(NoMatches, ScreenStackError):
|
|
await prior.remove()
|
|
|
|
try:
|
|
messages = self.query_one("#messages", Container)
|
|
except (NoMatches, ScreenStackError):
|
|
return
|
|
if not messages.is_attached:
|
|
return
|
|
|
|
new_widget = ErrorMessage(text)
|
|
# Mount before storing the reference so `ServerReady` racing this
|
|
# await cannot observe a half-mounted widget.
|
|
await self._mount_before_queued(messages, new_widget)
|
|
self._startup_failure_widget = new_widget
|
|
|
|
# Fire-and-forget mount: this is the *only* failure surface, so log
|
|
# any exception loudly via `_log_task_exception`.
|
|
task = asyncio.create_task(_mount_failure())
|
|
task.add_done_callback(_log_task_exception)
|
|
|
|
async def _await_prewarm_imports(self) -> None:
|
|
"""Wait for prewarm imports before re-entering their module graph.
|
|
|
|
Prevents a multi-threaded import deadlock: the prewarm worker runs in
|
|
`asyncio.to_thread`, and any caller that imports `deepagents` or
|
|
LangChain from the event-loop thread while it's still running can
|
|
race on partially-initialized module locks.
|
|
|
|
`asyncio.CancelledError` propagates so app shutdown isn't silently
|
|
absorbed. `WorkerCancelled` and `WorkerFailed` (both `Exception`
|
|
subclasses, distinct from `CancelledError`) are caught: prewarm is a
|
|
cache optimization, so a cancelled or failed worker just means the
|
|
next inline import is a cold load instead of a dict lookup.
|
|
"""
|
|
from textual.worker import WorkerCancelled, WorkerFailed
|
|
|
|
worker = self._prewarm_worker
|
|
if worker is None:
|
|
return
|
|
try:
|
|
await worker.wait()
|
|
except WorkerCancelled:
|
|
# Cancellation is benign here: app shutdown or another exclusive
|
|
# worker in the same group displaced the prewarm. The subsequent
|
|
# inline imports will still succeed — just without the warm-up.
|
|
logger.debug("Import prewarm worker was cancelled", exc_info=True)
|
|
except WorkerFailed:
|
|
# Prewarm body best-efforts third-party imports and already
|
|
# warns; logging at WARNING here surfaces unexpected failures
|
|
# (e.g. a regression that breaks a non-optional import) that
|
|
# the body itself didn't catch.
|
|
logger.warning("Import prewarm worker failed", exc_info=True)
|
|
|
|
@staticmethod
|
|
def _prewarm_deferred_imports() -> None:
|
|
"""Background-load modules deferred from the startup path.
|
|
|
|
Populates `sys.modules` so the first user-triggered inline import
|
|
is a cheap dict lookup instead of a cold module load.
|
|
"""
|
|
# Internal modules moved from top-level to local imports — a failure
|
|
# here indicates a packaging or code bug, not a missing optional dep, so
|
|
# we let the exception propagate (the worker catches it and logs
|
|
# at WARNING). textual_adapter and update_check are included so
|
|
# _post_paint_init's inline imports are dict lookups.
|
|
from deepagents_code.clipboard import (
|
|
copy_selection_to_clipboard, # noqa: F401
|
|
)
|
|
from deepagents_code.command_registry import ALWAYS_IMMEDIATE # noqa: F401
|
|
from deepagents_code.config import settings # noqa: F401
|
|
from deepagents_code.hooks import dispatch_hook # noqa: F401
|
|
from deepagents_code.model_config import ModelSpec # noqa: F401
|
|
from deepagents_code.textual_adapter import TextualUIAdapter # noqa: F401
|
|
from deepagents_code.update_check import is_update_check_enabled # noqa: F401
|
|
|
|
try:
|
|
# Heavy third-party deps deferred from textual_adapter /
|
|
# tool_display — hit on first message send and first tool
|
|
# approval. Best-effort: missing optional deps should not block the
|
|
# TUI from rendering.
|
|
from deepagents.backends import DEFAULT_EXECUTE_TIMEOUT # noqa: F401
|
|
from langchain.agents.middleware.human_in_the_loop import ( # noqa: F401
|
|
ApproveDecision,
|
|
)
|
|
from langchain_core.messages import AIMessage # noqa: F401
|
|
from langgraph.types import Command # noqa: F401
|
|
except Exception:
|
|
logger.warning("Could not prewarm third-party imports", exc_info=True)
|
|
|
|
# Markdown rendering stack — ~170 ms cold (textual._markdown pulls in
|
|
# markdown_it, pygments, linkify_it — 438 modules). Hit on first
|
|
# SkillMessage compose() and first code-fence highlight. Warming
|
|
# here makes the first expand/Ctrl+O instant.
|
|
import markdown_it # noqa: F401
|
|
from pygments.lexers import get_lexer_by_name as _get_lexer
|
|
from textual.widgets import Markdown # noqa: F401
|
|
|
|
# Instantiate the Python lexer to populate Pygments' internal
|
|
# lexer cache (~12 ms cold). Python is the most common fence
|
|
# language in skill bodies.
|
|
_get_lexer("python")
|
|
|
|
# Widgets deferred from app.py module level — a failure here indicates
|
|
# a packaging or code bug (same as the block above), so we let
|
|
# exceptions propagate.
|
|
from deepagents_code.widgets.approval import ApprovalMenu # noqa: F401
|
|
from deepagents_code.widgets.ask_user import AskUserMenu # noqa: F401
|
|
from deepagents_code.widgets.launch_init import LaunchNameScreen # noqa: F401
|
|
from deepagents_code.widgets.model_selector import (
|
|
ModelSelectorScreen, # noqa: F401
|
|
)
|
|
from deepagents_code.widgets.thread_selector import ( # noqa: F401
|
|
DeleteThreadConfirmScreen,
|
|
ThreadSelectorScreen,
|
|
)
|
|
|
|
async def _prewarm_threads_cache(self) -> None: # noqa: PLR6301 # Worker hook kept as instance method
|
|
"""Prewarm thread selector cache without blocking app startup."""
|
|
from deepagents_code.sessions import (
|
|
get_thread_limit,
|
|
prewarm_thread_message_counts,
|
|
)
|
|
|
|
await prewarm_thread_message_counts(limit=get_thread_limit())
|
|
|
|
async def _prewarm_model_caches(self) -> None:
|
|
"""Prewarm model discovery and profile caches without blocking startup."""
|
|
try:
|
|
from deepagents_code.model_config import (
|
|
get_available_models,
|
|
get_model_profiles,
|
|
)
|
|
|
|
await asyncio.to_thread(get_available_models)
|
|
await asyncio.to_thread(
|
|
get_model_profiles,
|
|
cli_override=self._profile_override,
|
|
)
|
|
except Exception:
|
|
logger.warning("Could not prewarm model caches", exc_info=True)
|
|
|
|
async def _check_for_updates(self, *, periodic: bool = False) -> None:
|
|
"""Run the update check and signal completion for downstream waiters.
|
|
|
|
Wraps `_check_for_updates_impl` so `_update_check_done.set()`
|
|
always fires — lets `_check_optional_tools_background` unblock
|
|
after the PyPI round-trip regardless of success, failure, or no-op.
|
|
|
|
Args:
|
|
periodic: Whether this is a quiet in-session recheck.
|
|
"""
|
|
try:
|
|
await self._check_for_updates_impl(periodic=periodic)
|
|
finally:
|
|
# Always signal completion — the optional-tools worker
|
|
# waits on this before deciding whether to post toasts.
|
|
self._update_check_done.set()
|
|
|
|
async def _check_for_updates_impl(self, *, periodic: bool = False) -> None:
|
|
"""Check PyPI for a newer version and either auto-update or queue a modal.
|
|
|
|
Phase 1 contacts PyPI and records the latest version on the app.
|
|
Phase 2 either performs the auto-upgrade (when enabled), or
|
|
registers the actionable notice and schedules the update modal.
|
|
Phase 2 sets `_update_modal_pending` *only* when the modal is
|
|
actually being scheduled; a detected-but-throttled update
|
|
leaves the event clear so missing-dep toasts still fire.
|
|
"""
|
|
# Phase 1: version check (benign failure)
|
|
try:
|
|
from deepagents_code.update_check import (
|
|
is_auto_update_enabled,
|
|
is_update_available,
|
|
upgrade_command,
|
|
)
|
|
|
|
available, latest = await asyncio.to_thread(
|
|
is_update_available,
|
|
bypass_cache=periodic,
|
|
)
|
|
if not available or latest is None:
|
|
return
|
|
|
|
self._update_available = (True, latest)
|
|
except Exception:
|
|
logger.debug("Background update check failed", exc_info=True)
|
|
return
|
|
|
|
# Phase 2: auto-update or register actionable notice
|
|
try:
|
|
from deepagents_code._version import __version__ as cli_version
|
|
|
|
if is_auto_update_enabled():
|
|
from deepagents_code._env_vars import DEBUG_UPDATE
|
|
from deepagents_code.update_check import (
|
|
create_update_log_path,
|
|
perform_upgrade,
|
|
)
|
|
|
|
if os.environ.get(DEBUG_UPDATE):
|
|
self.notify(
|
|
"Skipped update install (debug mode).",
|
|
severity="information",
|
|
timeout=4,
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
log_path = create_update_log_path()
|
|
self.notify(
|
|
f"Updating to v{latest}... Logs: {log_path}",
|
|
severity="information",
|
|
timeout=5,
|
|
markup=False,
|
|
)
|
|
success, output = await perform_upgrade(log_path=log_path)
|
|
if success:
|
|
self.notify(
|
|
f"Updated to v{latest}. Restart to use the new version.",
|
|
severity="information",
|
|
timeout=10,
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Background auto-upgrade to v%s failed. Output:\n%s",
|
|
latest,
|
|
output,
|
|
)
|
|
cmd = upgrade_command()
|
|
snippet = _truncate(output, limit=160) if output else ""
|
|
message = (
|
|
f"Auto-update failed. Run manually: {cmd}\nLog: {log_path}"
|
|
)
|
|
if snippet:
|
|
message = f"{message}\n{snippet}"
|
|
self.notify(
|
|
message,
|
|
severity="warning",
|
|
timeout=15,
|
|
markup=False,
|
|
)
|
|
else:
|
|
from deepagents_code.update_check import (
|
|
format_installed_age_suffix,
|
|
format_release_age_parenthetical,
|
|
mark_update_notified,
|
|
should_notify_update,
|
|
)
|
|
|
|
if not await asyncio.to_thread(should_notify_update, latest):
|
|
return
|
|
|
|
cmd = upgrade_command()
|
|
release_age = await asyncio.to_thread(
|
|
format_release_age_parenthetical,
|
|
latest,
|
|
)
|
|
installed_age = await asyncio.to_thread(
|
|
format_installed_age_suffix,
|
|
cli_version,
|
|
)
|
|
notification = self._build_update_notification(
|
|
latest=latest,
|
|
cli_version=cli_version,
|
|
release_age=release_age,
|
|
installed_age=installed_age,
|
|
upgrade_cmd=cmd,
|
|
)
|
|
if periodic:
|
|
self._notify_actionable(
|
|
notification,
|
|
severity="information",
|
|
timeout=12,
|
|
action_hint="Press ctrl+n to install.",
|
|
)
|
|
await asyncio.to_thread(mark_update_notified, latest)
|
|
return
|
|
# Register without a toast: the dedicated modal is
|
|
# the update's UI, so a parallel toast would be
|
|
# redundant. Registration still makes the entry
|
|
# reachable via ctrl+n if the modal is dismissed.
|
|
self._notice_registry.add(notification)
|
|
await asyncio.to_thread(mark_update_notified, latest)
|
|
# Set *before* scheduling the modal: the optional-tools
|
|
# worker may race with this path, and it gates toast
|
|
# suppression on this event.
|
|
self._update_modal_pending.set()
|
|
self.call_after_refresh(self._open_update_available_modal, notification)
|
|
except Exception:
|
|
logger.warning("Update check/notify failed unexpectedly", exc_info=True)
|
|
if is_auto_update_enabled():
|
|
self.notify(
|
|
"Auto-update failed unexpectedly.",
|
|
severity="warning",
|
|
timeout=10,
|
|
)
|
|
|
|
@staticmethod
|
|
def _build_update_notification(
|
|
*,
|
|
latest: str,
|
|
cli_version: str,
|
|
release_age: str,
|
|
installed_age: str,
|
|
upgrade_cmd: str,
|
|
) -> PendingNotification:
|
|
"""Build the update-available registry entry.
|
|
|
|
Args:
|
|
latest: New version advertised by PyPI.
|
|
cli_version: Currently installed version string.
|
|
release_age: Pre-formatted " (released N days ago)" fragment.
|
|
installed_age: Pre-formatted " (N days old)" fragment.
|
|
upgrade_cmd: Shell command to install the update.
|
|
|
|
Returns:
|
|
Registry entry ready to pass to `_notify_actionable`.
|
|
"""
|
|
body = (
|
|
f"v{latest} is available{release_age}.\n"
|
|
f"Currently installed: {cli_version}{installed_age}.\n"
|
|
"Your session will not be interrupted."
|
|
)
|
|
return PendingNotification(
|
|
key="update:available",
|
|
title="Update available",
|
|
body=body,
|
|
actions=(
|
|
NotificationAction(ActionId.INSTALL, "Install now", primary=True),
|
|
NotificationAction(ActionId.SKIP_ONCE, "Remind me next launch"),
|
|
NotificationAction(ActionId.SKIP_VERSION, "Skip this version"),
|
|
),
|
|
payload=UpdateAvailablePayload(latest=latest, upgrade_cmd=upgrade_cmd),
|
|
)
|
|
|
|
async def _show_whats_new(self) -> None:
|
|
"""Show a 'what's new' banner on the first launch after an upgrade."""
|
|
try:
|
|
from deepagents_code.update_check import should_show_whats_new
|
|
|
|
if not await asyncio.to_thread(should_show_whats_new):
|
|
return
|
|
except Exception:
|
|
logger.debug("What's new check failed", exc_info=True)
|
|
return
|
|
|
|
try:
|
|
from deepagents_code._version import __version__ as cli_version
|
|
from deepagents_code.config import _is_editable_install
|
|
|
|
if await asyncio.to_thread(_is_editable_install):
|
|
heading = f"Now running v{cli_version}"
|
|
else:
|
|
heading = f"Updated to v{cli_version}"
|
|
|
|
await self._mount_message(AppMessage(_build_whats_new_message(heading)))
|
|
except Exception:
|
|
logger.debug("What's new banner display failed", exc_info=True)
|
|
return
|
|
|
|
try:
|
|
from deepagents_code._version import __version__ as cli_version
|
|
from deepagents_code.update_check import mark_version_seen
|
|
|
|
await asyncio.to_thread(mark_version_seen, cli_version)
|
|
except Exception:
|
|
logger.warning("Failed to persist seen-version marker", exc_info=True)
|
|
|
|
async def _handle_update_command(self) -> None:
|
|
"""Handle the `/update` slash command — check for and install updates."""
|
|
await self._mount_message(UserMessage("/update"))
|
|
try:
|
|
from deepagents_code._env_vars import DEBUG_UPDATE
|
|
from deepagents_code._version import __version__ as cli_version
|
|
from deepagents_code.config import _is_editable_install
|
|
from deepagents_code.update_check import (
|
|
format_age_suffix,
|
|
format_installed_age_suffix,
|
|
format_release_age_parenthetical,
|
|
is_update_available,
|
|
perform_upgrade,
|
|
upgrade_command,
|
|
)
|
|
|
|
if await asyncio.to_thread(_is_editable_install):
|
|
age_suffix = await asyncio.to_thread(format_age_suffix, cli_version)
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"Updates are not available for editable installs. "
|
|
f"Currently on v{cli_version}{age_suffix}.",
|
|
),
|
|
)
|
|
return
|
|
|
|
await self._mount_message(AppMessage("Checking for updates..."))
|
|
available, latest = await asyncio.to_thread(
|
|
is_update_available,
|
|
bypass_cache=True,
|
|
)
|
|
if latest is None:
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"Could not determine the latest version. "
|
|
"Check your network and try again.",
|
|
),
|
|
)
|
|
return
|
|
if not available:
|
|
age_suffix = await asyncio.to_thread(format_age_suffix, cli_version)
|
|
await self._mount_message(
|
|
AppMessage(
|
|
f"Already on the latest version (v{cli_version}{age_suffix}).",
|
|
),
|
|
)
|
|
return
|
|
|
|
release_age = await asyncio.to_thread(
|
|
format_release_age_parenthetical,
|
|
latest,
|
|
)
|
|
installed_age = await asyncio.to_thread(
|
|
format_installed_age_suffix,
|
|
cli_version,
|
|
)
|
|
await self._mount_message(
|
|
AppMessage(
|
|
f"Update available: v{latest}{release_age}. "
|
|
f"Currently installed: {cli_version}{installed_age}. "
|
|
"Upgrading...",
|
|
),
|
|
)
|
|
if os.environ.get(DEBUG_UPDATE):
|
|
await self._mount_message(
|
|
AppMessage("Skipped update install (debug mode)."),
|
|
)
|
|
return
|
|
success, output = await perform_upgrade()
|
|
if success:
|
|
self._update_available = (False, None)
|
|
await self._mount_message(
|
|
AppMessage(
|
|
f"Updated to v{latest}. Restart to use the new version."
|
|
),
|
|
)
|
|
else:
|
|
cmd = upgrade_command()
|
|
detail = f": {output[:200]}" if output else ""
|
|
await self._mount_message(
|
|
AppMessage(f"Auto-update failed{detail}\nRun manually: {cmd}"),
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("/update command failed", exc_info=True)
|
|
await self._mount_message(
|
|
ErrorMessage(f"Update failed: {type(exc).__name__}: {exc}"),
|
|
)
|
|
|
|
async def _handle_install_command(self, command: str) -> None:
|
|
"""Handle the `/install <extra>` slash command.
|
|
|
|
Adds an optional extra (e.g. `quickjs`, `daytona`) to the installed
|
|
dcode tool by re-running `uv tool install -U 'deepagents-code[<extra>]'`.
|
|
Refuses unknown extras unless the user passes a `--force` token.
|
|
|
|
Args:
|
|
command: The full slash command line (e.g. `'/install quickjs'`
|
|
or `'/install foo --force'`).
|
|
"""
|
|
parts = command.split()
|
|
force = "--force" in parts[1:]
|
|
extras = [p for p in parts[1:] if not p.startswith("-")]
|
|
if not extras:
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"Usage: /install <extra> [--force]\nExample: /install quickjs",
|
|
),
|
|
)
|
|
return
|
|
if len(extras) > 1:
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"Only one extra may be installed per /install command. "
|
|
f"Got: {', '.join(extras)}",
|
|
),
|
|
)
|
|
return
|
|
extra = extras[0].lower()
|
|
await self._mount_message(UserMessage(command))
|
|
|
|
try:
|
|
from deepagents_code.config import _is_editable_install
|
|
from deepagents_code.extras_info import (
|
|
KNOWN_EXTRAS,
|
|
MODEL_PROVIDER_EXTRAS,
|
|
SANDBOX_EXTRAS,
|
|
)
|
|
from deepagents_code.update_check import (
|
|
create_update_log_path,
|
|
install_extra_command,
|
|
is_valid_extra_name,
|
|
perform_install_extra,
|
|
)
|
|
except ImportError as exc:
|
|
logger.warning("/install command import failed", exc_info=True)
|
|
await self._mount_message(
|
|
ErrorMessage(f"Install failed: {type(exc).__name__}: {exc}"),
|
|
)
|
|
return
|
|
|
|
if not is_valid_extra_name(extra):
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"Invalid extra name. Extra names must be "
|
|
"alphanumeric with `-`, `_`, or `.` (PEP 508).",
|
|
),
|
|
)
|
|
return
|
|
|
|
if await asyncio.to_thread(_is_editable_install):
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"Cannot install extras on editable installs. "
|
|
f"Run `uv sync --extra {extra}` from the "
|
|
"deepagents-code source directory instead.",
|
|
),
|
|
)
|
|
return
|
|
|
|
if extra not in KNOWN_EXTRAS and not force:
|
|
known = ", ".join(sorted(KNOWN_EXTRAS))
|
|
await self._mount_message(
|
|
AppMessage(
|
|
f"'{extra}' is not a known extra.\n"
|
|
f"Known extras: {known}\n\n"
|
|
f"This would run: `{install_extra_command(extra)}`\n"
|
|
f"Re-run with `--force` to install anyway: "
|
|
f"`/install {extra} --force`",
|
|
),
|
|
)
|
|
return
|
|
|
|
log_path = create_update_log_path()
|
|
await self._mount_message(
|
|
AppMessage(f"Installing extra '{extra}'..."),
|
|
)
|
|
manual_cmd = install_extra_command(extra)
|
|
try:
|
|
success, output = await perform_install_extra(extra, log_path=log_path)
|
|
except (OSError, asyncio.CancelledError) as exc:
|
|
logger.warning("/install command failed", exc_info=True)
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
f"Install failed: {type(exc).__name__}: {exc}\n"
|
|
f"Log: {log_path}\n"
|
|
f"Run manually: {manual_cmd}",
|
|
),
|
|
)
|
|
return
|
|
|
|
if not success:
|
|
# Tail the last 200 chars — uv resolver prints the resolved
|
|
# error at the end, not the beginning.
|
|
detail = f": {output[-200:]}" if output else ""
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
f"Install failed{detail}\n"
|
|
f"Log: {log_path}\n"
|
|
f"Run manually: {manual_cmd}",
|
|
),
|
|
)
|
|
return
|
|
|
|
# Model-provider and sandbox extras are imported by the langgraph
|
|
# server subprocess; `/restart` respawns that subprocess and picks
|
|
# them up without exiting the TUI. `quickjs` (and other
|
|
# STANDALONE_EXTRAS) are wired into the parent process at startup
|
|
# (`verify_interpreter_deps` gates `--interpreter`), so a full
|
|
# relaunch is required.
|
|
if extra in MODEL_PROVIDER_EXTRAS or extra in SANDBOX_EXTRAS:
|
|
next_step = "Run `/restart` to load it now, or relaunch dcode."
|
|
else:
|
|
next_step = "Exit and relaunch dcode to use the new dependencies."
|
|
|
|
await self._mount_message(
|
|
AppMessage(f"Installed extra '{extra}'. {next_step}"),
|
|
)
|
|
|
|
async def _handle_version_command(self) -> None:
|
|
"""Handle the `/version` slash command — show versions and update status.
|
|
|
|
The app's release age is served from the cache populated by the
|
|
background update check. The SDK release age is served from its own
|
|
cache; on the first call for a given SDK version (or on a cache
|
|
miss) this triggers a one-off PyPI fetch bounded by a 3s timeout,
|
|
then persists the result so subsequent calls stay local. The
|
|
update-available hint reads `self._update_available`, which
|
|
reflects the last completed background check.
|
|
"""
|
|
from importlib.metadata import (
|
|
PackageNotFoundError,
|
|
version as _pkg_version,
|
|
)
|
|
|
|
lines: list[str] = []
|
|
try:
|
|
from deepagents_code._version import __version__ as cli_version
|
|
from deepagents_code.update_check import format_age_suffix
|
|
|
|
age_suffix = await asyncio.to_thread(format_age_suffix, cli_version)
|
|
lines.append(f"deepagents-code version: {cli_version}{age_suffix}")
|
|
except ImportError:
|
|
logger.debug("deepagents_code._version module not found")
|
|
lines.append("deepagents-code version: unknown")
|
|
except Exception:
|
|
logger.warning("Unexpected error looking up app version", exc_info=True)
|
|
lines.append("deepagents-code version: unknown")
|
|
|
|
try:
|
|
from deepagents_code.update_check import format_sdk_age_suffix
|
|
|
|
sdk_version = _pkg_version("deepagents")
|
|
sdk_age_suffix = await asyncio.to_thread(format_sdk_age_suffix, sdk_version)
|
|
lines.append(f"deepagents (SDK) version: {sdk_version}{sdk_age_suffix}")
|
|
except PackageNotFoundError:
|
|
logger.debug("deepagents SDK package not found in environment")
|
|
lines.append("deepagents (SDK) version: unknown")
|
|
except Exception:
|
|
logger.warning("Unexpected error looking up SDK version", exc_info=True)
|
|
lines.append("deepagents (SDK) version: unknown")
|
|
|
|
available, latest = self._update_available
|
|
if available and latest:
|
|
try:
|
|
from deepagents_code.update_check import upgrade_command
|
|
|
|
cmd = upgrade_command()
|
|
except Exception:
|
|
logger.warning(
|
|
"Could not resolve upgrade command for /version; "
|
|
"falling back to generic upgrade hint",
|
|
exc_info=True,
|
|
)
|
|
from deepagents_code.update_check import FALLBACK_UPGRADE_COMMAND
|
|
|
|
cmd = FALLBACK_UPGRADE_COMMAND
|
|
lines.extend(("", f"Update available: v{latest}. Run: {cmd}"))
|
|
|
|
await self._mount_message(AppMessage("\n".join(lines)))
|
|
|
|
try:
|
|
from deepagents_code.extras_info import (
|
|
format_extras_status,
|
|
get_extras_status,
|
|
)
|
|
|
|
extras_markdown = format_extras_status(get_extras_status())
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to collect optional dependency status",
|
|
exc_info=True,
|
|
)
|
|
extras_markdown = ""
|
|
if extras_markdown:
|
|
await self._mount_message(AppMessage(extras_markdown, markdown=True))
|
|
|
|
async def _handle_auto_update_toggle(self) -> None:
|
|
"""Handle the `/auto-update` slash command — persist toggle immediately."""
|
|
try:
|
|
from deepagents_code.config import _is_editable_install
|
|
from deepagents_code.update_check import (
|
|
is_auto_update_enabled,
|
|
set_auto_update,
|
|
)
|
|
|
|
if await asyncio.to_thread(_is_editable_install):
|
|
self.notify(
|
|
"Auto-updates are not available for editable installs.",
|
|
severity="warning",
|
|
timeout=5,
|
|
)
|
|
return
|
|
|
|
currently_enabled = await asyncio.to_thread(is_auto_update_enabled)
|
|
new_state = not currently_enabled
|
|
await asyncio.to_thread(set_auto_update, new_state)
|
|
label = "enabled" if new_state else "disabled"
|
|
self.notify(
|
|
f"Auto-updates {label}.",
|
|
severity="information",
|
|
timeout=5,
|
|
markup=False,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("/auto-update command failed", exc_info=True)
|
|
self.notify(
|
|
f"Auto-update toggle failed: {type(exc).__name__}: {exc}",
|
|
severity="warning",
|
|
timeout=5,
|
|
markup=False,
|
|
)
|
|
|
|
def on_scroll_up(self, _event: ScrollUp) -> None:
|
|
"""Handle scroll up to check if we need to hydrate older messages."""
|
|
self._check_hydration_needed()
|
|
|
|
def _update_status(self, message: str) -> None:
|
|
"""Update the status bar with a message."""
|
|
if self._status_bar:
|
|
self._status_bar.set_status_message(message)
|
|
|
|
def _update_tokens(self, count: int, *, approximate: bool = False) -> None:
|
|
"""Update the token count in the status bar.
|
|
|
|
Low-level helper — only touches the UI. Callers that also need to
|
|
update the local cache should use `_on_tokens_update` instead.
|
|
|
|
Args:
|
|
count: Total context token count.
|
|
approximate: Append "+" to signal a stale/interrupted count.
|
|
"""
|
|
if self._status_bar:
|
|
self._status_bar.set_tokens(count, approximate=approximate)
|
|
|
|
def _on_tokens_update(self, count: int, *, approximate: bool = False) -> None:
|
|
"""Update the local cache *and* the status bar.
|
|
|
|
This is the callback wired to the adapter's `_on_tokens_update`.
|
|
|
|
Args:
|
|
count: Total context token count to cache and display.
|
|
approximate: Append "+" to signal a stale/interrupted count.
|
|
"""
|
|
self._context_tokens = count
|
|
self._tokens_approximate = approximate
|
|
self._update_tokens(count, approximate=approximate)
|
|
|
|
def _show_tokens(self, *, approximate: bool = False) -> None:
|
|
"""Restore the status bar to the cached token value.
|
|
|
|
Args:
|
|
approximate: Append "+" to signal a stale/interrupted count.
|
|
|
|
This flag is sticky until `_on_tokens_update` receives a fresh
|
|
count from the model.
|
|
"""
|
|
self._tokens_approximate = self._tokens_approximate or approximate
|
|
self._update_tokens(
|
|
self._context_tokens,
|
|
approximate=self._tokens_approximate,
|
|
)
|
|
|
|
def _show_pending_tokens(self) -> None:
|
|
"""Show the unknown token count placeholder during streaming."""
|
|
if self._status_bar:
|
|
self._status_bar.show_pending_tokens()
|
|
|
|
def _check_hydration_needed(self) -> None:
|
|
"""Check if we need to hydrate messages from the store.
|
|
|
|
Called when user scrolls up near the top of visible messages.
|
|
"""
|
|
if not self._message_store.has_messages_above:
|
|
return
|
|
|
|
try:
|
|
chat = self.query_one("#chat", VerticalScroll)
|
|
except NoMatches:
|
|
logger.debug("Skipping hydration check: #chat container not found")
|
|
return
|
|
|
|
scroll_y = chat.scroll_y
|
|
viewport_height = chat.size.height
|
|
|
|
if self._message_store.should_hydrate_above(scroll_y, viewport_height):
|
|
self.call_later(self._hydrate_messages_above)
|
|
|
|
async def _hydrate_messages_above(self) -> None:
|
|
"""Hydrate older messages when user scrolls near the top.
|
|
|
|
This recreates widgets for archived messages and inserts them
|
|
at the top of the messages container.
|
|
"""
|
|
if not self._message_store.has_messages_above:
|
|
return
|
|
|
|
try:
|
|
chat = self.query_one("#chat", VerticalScroll)
|
|
except NoMatches:
|
|
logger.debug("Skipping hydration: #chat not found")
|
|
return
|
|
|
|
try:
|
|
messages_container = self.query_one("#messages", Container)
|
|
except NoMatches:
|
|
logger.debug("Skipping hydration: #messages not found")
|
|
return
|
|
|
|
to_hydrate = self._message_store.get_messages_to_hydrate()
|
|
if not to_hydrate:
|
|
return
|
|
|
|
old_scroll_y = chat.scroll_y
|
|
first_child = (
|
|
messages_container.children[0] if messages_container.children else None
|
|
)
|
|
|
|
# Build widgets in chronological order, then mount in reverse so
|
|
# each is inserted before the previous first_child, resulting in
|
|
# correct chronological order in the DOM.
|
|
hydrated_count = 0
|
|
hydrated_widgets: list[tuple] = [] # (widget, msg_data)
|
|
for msg_data in to_hydrate:
|
|
try:
|
|
widget = msg_data.to_widget()
|
|
hydrated_widgets.append((widget, msg_data))
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to create widget for message %s",
|
|
msg_data.id,
|
|
exc_info=True,
|
|
)
|
|
|
|
for widget, msg_data in reversed(hydrated_widgets):
|
|
try:
|
|
if first_child:
|
|
await messages_container.mount(widget, before=first_child)
|
|
else:
|
|
await messages_container.mount(widget)
|
|
first_child = widget
|
|
hydrated_count += 1
|
|
# Render Markdown content for hydrated assistant messages
|
|
if isinstance(widget, AssistantMessage) and msg_data.content:
|
|
await widget.set_content(msg_data.content)
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to mount hydrated widget %s",
|
|
widget.id,
|
|
exc_info=True,
|
|
)
|
|
|
|
# Only update store for the number we actually mounted
|
|
if hydrated_count > 0:
|
|
self._message_store.mark_hydrated(hydrated_count)
|
|
|
|
# Adjust scroll position to maintain the user's view.
|
|
# Widget heights aren't known until after layout, so we use a
|
|
# heuristic. A more accurate approach would measure actual heights
|
|
# via call_after_refresh.
|
|
estimated_height_per_message = 5 # terminal rows, rough estimate
|
|
added_height = hydrated_count * estimated_height_per_message
|
|
chat.scroll_y = old_scroll_y + added_height
|
|
|
|
async def _mount_before_queued(self, container: Container, widget: Widget) -> None:
|
|
"""Mount a widget in the messages container, before any queued widgets.
|
|
|
|
Queued-message widgets must stay at the bottom of the container so
|
|
they remain visually anchored below the current agent response.
|
|
This helper inserts `widget` just before the first queued widget,
|
|
or appends at the end when the queue is empty.
|
|
|
|
Args:
|
|
container: The `#messages` container to mount into.
|
|
widget: The widget to mount.
|
|
"""
|
|
if not container.is_attached:
|
|
return
|
|
first_queued = self._queued_widgets[0] if self._queued_widgets else None
|
|
if first_queued is not None and first_queued.parent is container:
|
|
try:
|
|
await container.mount(widget, before=first_queued)
|
|
except Exception:
|
|
logger.warning(
|
|
"Stale queued-widget reference; appending at end",
|
|
exc_info=True,
|
|
)
|
|
else:
|
|
return
|
|
await container.mount(widget)
|
|
|
|
def _is_spinner_at_correct_position(self, container: Container) -> bool:
|
|
"""Check whether the loading spinner is already correctly positioned.
|
|
|
|
The spinner should be immediately before the first queued widget, or
|
|
at the very end of the container when the queue is empty.
|
|
|
|
Args:
|
|
container: The `#messages` container.
|
|
|
|
Returns:
|
|
`True` if the spinner is already in the correct position.
|
|
"""
|
|
children = list(container.children)
|
|
if not children or self._loading_widget not in children:
|
|
return False
|
|
|
|
if self._queued_widgets:
|
|
first_queued = self._queued_widgets[0]
|
|
if first_queued not in children:
|
|
return False
|
|
return children.index(self._loading_widget) == (
|
|
children.index(first_queued) - 1
|
|
)
|
|
|
|
return children[-1] == self._loading_widget
|
|
|
|
def sync_terminal_background(self) -> None:
|
|
"""Best-effort sync of terminal default background to the active theme.
|
|
|
|
Custom themes use their stored registry colors; built-in Textual themes
|
|
resolve colors from the active app theme. Terminal write failures are
|
|
logged and swallowed because the OSC background sync is cosmetic.
|
|
|
|
ANSI themes intentionally skip this step so the terminal's native
|
|
background is preserved.
|
|
"""
|
|
if self.theme in {"ansi-dark", "ansi-light"}:
|
|
return
|
|
|
|
from deepagents_code.terminal_escape import set_terminal_background
|
|
|
|
entry = theme.get_registry().get(self.theme)
|
|
colors = (
|
|
entry.colors
|
|
if entry is not None and entry.custom
|
|
else theme.get_theme_colors(self)
|
|
)
|
|
try:
|
|
set_terminal_background(colors.background)
|
|
except Exception:
|
|
# Cosmetic only: must never break app startup or theme changes.
|
|
logger.warning("set_terminal_background raised unexpectedly", exc_info=True)
|
|
|
|
async def _set_spinner(self, status: SpinnerStatus) -> None:
|
|
"""Show, update, or hide the loading spinner.
|
|
|
|
Also drives the terminal's `OSC 9;4` progress indicator, when
|
|
supported, so taskbar / dock / tab badges reflect agent activity while
|
|
the user is in another window.
|
|
|
|
Args:
|
|
status: The spinner status to display, or `None` to hide.
|
|
"""
|
|
from deepagents_code.terminal_escape import (
|
|
TerminalProgressState,
|
|
clear_terminal_progress,
|
|
set_terminal_progress,
|
|
)
|
|
|
|
if status is None:
|
|
# Hide
|
|
if self._loading_widget:
|
|
await self._loading_widget.remove()
|
|
self._loading_widget = None
|
|
try:
|
|
clear_terminal_progress()
|
|
except Exception:
|
|
# Cosmetic only — must never break spinner lifecycle.
|
|
logger.exception("clear_terminal_progress raised unexpectedly")
|
|
return
|
|
|
|
try:
|
|
set_terminal_progress(state=TerminalProgressState.INDETERMINATE)
|
|
except Exception:
|
|
# Cosmetic only — must never break spinner lifecycle.
|
|
logger.exception("set_terminal_progress raised unexpectedly")
|
|
|
|
try:
|
|
messages = self.query_one("#messages", Container)
|
|
except NoMatches:
|
|
# Container was torn down (e.g. shutdown mid-stream). Skip
|
|
# silently so the streaming loop doesn't crash.
|
|
return
|
|
|
|
if self._loading_widget is None:
|
|
# Create new
|
|
self._loading_widget = LoadingWidget(status)
|
|
await self._mount_before_queued(messages, self._loading_widget)
|
|
else:
|
|
# Update existing
|
|
self._loading_widget.set_status(status)
|
|
# Reposition via move_child so elapsed-time and animation state
|
|
# carry through; remove + re-mount would reset both.
|
|
if not self._is_spinner_at_correct_position(messages):
|
|
self._reposition_spinner(messages)
|
|
# NOTE: Don't call anchor() here - it would re-anchor and drag user back
|
|
# to bottom if they've scrolled away during streaming
|
|
|
|
def _reposition_spinner(self, container: Container) -> None:
|
|
"""Move the spinner to its correct position without resetting state.
|
|
|
|
The spinner must sit immediately before the first queued widget, or
|
|
at the very end of the container when no widgets are queued. Using
|
|
`move_child` preserves the widget's internal state (elapsed time,
|
|
animation frame) that a remove + re-mount would reset.
|
|
|
|
Args:
|
|
container: The messages container that hosts the spinner.
|
|
"""
|
|
if self._loading_widget is None:
|
|
return
|
|
if self._loading_widget not in container.children:
|
|
# The caller holds a spinner reference that isn't in this
|
|
# container — the widget was reparented or removed by another
|
|
# code path. Log so the desync is visible instead of silently
|
|
# leaving the spinner in the wrong place.
|
|
logger.debug(
|
|
"Spinner widget not in container children; skipping reposition",
|
|
)
|
|
return
|
|
first_queued = self._queued_widgets[0] if self._queued_widgets else None
|
|
if first_queued is not None and first_queued.parent is container:
|
|
container.move_child(self._loading_widget, before=first_queued)
|
|
return
|
|
non_spinner = [
|
|
child for child in container.children if child is not self._loading_widget
|
|
]
|
|
if non_spinner:
|
|
container.move_child(self._loading_widget, after=non_spinner[-1])
|
|
|
|
async def _request_approval(
|
|
self,
|
|
action_requests: Any, # noqa: ANN401 # ActionRequest uses dynamic typing
|
|
assistant_id: str | None,
|
|
) -> asyncio.Future:
|
|
"""Request user approval inline in the messages area.
|
|
|
|
Mounts ApprovalMenu in the messages area (inline with chat).
|
|
ChatInput stays visible - user can still see it.
|
|
|
|
If another approval is already pending, queue this one.
|
|
|
|
Auto-approves shell commands that are in the configured allow-list.
|
|
|
|
Args:
|
|
action_requests: List of action request dicts to approve
|
|
assistant_id: The assistant ID for display purposes
|
|
|
|
Returns:
|
|
A Future that resolves to the user's decision.
|
|
"""
|
|
from deepagents_code.config import (
|
|
is_shell_command_allowed,
|
|
settings,
|
|
)
|
|
|
|
loop = asyncio.get_running_loop()
|
|
result_future: asyncio.Future = loop.create_future()
|
|
|
|
# Check if ALL actions in the batch are auto-approvable shell commands
|
|
if settings.shell_allow_list and action_requests:
|
|
all_auto_approved = True
|
|
approved_commands = []
|
|
|
|
for req in action_requests:
|
|
if req.get("name") == "execute":
|
|
command = req.get("args", {}).get("command", "")
|
|
if is_shell_command_allowed(command, settings.shell_allow_list):
|
|
approved_commands.append(command)
|
|
else:
|
|
all_auto_approved = False
|
|
break
|
|
else:
|
|
# Non-shell commands need normal approval
|
|
all_auto_approved = False
|
|
break
|
|
|
|
if all_auto_approved and approved_commands:
|
|
# Auto-approve all commands in the batch
|
|
result_future.set_result({"type": "approve"})
|
|
|
|
# Mount system messages showing the auto-approvals
|
|
try:
|
|
messages = self.query_one("#messages", Container)
|
|
for command in approved_commands:
|
|
auto_msg = AppMessage(
|
|
f"✓ Auto-approved shell command (allow-list): {command}",
|
|
)
|
|
await self._mount_before_queued(messages, auto_msg)
|
|
with suppress(NoMatches, ScreenStackError):
|
|
self.query_one("#chat", VerticalScroll).anchor()
|
|
except Exception: # noqa: S110, BLE001 # Resilient auto-message display
|
|
pass # Don't fail if we can't show the message
|
|
|
|
return result_future
|
|
|
|
# If there's already a pending approval, wait for it to complete first
|
|
if self._pending_approval_widget is not None:
|
|
while self._pending_approval_widget is not None: # noqa: ASYNC110 # Simple polling is sufficient here
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Create menu with unique ID to avoid conflicts
|
|
from deepagents_code.widgets.approval import ApprovalMenu
|
|
|
|
unique_id = f"approval-menu-{uuid.uuid4().hex[:8]}"
|
|
menu = ApprovalMenu(action_requests, assistant_id, id=unique_id)
|
|
menu.set_future(result_future)
|
|
|
|
self._pending_approval_widget = menu
|
|
|
|
if self._is_user_typing():
|
|
# Show a placeholder until the user stops typing, then swap in the
|
|
# real ApprovalMenu. This prevents accidental key presses (e.g.
|
|
# 'y', 'n') from triggering approval decisions mid-sentence.
|
|
placeholder = Static(
|
|
"Waiting for typing to finish...",
|
|
classes="approval-placeholder",
|
|
)
|
|
self._approval_placeholder = placeholder
|
|
try:
|
|
messages = self.query_one("#messages", Container)
|
|
await self._mount_before_queued(messages, placeholder)
|
|
self.call_after_refresh(placeholder.scroll_visible)
|
|
except Exception:
|
|
logger.exception("Failed to mount approval placeholder")
|
|
# Placeholder failed — fall back to showing the menu directly
|
|
# so the future is always resolvable.
|
|
self._approval_placeholder = None
|
|
await self._mount_approval_widget(menu, result_future)
|
|
return result_future
|
|
|
|
self.run_worker(
|
|
self._deferred_show_approval(placeholder, menu, result_future),
|
|
exclusive=False,
|
|
)
|
|
else:
|
|
await self._mount_approval_widget(menu, result_future)
|
|
|
|
return result_future
|
|
|
|
async def _mount_approval_widget(
|
|
self,
|
|
menu: ApprovalMenu,
|
|
result_future: asyncio.Future[dict[str, str]],
|
|
) -> None:
|
|
"""Mount the approval menu widget inline in the messages area.
|
|
|
|
If mounting fails, clears `_pending_approval_widget` and propagates
|
|
the exception via `result_future`.
|
|
|
|
Args:
|
|
menu: The `ApprovalMenu` instance to mount.
|
|
result_future: The future to resolve/reject for the caller.
|
|
"""
|
|
try:
|
|
messages = self.query_one("#messages", Container)
|
|
await self._mount_before_queued(messages, menu)
|
|
self.call_after_refresh(menu.scroll_visible)
|
|
self.call_after_refresh(menu.focus)
|
|
except Exception as e:
|
|
logger.exception(
|
|
"Failed to mount approval menu (id=%s) in messages container",
|
|
menu.id,
|
|
)
|
|
self._pending_approval_widget = None
|
|
if not result_future.done():
|
|
result_future.set_exception(e)
|
|
|
|
async def _deferred_show_approval(
|
|
self,
|
|
placeholder: Static,
|
|
menu: ApprovalMenu,
|
|
result_future: asyncio.Future[dict[str, str]],
|
|
) -> None:
|
|
"""Wait until the user is idle, then swap the placeholder for the real menu.
|
|
|
|
Exits early if the placeholder has already been detached (e.g. the
|
|
approval was cancelled while waiting). In that case the future is
|
|
cancelled so the caller is not left hanging.
|
|
|
|
Args:
|
|
placeholder: The temporary placeholder widget currently mounted.
|
|
menu: The `ApprovalMenu` to show once the user stops typing.
|
|
result_future: The future backing this approval flow.
|
|
"""
|
|
deadline = _monotonic() + _DEFERRED_APPROVAL_TIMEOUT_SECONDS
|
|
while self._is_user_typing(): # Simple polling
|
|
if _monotonic() > deadline:
|
|
logger.warning(
|
|
"Timed out waiting for user to stop typing; showing approval now",
|
|
)
|
|
break
|
|
await asyncio.sleep(0.2)
|
|
|
|
# Guard: if the placeholder was already removed (e.g. agent cancelled
|
|
# the approval while we were waiting), clean up and cancel the future.
|
|
if not placeholder.is_attached:
|
|
logger.warning(
|
|
"Approval placeholder detached before menu shown (id=%s)",
|
|
menu.id,
|
|
)
|
|
self._approval_placeholder = None
|
|
self._pending_approval_widget = None
|
|
if not result_future.done():
|
|
result_future.cancel()
|
|
return
|
|
|
|
self._approval_placeholder = None
|
|
try:
|
|
await placeholder.remove()
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to remove approval placeholder during swap",
|
|
exc_info=True,
|
|
)
|
|
await self._mount_approval_widget(menu, result_future)
|
|
|
|
def _on_auto_approve_enabled(self) -> None:
|
|
"""Handle auto-approve being enabled via the HITL approval menu.
|
|
|
|
Called when the user selects "Auto-approve all" from an approval
|
|
dialog. Syncs the auto-approve state across the app flag, status
|
|
bar indicator, and session state so subsequent tool calls skip
|
|
the approval prompt.
|
|
"""
|
|
self._auto_approve = True
|
|
if self._status_bar:
|
|
self._status_bar.set_auto_approve(enabled=True)
|
|
if self._session_state:
|
|
self._session_state.auto_approve = True
|
|
|
|
async def _remove_ask_user_widget( # noqa: PLR6301 # Shared helper used by ask_user event handlers
|
|
self,
|
|
widget: AskUserMenu,
|
|
*,
|
|
context: str,
|
|
) -> None:
|
|
"""Remove an ask_user widget without surfacing cleanup races.
|
|
|
|
Args:
|
|
widget: Ask-user widget instance to remove.
|
|
context: Short context string for diagnostics.
|
|
"""
|
|
try:
|
|
await widget.remove()
|
|
except Exception:
|
|
logger.debug(
|
|
"Failed to remove ask-user widget during %s",
|
|
context,
|
|
exc_info=True,
|
|
)
|
|
|
|
async def _request_ask_user(
|
|
self,
|
|
questions: list[Question],
|
|
) -> asyncio.Future[AskUserWidgetResult]:
|
|
"""Display the ask_user widget and return a Future with user response.
|
|
|
|
Args:
|
|
questions: List of question dicts, each with `question`, `type`,
|
|
and optional `choices` and `required` keys.
|
|
|
|
Returns:
|
|
A Future that resolves to a dict with `'type'` (`'answered'` or
|
|
`'cancelled'`) and, when answered, an `'answers'` list.
|
|
"""
|
|
loop = asyncio.get_running_loop()
|
|
result_future: asyncio.Future[AskUserWidgetResult] = loop.create_future()
|
|
|
|
if self._pending_ask_user_widget is not None:
|
|
deadline = _monotonic() + 30
|
|
while self._pending_ask_user_widget is not None:
|
|
if _monotonic() > deadline:
|
|
logger.error(
|
|
"Timed out waiting for previous ask-user widget to "
|
|
"clear. Forcefully cleaning up.",
|
|
)
|
|
old_widget = self._pending_ask_user_widget
|
|
if old_widget is not None:
|
|
old_widget.action_cancel()
|
|
self._pending_ask_user_widget = None
|
|
await self._remove_ask_user_widget(
|
|
old_widget,
|
|
context="ask-user timeout cleanup",
|
|
)
|
|
break
|
|
await asyncio.sleep(0.1)
|
|
|
|
from deepagents_code.widgets.ask_user import AskUserMenu
|
|
|
|
unique_id = f"ask-user-menu-{uuid.uuid4().hex[:8]}"
|
|
menu = AskUserMenu(questions, id=unique_id)
|
|
menu.set_future(result_future)
|
|
|
|
self._pending_ask_user_widget = menu
|
|
|
|
try:
|
|
messages = self.query_one("#messages", Container)
|
|
await self._mount_before_queued(messages, menu)
|
|
self.call_after_refresh(lambda: self._scroll_ask_user_into_view(menu))
|
|
self.call_after_refresh(menu.focus_active)
|
|
except Exception as e:
|
|
logger.exception(
|
|
"Failed to mount ask-user menu (id=%s)",
|
|
unique_id,
|
|
)
|
|
self._pending_ask_user_widget = None
|
|
if not result_future.done():
|
|
result_future.set_exception(e)
|
|
|
|
return result_future
|
|
|
|
def _scroll_ask_user_into_view(self, menu: AskUserMenu) -> None:
|
|
"""Scroll mounted ask_user prompts into view.
|
|
|
|
Oversized prompts should start at the top of the viewport so the first
|
|
question and menu border are visible, instead of only exposing the
|
|
bottom edge of the widget.
|
|
"""
|
|
chat = self.query_one("#chat", VerticalScroll)
|
|
if menu.outer_size.height > chat.size.height:
|
|
menu.scroll_visible(animate=False, top=True)
|
|
return
|
|
menu.scroll_visible()
|
|
|
|
async def on_ask_user_menu_answered(
|
|
self,
|
|
event: Any, # noqa: ARG002, ANN401
|
|
) -> None:
|
|
"""Handle ask_user menu answers - remove widget and refocus input."""
|
|
if self._pending_ask_user_widget:
|
|
widget = self._pending_ask_user_widget
|
|
self._pending_ask_user_widget = None
|
|
await self._remove_ask_user_widget(widget, context="ask-user answered")
|
|
|
|
if self._chat_input:
|
|
self.call_after_refresh(self._chat_input.focus_input)
|
|
|
|
async def on_ask_user_menu_cancelled(
|
|
self,
|
|
event: Any, # noqa: ARG002, ANN401
|
|
) -> None:
|
|
"""Handle ask_user menu cancellation - remove widget and refocus input."""
|
|
if self._pending_ask_user_widget:
|
|
widget = self._pending_ask_user_widget
|
|
self._pending_ask_user_widget = None
|
|
await self._remove_ask_user_widget(widget, context="ask-user cancelled")
|
|
|
|
if self._chat_input:
|
|
self.call_after_refresh(self._chat_input.focus_input)
|
|
|
|
async def _process_message(self, value: str, mode: InputMode) -> None:
|
|
"""Route a message to the appropriate handler based on mode.
|
|
|
|
Args:
|
|
value: The message text to process.
|
|
mode: The input mode that determines message routing.
|
|
"""
|
|
if mode == "shell_incognito":
|
|
await self._handle_shell_command(
|
|
self._strip_mode_value(value, "!!", "!", mode),
|
|
incognito=True,
|
|
)
|
|
elif mode == "shell":
|
|
await self._handle_shell_command(
|
|
self._strip_mode_value(value, "!", "!!", mode),
|
|
)
|
|
elif mode == "command":
|
|
await self._handle_command(value)
|
|
elif mode == "normal":
|
|
await self._handle_user_message(value)
|
|
else:
|
|
# Fail safe: never default to the agent dispatch path on an
|
|
# unrecognized mode, since that would silently leak `!!`/`!`
|
|
# prefixed text to the LLM if the mode literal is ever wrong.
|
|
logger.error(
|
|
"Unrecognized input mode %r; refusing to forward to agent",
|
|
mode,
|
|
)
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
f"Internal error: unknown input mode {mode!r}. "
|
|
"Message was not sent.",
|
|
),
|
|
)
|
|
|
|
@staticmethod
|
|
def _strip_mode_value(
|
|
value: str,
|
|
prefix: str,
|
|
conflicting_prefix: str,
|
|
mode: InputMode,
|
|
) -> str:
|
|
"""Strip `prefix` from `value`, logging if a wrong prefix was supplied.
|
|
|
|
Three submission paths feed `_process_message`: (1) typed input, where
|
|
the chat input has already stripped the prefix, so `value` does not
|
|
start with `prefix`; (2) re-submission via the queue, where the value
|
|
was re-prepended with `prefix`; and (3) external/programmatic callers,
|
|
which may send either form. `removeprefix` is a no-op for path (1) and
|
|
does the work for paths (2) and (3).
|
|
|
|
A leading `conflicting_prefix` (the sibling shell mode's trigger)
|
|
indicates state-machine drift between the declared `mode` and the
|
|
actual text — for example, mode `"shell_incognito"` paired with a
|
|
value starting with a single `!`. We log for diagnostics but still
|
|
strip `prefix` so the user is not surprised by a sudden refusal; the
|
|
sibling prefix becomes part of the command body and the shell will
|
|
report any resulting error locally.
|
|
|
|
Examples:
|
|
shell_incognito + `"!!ls"` -> `"ls"` (queued submission)
|
|
shell_incognito + `"ls"` -> `"ls"` (typed submission, prefix
|
|
already stripped)
|
|
shell_incognito + `"!ls"` -> `"!ls"` (drift; logs a warning,
|
|
shell sees `!ls`)
|
|
shell + `"!ls"` -> `"ls"`
|
|
shell + `"!!ls"` -> `"!ls"` (drift; logs a warning)
|
|
|
|
Args:
|
|
value: Submitted text expected to match `mode`.
|
|
prefix: Trigger prefix associated with `mode` (e.g. `"!!"` for
|
|
`shell_incognito`, `"!"` for `shell`).
|
|
conflicting_prefix: Sibling-mode prefix whose presence at the
|
|
start of `value` signals drift (e.g. pass `"!"` when
|
|
`prefix="!!"`).
|
|
mode: Input mode for diagnostic messages.
|
|
|
|
Returns:
|
|
`value` with a leading `prefix` removed if present, otherwise
|
|
`value` unchanged.
|
|
"""
|
|
if value.startswith(conflicting_prefix) and not value.startswith(prefix):
|
|
logger.warning(
|
|
"Mode %r received value with conflicting prefix %r",
|
|
mode,
|
|
conflicting_prefix,
|
|
)
|
|
return value.removeprefix(prefix)
|
|
|
|
def _has_initial_submission(self) -> bool:
|
|
"""Return whether startup should auto-submit a prompt or skill."""
|
|
return self._initial_skill is not None or bool(
|
|
self._initial_prompt and self._initial_prompt.strip(),
|
|
)
|
|
|
|
async def _run_session_start_sequence(self) -> None:
|
|
"""Load history, run `--startup-cmd`, then dispatch initial work.
|
|
|
|
Single entry point for the post-connect sequence. Sequencing the
|
|
startup command before any user-facing agent work guarantees the
|
|
agent never observes input until the command has completed.
|
|
"""
|
|
if self._server_startup_deferred:
|
|
return
|
|
|
|
if self._launch_init_requested:
|
|
self._ensure_launch_init_task()
|
|
launch_init_task = self._launch_init_task
|
|
if launch_init_task is not None and not launch_init_task.done():
|
|
self._schedule_session_start_after_launch_init(launch_init_task)
|
|
return
|
|
|
|
self._startup_sequence_running = True
|
|
try:
|
|
should_load_history = bool(self._lc_thread_id and self._agent) and (
|
|
self._resume_thread_intent is not None
|
|
or not self._has_initial_submission()
|
|
)
|
|
if should_load_history:
|
|
await self._load_thread_history()
|
|
|
|
if self._startup_cmd:
|
|
cmd = self._startup_cmd
|
|
# One-shot: clear to avoid re-running on any subsequent server swap.
|
|
self._startup_cmd = None
|
|
await self._run_startup_command(cmd)
|
|
|
|
if self._has_initial_submission():
|
|
await self._submit_initial_submission()
|
|
return
|
|
finally:
|
|
self._startup_sequence_running = False
|
|
|
|
if self._agent_running or self._shell_running:
|
|
return
|
|
|
|
try:
|
|
await self._maybe_drain_deferred()
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to drain deferred actions after startup sequencing",
|
|
)
|
|
with suppress(Exception):
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
"A deferred action failed during startup. "
|
|
"You may need to retry the operation.",
|
|
),
|
|
)
|
|
|
|
if self._pending_messages:
|
|
await self._process_next_from_queue()
|
|
|
|
def _schedule_session_start_after_launch_init(
|
|
self,
|
|
launch_init_task: asyncio.Task[None],
|
|
) -> None:
|
|
"""Resume post-connect startup after onboarding without awaiting it.
|
|
|
|
Args:
|
|
launch_init_task: Active onboarding task.
|
|
"""
|
|
if self._session_start_waiting_for_launch_init:
|
|
return
|
|
|
|
self._session_start_waiting_for_launch_init = True
|
|
|
|
def _resume_when_launch_done(_done: asyncio.Task[None]) -> None:
|
|
self._session_start_waiting_for_launch_init = False
|
|
if self._exit:
|
|
return
|
|
task = asyncio.create_task(self._run_session_start_sequence())
|
|
task.add_done_callback(_log_task_exception)
|
|
|
|
launch_init_task.add_done_callback(_resume_when_launch_done)
|
|
|
|
async def _run_startup_command(self, command: str) -> None:
|
|
"""Execute the `--startup-cmd` and render its output in the transcript.
|
|
|
|
Uses the same worker-backed subprocess path as the interactive `!`
|
|
shell prefix, with an app-style header (since the user did not type
|
|
the command). Non-zero exit is already rendered as an error by
|
|
`_run_shell_task` but does not abort the session.
|
|
|
|
Raises:
|
|
CancelledError: If the worker is cancelled (e.g. Esc/Ctrl+C);
|
|
re-raised so `_run_shell_task`'s finally can clean up.
|
|
"""
|
|
try:
|
|
await self._mount_message(
|
|
AppMessage(
|
|
Content.from_markup("Running startup command: $cmd", cmd=command),
|
|
),
|
|
)
|
|
except Exception:
|
|
logger.warning("Failed to mount startup-command header", exc_info=True)
|
|
|
|
self._shell_running = True
|
|
if self._chat_input:
|
|
self._chat_input.set_cursor_active(active=False)
|
|
|
|
try:
|
|
worker = self.run_worker(self._run_shell_task(command), exclusive=False)
|
|
except Exception:
|
|
# `run_worker` failed synchronously — `_run_shell_task`'s finally
|
|
# never fires, so reset the busy flags here or the UI stays wedged.
|
|
logger.exception("Failed to schedule startup-command worker")
|
|
self._shell_running = False
|
|
self._shell_worker = None
|
|
if self._chat_input:
|
|
self._chat_input.set_cursor_active(active=True)
|
|
with suppress(Exception):
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
"Failed to start startup command; continuing session."
|
|
),
|
|
)
|
|
return
|
|
|
|
self._shell_worker = worker
|
|
try:
|
|
await worker.wait()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
logger.exception("Startup command worker raised unexpectedly")
|
|
|
|
async def _submit_initial_submission(self) -> None:
|
|
"""Submit the startup prompt or skill after the UI is ready."""
|
|
try:
|
|
if self._initial_skill is not None:
|
|
await self._invoke_skill(
|
|
self._initial_skill,
|
|
self._initial_prompt or "",
|
|
)
|
|
return
|
|
if self._initial_prompt and self._initial_prompt.strip():
|
|
await self._handle_user_message(self._initial_prompt)
|
|
except Exception:
|
|
logger.exception("Unhandled error during initial submission")
|
|
with suppress(Exception):
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
"Failed to submit startup prompt. "
|
|
"Try running the command manually in the session.",
|
|
),
|
|
)
|
|
|
|
def _push_screen_result_future(
|
|
self,
|
|
screen: ModalScreen[ScreenResultT],
|
|
) -> asyncio.Future[ScreenResultT | None]:
|
|
"""Push a modal screen and return a future for its dismissal result.
|
|
|
|
Args:
|
|
screen: Modal screen to display.
|
|
|
|
Returns:
|
|
Future completed with the result passed to `dismiss()`.
|
|
"""
|
|
loop = asyncio.get_running_loop()
|
|
result_future: asyncio.Future[ScreenResultT | None] = loop.create_future()
|
|
|
|
def handle_result(result: ScreenResultT | None) -> None:
|
|
if not result_future.done():
|
|
result_future.set_result(result)
|
|
|
|
self.push_screen(screen, handle_result)
|
|
return result_future
|
|
|
|
async def _push_screen_wait(
|
|
self,
|
|
screen: ModalScreen[ScreenResultT],
|
|
) -> ScreenResultT | None:
|
|
"""Push a modal screen and wait for its dismissal result.
|
|
|
|
Args:
|
|
screen: Modal screen to display.
|
|
|
|
Returns:
|
|
The result passed to `dismiss()`.
|
|
"""
|
|
result_future = self._push_screen_result_future(screen)
|
|
return await result_future
|
|
|
|
def _ensure_launch_init_task(
|
|
self,
|
|
*,
|
|
name_result: Awaitable[str | None] | None = None,
|
|
) -> asyncio.Task[None]:
|
|
"""Start the onboarding task if needed.
|
|
|
|
Args:
|
|
name_result: Optional pre-pushed name-screen result. Used during
|
|
app mount so the modal is present before the first frame.
|
|
|
|
Returns:
|
|
The active onboarding task.
|
|
"""
|
|
self._launch_init_requested = False
|
|
task = self._launch_init_task
|
|
if task is not None and not task.done():
|
|
return task
|
|
|
|
if name_result is None:
|
|
task = asyncio.create_task(self._run_launch_init_sequence())
|
|
else:
|
|
task = asyncio.create_task(
|
|
self._run_launch_init_sequence(name_result=name_result),
|
|
)
|
|
self._launch_init_task = task
|
|
|
|
def _finalize_launch_init(done: asyncio.Task[None]) -> None:
|
|
if self._launch_init_task is done:
|
|
self._launch_init_task = None
|
|
_log_task_exception(done)
|
|
|
|
task.add_done_callback(_finalize_launch_init)
|
|
return task
|
|
|
|
async def _run_launch_init_sequence(
|
|
self,
|
|
*,
|
|
name_result: Awaitable[str | None] | None = None,
|
|
) -> None:
|
|
"""Run the onboarding flow."""
|
|
if self._launch_init_running:
|
|
return
|
|
|
|
name_memory_task: asyncio.Task[None] | None = None
|
|
self._launch_init_running = True
|
|
try:
|
|
if name_result is None:
|
|
from deepagents_code.widgets.launch_init import LaunchNameScreen
|
|
|
|
name = await self._push_screen_wait(LaunchNameScreen())
|
|
else:
|
|
name = await name_result
|
|
if name is None:
|
|
await self._finish_launch_init(name=None)
|
|
return
|
|
|
|
if name:
|
|
self._launch_user_name = name
|
|
name_memory_task = asyncio.create_task(
|
|
self._write_launch_name_memory(name),
|
|
)
|
|
|
|
(
|
|
dependency_continued,
|
|
result,
|
|
) = await self._prompt_launch_dependencies_then_model()
|
|
if not dependency_continued:
|
|
await self._await_launch_name_memory(name_memory_task)
|
|
await self._finish_launch_init(name=name)
|
|
return
|
|
|
|
if result is None:
|
|
await self._await_launch_name_memory(name_memory_task)
|
|
await self._finish_launch_init(name=name)
|
|
return
|
|
|
|
model_spec, _provider = result
|
|
if self._connecting:
|
|
# Bound the wait so a stuck server never traps onboarding.
|
|
# Server startup typically completes in seconds; a minute is
|
|
# a generous ceiling that still beats hanging forever.
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._connection_ready_event.wait(),
|
|
timeout=_LAUNCH_INIT_CONNECTION_TIMEOUT_SECONDS,
|
|
)
|
|
except TimeoutError:
|
|
logger.warning(
|
|
"Server connection did not become ready within %ss; "
|
|
"skipping onboarding model switch",
|
|
_LAUNCH_INIT_CONNECTION_TIMEOUT_SECONDS,
|
|
)
|
|
self.notify(
|
|
"Server still starting. Use /model to switch when ready.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
await self._await_launch_name_memory(name_memory_task)
|
|
await self._finish_launch_init(name=name)
|
|
return
|
|
if self._exit:
|
|
await self._await_launch_name_memory(name_memory_task)
|
|
return
|
|
try:
|
|
await self._switch_model(model_spec, announce_unchanged=False)
|
|
except Exception as exc: # surface to user, don't crash onboarding
|
|
logger.warning(
|
|
"Model switch during onboarding failed",
|
|
exc_info=True,
|
|
)
|
|
self.notify(
|
|
f"Could not switch to {model_spec}: {exc}. Use /model to "
|
|
"try again.",
|
|
severity="error",
|
|
markup=False,
|
|
)
|
|
await self._await_launch_name_memory(name_memory_task)
|
|
await self._finish_launch_init(name=name)
|
|
except Exception:
|
|
# Last-resort guard: surface unexpected failures and best-effort
|
|
# mark onboarding complete so the user is not trapped re-running
|
|
# a broken flow on every launch.
|
|
logger.exception("Onboarding sequence failed unexpectedly")
|
|
self.notify(
|
|
"Setup hit an unexpected error. You can configure things "
|
|
"manually with /model and /memory.",
|
|
severity="error",
|
|
markup=False,
|
|
)
|
|
with suppress(Exception):
|
|
await self._await_launch_name_memory(name_memory_task)
|
|
with suppress(Exception):
|
|
await self._mark_onboarding_complete()
|
|
finally:
|
|
self._launch_init_running = False
|
|
if self._chat_input:
|
|
self._chat_input.focus_input()
|
|
|
|
async def _finish_launch_init(self, *, name: str | None) -> None:
|
|
"""Persist onboarding completion and, when given, mount the welcome.
|
|
|
|
Args:
|
|
name: Submitted user name.
|
|
|
|
When `None` (skip path) or empty, the personalized
|
|
welcome message is not mounted.
|
|
"""
|
|
await self._mark_onboarding_complete()
|
|
if name:
|
|
await self._mount_launch_welcome(name)
|
|
|
|
async def _mount_launch_welcome(self, name: str) -> None:
|
|
"""Mount the personalized onboarding welcome message."""
|
|
await self._mount_message(
|
|
AppMessage(Content.from_markup("Welcome, $name.", name=name)),
|
|
)
|
|
|
|
@staticmethod
|
|
def _dispatch_launch_name_hook(name: str, assistant_id: str) -> None:
|
|
"""Fire the onboarding name hook for external integrations.
|
|
|
|
Args:
|
|
name: Submitted user name.
|
|
assistant_id: Agent identifier associated with the submitted name.
|
|
"""
|
|
from deepagents_code.hooks import dispatch_hook_fire_and_forget
|
|
|
|
dispatch_hook_fire_and_forget(
|
|
"user.name.set",
|
|
{
|
|
"name": name,
|
|
"assistant_id": assistant_id,
|
|
},
|
|
)
|
|
|
|
async def _mark_onboarding_complete(self) -> None:
|
|
"""Persist that first-run onboarding should not be shown again.
|
|
|
|
Surfaces a user-visible toast when the marker write fails so the user
|
|
understands why onboarding may reappear on the next launch.
|
|
"""
|
|
from deepagents_code.onboarding import mark_onboarding_complete
|
|
|
|
ok = await asyncio.to_thread(mark_onboarding_complete)
|
|
if not ok:
|
|
self.notify(
|
|
"Could not save onboarding state. Setup may run again next "
|
|
"launch — check permissions on ~/.deepagents/.state/.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
|
|
async def _write_launch_name_memory(self, name: str) -> None:
|
|
"""Persist the optional onboarding name into agent memory.
|
|
|
|
Surfaces a user-visible toast when the memory write fails so the
|
|
promise made in `LaunchNameScreen` ("will be remembered for future
|
|
sessions") does not silently break.
|
|
"""
|
|
from deepagents_code.onboarding import write_onboarding_name_memory
|
|
|
|
await self._resume_thread_resolved_event.wait()
|
|
assistant_id = self._assistant_id or DEFAULT_ASSISTANT_ID
|
|
self._dispatch_launch_name_hook(name, assistant_id)
|
|
ok = await asyncio.to_thread(write_onboarding_name_memory, name, assistant_id)
|
|
if not ok:
|
|
self.notify(
|
|
"Could not save your name to agent memory. Future sessions "
|
|
"may not remember it.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
|
|
@staticmethod
|
|
async def _await_launch_name_memory(
|
|
task: asyncio.Task[None] | None,
|
|
) -> None:
|
|
"""Wait for the optional name-memory write when one is in flight."""
|
|
if task is not None:
|
|
await task
|
|
|
|
@staticmethod
|
|
def _build_launch_dependencies_screen(
|
|
*,
|
|
continue_screen: ModalScreen[Any] | None = None,
|
|
) -> ModalScreen:
|
|
"""Build the onboarding optional-dependency summary screen.
|
|
|
|
Args:
|
|
continue_screen: Optional screen to switch to when continuing.
|
|
|
|
Returns:
|
|
Dependency summary modal.
|
|
"""
|
|
from deepagents_code.widgets.launch_init import LaunchDependenciesScreen
|
|
|
|
return LaunchDependenciesScreen(continue_screen=continue_screen)
|
|
|
|
async def _prompt_launch_dependencies(self) -> bool | None:
|
|
"""Show the onboarding dependency summary and wait for a choice.
|
|
|
|
Returns:
|
|
`True` when the user continues, or `None` when setup is skipped.
|
|
"""
|
|
result = await self._push_screen_wait(self._build_launch_dependencies_screen())
|
|
return result if result is None or isinstance(result, bool) else None
|
|
|
|
async def _prompt_launch_dependencies_then_model(
|
|
self,
|
|
) -> tuple[bool, tuple[str, str] | None]:
|
|
"""Show dependencies, then replace that modal with model selection.
|
|
|
|
Returns:
|
|
A tuple where the first value indicates whether the user continued
|
|
past the dependency screen, and the second is the selected model
|
|
result when one was chosen.
|
|
"""
|
|
loop = asyncio.get_running_loop()
|
|
result_future: asyncio.Future[tuple[bool, tuple[str, str] | None]] = (
|
|
loop.create_future()
|
|
)
|
|
|
|
def finish(result: tuple[bool, tuple[str, str] | None]) -> None:
|
|
if not result_future.done():
|
|
result_future.set_result(result)
|
|
|
|
def handle_model(result: tuple[str, str] | None) -> None:
|
|
finish((True, result))
|
|
|
|
model_screen = self._build_model_selector_screen(
|
|
curated=True,
|
|
result_callback=handle_model,
|
|
)
|
|
dependency_screen = self._build_launch_dependencies_screen(
|
|
continue_screen=model_screen,
|
|
)
|
|
|
|
def handle_dependencies(result: bool | None) -> None:
|
|
if result is None:
|
|
finish((False, None))
|
|
elif result is True:
|
|
finish((True, None))
|
|
|
|
self.push_screen(dependency_screen, handle_dependencies)
|
|
return await result_future
|
|
|
|
def _can_bypass_queue(self, value: str) -> bool:
|
|
"""Check if a slash command can skip the message queue.
|
|
|
|
Args:
|
|
value: The lowered, stripped command string (e.g. `/model`).
|
|
|
|
Returns:
|
|
`True` if the command should bypass the busy-state queue.
|
|
"""
|
|
from deepagents_code.command_registry import (
|
|
BYPASS_WHEN_CONNECTING,
|
|
IMMEDIATE_UI,
|
|
SIDE_EFFECT_FREE,
|
|
)
|
|
|
|
cmd = value.split(maxsplit=1)[0] if value else ""
|
|
if cmd in BYPASS_WHEN_CONNECTING:
|
|
return self._connecting and not (self._agent_running or self._shell_running)
|
|
if cmd in IMMEDIATE_UI:
|
|
# Only bare form (no args) bypasses — /model opens selector,
|
|
# /model <name> does a direct switch that shouldn't race with agent.
|
|
return value == cmd
|
|
return cmd in SIDE_EFFECT_FREE
|
|
|
|
async def _submit_input(
|
|
self,
|
|
value: str,
|
|
mode: InputMode,
|
|
*,
|
|
force_bypass: bool = False,
|
|
) -> None:
|
|
"""Submit input, fast-pathing always-immediate commands.
|
|
|
|
For commands in `ALWAYS_IMMEDIATE` (or whenever `force_bypass` is set
|
|
by an external caller), the value is processed directly. Otherwise
|
|
the standard queue and per-tier bypass policy applies.
|
|
|
|
Args:
|
|
value: Raw text submitted by the user or external source.
|
|
mode: Input routing mode.
|
|
force_bypass: When `True`, skip queueing and process the value
|
|
immediately. External callers use this to mirror the
|
|
`ALWAYS_IMMEDIATE` fast path for commands they classify as
|
|
urgent.
|
|
"""
|
|
from deepagents_code.command_registry import (
|
|
ALWAYS_IMMEDIATE,
|
|
HIDDEN_COMMANDS,
|
|
)
|
|
|
|
# Hidden commands are recovery / power-user escape hatches and
|
|
# must work even when the app is busy or wedged — treat them as
|
|
# always-immediate.
|
|
always_bypass = ALWAYS_IMMEDIATE | HIDDEN_COMMANDS
|
|
|
|
if force_bypass or (
|
|
mode == "command" and value.lower().strip() in always_bypass
|
|
):
|
|
await self._process_message(value, mode)
|
|
return
|
|
|
|
# Prevent message handling while a thread switch is in-flight.
|
|
if self._thread_switching:
|
|
self.notify(
|
|
"Thread switch in progress. Please wait.",
|
|
severity="warning",
|
|
timeout=3,
|
|
)
|
|
return
|
|
|
|
# If the app is busy, still sequencing startup work, or holding a
|
|
# post-failure recovery state (server hasn't come up yet but `/model`
|
|
# retry is still possible), enqueue instead of processing. Messages
|
|
# queued in any of these states are drained once the session reaches
|
|
# its first stable idle/running state.
|
|
if (
|
|
self._agent_running
|
|
or self._shell_running
|
|
or self._connecting
|
|
or self._startup_sequence_running
|
|
or self._server_startup_error is not None
|
|
):
|
|
if mode == "command" and self._can_bypass_queue(value.lower().strip()):
|
|
await self._process_message(value, mode)
|
|
return
|
|
self._pending_messages.append(QueuedMessage(text=value, mode=mode))
|
|
queued_widget = QueuedUserMessage(value)
|
|
self._queued_widgets.append(queued_widget)
|
|
await self._mount_message(queued_widget)
|
|
if self._connecting:
|
|
with suppress(NoMatches):
|
|
self.query_one(
|
|
"#welcome-banner",
|
|
WelcomeBanner,
|
|
).reveal_connecting_footer()
|
|
return
|
|
|
|
await self._process_message(value, mode)
|
|
|
|
async def on_chat_input_submitted(self, event: ChatInput.Submitted) -> None:
|
|
"""Handle submitted input from ChatInput widget."""
|
|
value = event.value
|
|
mode: InputMode = event.mode # type: ignore[assignment] # Textual event mode is str at type level but InputMode at runtime
|
|
|
|
# Reset quit pending state on any input
|
|
self._quit_pending = False
|
|
|
|
from deepagents_code.hooks import dispatch_hook
|
|
|
|
await dispatch_hook("user.prompt", {})
|
|
|
|
await self._submit_input(value, mode)
|
|
|
|
async def on_external_input(self, event: ExternalInput) -> None:
|
|
"""Route external prompt and command events through the app queue.
|
|
|
|
Honors `event.bypass`: when an external caller supplies any tier
|
|
other than `QUEUED`, the event skips the queue regardless of normal
|
|
per-command policy. This is the documented escape hatch for
|
|
scripted callers that need to inject high-priority work.
|
|
"""
|
|
from deepagents_code.command_registry import BypassTier
|
|
|
|
external = event.event
|
|
if external.kind == "signal":
|
|
await self._handle_external_signal(external.payload)
|
|
return
|
|
|
|
mode: InputMode = "command" if external.kind == "command" else "normal"
|
|
force_bypass = external.bypass is not BypassTier.QUEUED
|
|
await self._submit_input(external.payload, mode, force_bypass=force_bypass)
|
|
|
|
async def _handle_external_signal(self, payload: str) -> None:
|
|
"""Dispatch an external signal payload to the corresponding action.
|
|
|
|
The wire-protocol decoder rejects unknown signal names before they
|
|
reach this method, so the `else` branch only fires when callers
|
|
construct an `ExternalEvent` directly with an unvalidated payload.
|
|
"""
|
|
signal_name = payload.strip().lower()
|
|
if signal_name == "interrupt":
|
|
self.action_interrupt()
|
|
elif signal_name == "force-clear":
|
|
await self._submit_input("/force-clear", "command", force_bypass=True)
|
|
else:
|
|
logger.warning("Ignoring unknown external signal %r", payload)
|
|
|
|
def on_chat_input_mode_changed(self, event: ChatInput.ModeChanged) -> None:
|
|
"""Update status bar when input mode changes."""
|
|
if self._status_bar:
|
|
self._status_bar.set_mode(event.mode)
|
|
|
|
def on_chat_input_typing(
|
|
self,
|
|
event: ChatInput.Typing, # noqa: ARG002 # Textual event handler signature
|
|
) -> None:
|
|
"""Record the most recent keystroke time for typing-aware approval deferral."""
|
|
self._last_typed_at = _monotonic()
|
|
|
|
def _is_user_typing(self) -> bool:
|
|
"""Return whether the user typed recently (within the idle threshold).
|
|
|
|
Returns:
|
|
`True` if the last recorded typing event occurred within the last
|
|
`_TYPING_IDLE_THRESHOLD_SECONDS` seconds, `False` otherwise.
|
|
"""
|
|
if self._last_typed_at is None:
|
|
return False
|
|
return (_monotonic() - self._last_typed_at) < _TYPING_IDLE_THRESHOLD_SECONDS
|
|
|
|
async def on_approval_menu_decided(
|
|
self,
|
|
event: Any, # noqa: ARG002, ANN401 # Textual event handler signature
|
|
) -> None:
|
|
"""Handle approval menu decision - remove from messages and refocus input."""
|
|
# Defensively remove any lingering placeholder (should already be gone
|
|
# once the deferred worker swaps it, but guard against edge cases).
|
|
if self._approval_placeholder is not None:
|
|
if self._approval_placeholder.is_attached:
|
|
try:
|
|
await self._approval_placeholder.remove()
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to remove approval placeholder during cleanup",
|
|
exc_info=True,
|
|
)
|
|
self._approval_placeholder = None
|
|
|
|
# Remove ApprovalMenu using stored reference
|
|
if self._pending_approval_widget:
|
|
await self._pending_approval_widget.remove()
|
|
self._pending_approval_widget = None
|
|
|
|
# Refocus the chat input
|
|
if self._chat_input:
|
|
self.call_after_refresh(self._chat_input.focus_input)
|
|
|
|
async def _handle_shell_command(
|
|
self,
|
|
command: str,
|
|
*,
|
|
incognito: bool = False,
|
|
) -> None:
|
|
"""Handle a shell command (! prefix).
|
|
|
|
Thin dispatcher that mounts the user message and spawns a worker
|
|
so the event loop stays free for key events (Esc/Ctrl+C).
|
|
|
|
Args:
|
|
command: The shell command to execute.
|
|
incognito: Whether the command/output should remain local-only.
|
|
"""
|
|
if not incognito:
|
|
await self._mount_message(UserMessage(f"!{command}"))
|
|
self._shell_running = True
|
|
|
|
if self._chat_input:
|
|
self._chat_input.set_cursor_active(active=False)
|
|
|
|
self._shell_worker = self.run_worker(
|
|
self._run_shell_task(command, incognito=incognito),
|
|
exclusive=False,
|
|
)
|
|
|
|
async def _run_shell_task(self, command: str, *, incognito: bool = False) -> None:
|
|
"""Run a shell command in a background worker.
|
|
|
|
This mirrors `_run_agent_task`: running in a worker keeps the event
|
|
loop free so Esc/Ctrl+C can cancel the worker -> raise
|
|
`CancelledError` -> kill the process.
|
|
|
|
Args:
|
|
command: The shell command to execute.
|
|
incognito: Whether the command/output should remain local-only.
|
|
|
|
Raises:
|
|
CancelledError: If the command is interrupted by the user.
|
|
"""
|
|
refresh_started = False
|
|
try:
|
|
proc = await asyncio.create_subprocess_shell(
|
|
command,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
cwd=self._cwd,
|
|
start_new_session=(sys.platform != "win32"),
|
|
)
|
|
self._shell_process = proc
|
|
|
|
try:
|
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
proc.communicate(),
|
|
timeout=60,
|
|
)
|
|
except TimeoutError:
|
|
await self._kill_shell_process()
|
|
await self._mount_message(ErrorMessage("Command timed out (60s limit)"))
|
|
return
|
|
except asyncio.CancelledError:
|
|
await self._kill_shell_process()
|
|
raise
|
|
|
|
# Start branch refresh as soon as the shell exits so it can overlap
|
|
# with output rendering instead of trailing it.
|
|
self._schedule_git_branch_refresh()
|
|
refresh_started = True
|
|
|
|
output = (stdout_bytes or b"").decode(errors="replace").strip()
|
|
stderr_text = (stderr_bytes or b"").decode(errors="replace").strip()
|
|
if stderr_text:
|
|
output += f"\n[stderr]\n{stderr_text}"
|
|
|
|
if output:
|
|
if incognito:
|
|
await self._mount_message(
|
|
AppMessage(f"```\n{output}\n```", markdown=True),
|
|
)
|
|
else:
|
|
msg = AssistantMessage(f"```\n{output}\n```")
|
|
await self._mount_message(msg)
|
|
await msg.write_initial_content()
|
|
else:
|
|
await self._mount_message(AppMessage("Command completed (no output)"))
|
|
|
|
if proc.returncode and proc.returncode != 0:
|
|
await self._mount_message(ErrorMessage(f"Exit code: {proc.returncode}"))
|
|
|
|
# Anchor to bottom so shell output stays visible
|
|
with suppress(NoMatches, ScreenStackError):
|
|
self.query_one("#chat", VerticalScroll).anchor()
|
|
|
|
except OSError as e:
|
|
logger.exception("Failed to execute shell command: %s", command)
|
|
err_msg = f"Failed to run command: {e}"
|
|
await self._mount_message(ErrorMessage(err_msg))
|
|
except Exception:
|
|
# Defense in depth: a crash between subprocess read and
|
|
# `_mount_message` could leave the user with no signal that the
|
|
# command ran at all (privacy-sensitive in the incognito path).
|
|
# Surface a local-only error and re-raise so the worker layer
|
|
# records the failure.
|
|
logger.exception(
|
|
"Shell task crashed (incognito=%s): %s",
|
|
incognito,
|
|
command,
|
|
)
|
|
with suppress(Exception):
|
|
await self._mount_message(
|
|
ErrorMessage("Shell command crashed; see logs."),
|
|
)
|
|
raise
|
|
finally:
|
|
await self._cleanup_shell_task(refresh_git_branch=not refresh_started)
|
|
|
|
async def _cleanup_shell_task(self, *, refresh_git_branch: bool = True) -> None:
|
|
"""Clean up after shell command task completes or is cancelled.
|
|
|
|
Args:
|
|
refresh_git_branch: Whether to schedule a footer branch refresh
|
|
during cleanup. Successful shell runs can launch this earlier
|
|
so refresh overlaps with output rendering.
|
|
"""
|
|
was_interrupted = self._shell_process is not None and (
|
|
self._shell_worker is not None and self._shell_worker.is_cancelled
|
|
)
|
|
self._shell_process = None
|
|
self._shell_running = False
|
|
self._shell_worker = None
|
|
if was_interrupted:
|
|
await self._mount_message(AppMessage("Command interrupted"))
|
|
if self._chat_input:
|
|
self._chat_input.set_cursor_active(active=True)
|
|
if refresh_git_branch:
|
|
# A `!` command may have changed git state (e.g. `git checkout`);
|
|
# re-resolve so the footer reflects the new branch.
|
|
self._schedule_git_branch_refresh()
|
|
try:
|
|
await self._maybe_drain_deferred()
|
|
except Exception:
|
|
logger.exception("Failed to drain deferred actions during shell cleanup")
|
|
with suppress(Exception):
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
"A deferred action failed after task completion. "
|
|
"You may need to retry the operation.",
|
|
),
|
|
)
|
|
if not self._startup_sequence_running:
|
|
await self._process_next_from_queue()
|
|
|
|
async def _kill_shell_process(self) -> None:
|
|
"""Terminate the running shell command process.
|
|
|
|
On POSIX, sends SIGTERM to the entire process group (killing children).
|
|
On Windows, terminates only the root process. No-op if the process has
|
|
already exited. Waits up to 5s for clean shutdown, then escalates
|
|
to SIGKILL.
|
|
"""
|
|
proc = self._shell_process
|
|
if proc is None or proc.returncode is not None:
|
|
return
|
|
|
|
try:
|
|
if sys.platform != "win32":
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
else:
|
|
proc.terminate()
|
|
except ProcessLookupError:
|
|
return
|
|
except OSError:
|
|
logger.warning(
|
|
"Failed to terminate shell process (pid=%s)",
|
|
proc.pid,
|
|
exc_info=True,
|
|
)
|
|
return
|
|
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=5)
|
|
except TimeoutError:
|
|
logger.warning(
|
|
"Shell process (pid=%s) did not exit after SIGTERM; sending SIGKILL",
|
|
proc.pid,
|
|
)
|
|
with suppress(ProcessLookupError, OSError):
|
|
if sys.platform != "win32":
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
else:
|
|
proc.kill()
|
|
with suppress(ProcessLookupError, OSError):
|
|
await proc.wait()
|
|
except (ProcessLookupError, OSError):
|
|
pass
|
|
|
|
async def _open_url_command(self, command: str, cmd: str) -> None:
|
|
"""Open a URL in the browser and display a clickable link.
|
|
|
|
The browser opens immediately regardless of busy state. When the app is
|
|
busy, a queued indicator is shown and the real chat output (user echo
|
|
+ clickable link) replaces it after the current task finishes.
|
|
|
|
Args:
|
|
command: The raw command text (displayed as user message).
|
|
cmd: The normalized slash command used to look up the URL.
|
|
"""
|
|
url = _COMMAND_URLS[cmd]
|
|
webbrowser.open(url)
|
|
|
|
if self._agent_running or self._shell_running:
|
|
queued_widget = QueuedUserMessage(command)
|
|
self._queued_widgets.append(queued_widget)
|
|
await self._mount_message(queued_widget)
|
|
|
|
async def _mount_output() -> None:
|
|
# Remove the ephemeral queued widget, then mount real output.
|
|
if queued_widget in self._queued_widgets:
|
|
self._queued_widgets.remove(queued_widget)
|
|
with suppress(Exception):
|
|
await queued_widget.remove()
|
|
await self._mount_message(UserMessage(command))
|
|
link = Content.styled(url, TStyle(dim=True, italic=True, link=url))
|
|
await self._mount_message(AppMessage(link))
|
|
|
|
# Append directly — no dedup; each URL command gets its own output.
|
|
self._deferred_actions.append(
|
|
DeferredAction(kind="chat_output", execute=_mount_output),
|
|
)
|
|
return
|
|
|
|
await self._mount_message(UserMessage(command))
|
|
link = Content.styled(url, TStyle(dim=True, italic=True, link=url))
|
|
await self._mount_message(AppMessage(link))
|
|
|
|
@staticmethod
|
|
async def _build_thread_message(prefix: str, thread_id: str) -> str | Content:
|
|
"""Build a thread status message, hyperlinking the ID when possible.
|
|
|
|
Attempts to resolve the LangSmith thread URL with a short timeout.
|
|
Falls back to plain text if tracing is not configured or resolution
|
|
fails.
|
|
|
|
Args:
|
|
prefix: Label before the thread ID (e.g. `'Resumed thread'`).
|
|
thread_id: The thread identifier.
|
|
|
|
Returns:
|
|
`Content` with a clickable thread ID, or a plain string.
|
|
"""
|
|
from deepagents_code.config import build_langsmith_thread_url
|
|
|
|
try:
|
|
url = await asyncio.wait_for(
|
|
asyncio.to_thread(build_langsmith_thread_url, thread_id),
|
|
timeout=2.0,
|
|
)
|
|
except (TimeoutError, Exception): # noqa: BLE001 # Resilient non-interactive mode error handling
|
|
url = None
|
|
|
|
if url:
|
|
return Content.assemble(
|
|
f"{prefix}: ",
|
|
(thread_id, TStyle(link=url)),
|
|
)
|
|
return f"{prefix}: {thread_id}"
|
|
|
|
async def _handle_trace_command(self, command: str) -> None:
|
|
"""Open the current thread in LangSmith.
|
|
|
|
Resolves the URL and opens the browser immediately regardless of busy
|
|
state. When the app is busy, chat output (user echo + clickable link)
|
|
is deferred until the current task finishes. Error conditions (no
|
|
session, URL failure, tracing not configured) render immediately
|
|
regardless of busy state.
|
|
|
|
Args:
|
|
command: The raw command text (displayed as user message).
|
|
"""
|
|
from deepagents_code.config import (
|
|
LangSmithApiError,
|
|
LangSmithImportError,
|
|
LangSmithLookupTimeoutError,
|
|
_assemble_langsmith_thread_url,
|
|
fetch_langsmith_project_url_or_raise,
|
|
get_langsmith_project_name,
|
|
)
|
|
|
|
if not self._session_state:
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(AppMessage("No active session."))
|
|
return
|
|
thread_id = self._session_state.thread_id
|
|
try:
|
|
project_name = await asyncio.to_thread(get_langsmith_project_name)
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to resolve LangSmith project name for thread %s",
|
|
thread_id,
|
|
)
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(
|
|
AppMessage("Failed to resolve LangSmith project name."),
|
|
)
|
|
return
|
|
if not project_name:
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"LangSmith tracing is not configured. "
|
|
"Set LANGSMITH_API_KEY and LANGSMITH_TRACING=true to enable.",
|
|
),
|
|
)
|
|
return
|
|
try:
|
|
project_url = await asyncio.to_thread(
|
|
fetch_langsmith_project_url_or_raise, project_name
|
|
)
|
|
except LangSmithImportError:
|
|
logger.warning(
|
|
"langsmith package not installed; cannot resolve thread URL for %s",
|
|
thread_id,
|
|
)
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"The `langsmith` package is not installed. "
|
|
"Install it with "
|
|
"`uv tool install -U deepagents-code --with langsmith` "
|
|
"to enable `/trace`.",
|
|
),
|
|
)
|
|
return
|
|
except LangSmithLookupTimeoutError:
|
|
logger.warning(
|
|
"LangSmith project URL lookup timed out for thread %s",
|
|
thread_id,
|
|
)
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"Could not reach LangSmith to resolve the thread URL. "
|
|
"Check your network connection and try again.",
|
|
),
|
|
)
|
|
return
|
|
except LangSmithApiError as exc:
|
|
logger.warning(
|
|
"LangSmith API call failed while resolving thread URL for %s: %s",
|
|
thread_id,
|
|
exc,
|
|
)
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(
|
|
AppMessage(
|
|
f"LangSmith rejected the project lookup: {exc}. "
|
|
"Verify LANGSMITH_API_KEY and the project name are correct.",
|
|
),
|
|
)
|
|
return
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to fetch LangSmith project URL for thread %s",
|
|
thread_id,
|
|
)
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(
|
|
AppMessage("Failed to resolve LangSmith thread URL."),
|
|
)
|
|
return
|
|
url = _assemble_langsmith_thread_url(project_url, thread_id)
|
|
|
|
def _open_browser() -> None:
|
|
try:
|
|
webbrowser.open(url)
|
|
except Exception:
|
|
logger.debug("Could not open browser for URL: %s", url, exc_info=True)
|
|
|
|
asyncio.get_running_loop().run_in_executor(None, _open_browser)
|
|
|
|
# Defer chat output while a turn is in progress — rendering the user
|
|
# echo + link immediately would splice it into the middle of the
|
|
# streaming assistant response
|
|
if self._agent_running or self._shell_running:
|
|
queued_widget = QueuedUserMessage(command)
|
|
self._queued_widgets.append(queued_widget)
|
|
await self._mount_message(queued_widget)
|
|
|
|
async def _mount_output() -> None:
|
|
if queued_widget in self._queued_widgets:
|
|
self._queued_widgets.remove(queued_widget)
|
|
with suppress(Exception):
|
|
await queued_widget.remove()
|
|
await self._mount_message(UserMessage(command))
|
|
link = Content.styled(url, TStyle(dim=True, italic=True, link=url))
|
|
await self._mount_message(AppMessage(link))
|
|
|
|
# Append directly — no dedup; each /trace invocation gets its own output.
|
|
self._deferred_actions.append(
|
|
DeferredAction(kind="chat_output", execute=_mount_output),
|
|
)
|
|
return
|
|
|
|
await self._mount_message(UserMessage(command))
|
|
link = Content.styled(url, TStyle(dim=True, italic=True, link=url))
|
|
await self._mount_message(AppMessage(link))
|
|
|
|
async def _handle_command(self, command: str) -> None:
|
|
"""Handle a slash command.
|
|
|
|
Args:
|
|
command: The slash command (including /)
|
|
"""
|
|
from deepagents_code.config import newline_shortcut, settings
|
|
|
|
cmd = command.lower().strip()
|
|
|
|
if cmd in {"/quit", "/q"}:
|
|
self.exit()
|
|
elif cmd == "/help":
|
|
await self._mount_message(UserMessage(command))
|
|
help_body = (
|
|
"Commands: /quit, /agents, /auth, /clear, /force-clear, "
|
|
"/copy, /offload, /editor, "
|
|
"/mcp, /model [--model-params JSON] [--default], "
|
|
"/notifications, /reload, /skill:<name>, /remember, "
|
|
"/skill-creator, /theme, /tokens, /threads, /trace, "
|
|
"/update, /auto-update, /install, /changelog, /docs, "
|
|
"/feedback, /help\n\n"
|
|
"Interactive Features:\n"
|
|
" Enter Submit your message\n"
|
|
f" {newline_shortcut():<15} Insert newline\n"
|
|
" Ctrl+X Open prompt in external editor\n"
|
|
" Ctrl+N Review pending notifications\n"
|
|
" Shift+Tab Toggle auto-approve mode\n"
|
|
" @filename Auto-complete files and inject content\n"
|
|
" /command Slash commands (/help, /clear, /quit)\n"
|
|
" !command Run shell commands directly\n"
|
|
" !!command Run shell commands without adding "
|
|
"command/output to model context\n\n"
|
|
"Docs: "
|
|
)
|
|
help_text = Content.assemble(
|
|
(help_body, "dim italic"),
|
|
(DOCS_URL, TStyle(dim=True, italic=True, link=DOCS_URL)),
|
|
)
|
|
await self._mount_message(AppMessage(help_text))
|
|
|
|
elif cmd in {"/changelog", "/docs", "/feedback"}:
|
|
await self._open_url_command(command, cmd)
|
|
elif cmd in {"/version", "/about"}:
|
|
await self._mount_message(UserMessage(command))
|
|
await self._handle_version_command()
|
|
elif cmd == "/agents":
|
|
await self._show_agent_selector()
|
|
elif cmd in {"/clear", "/force-clear"}:
|
|
if cmd == "/force-clear":
|
|
self._force_interrupt_active_work()
|
|
self._pending_messages.clear()
|
|
self._queued_widgets.clear()
|
|
await self._clear_messages()
|
|
self._context_tokens = 0
|
|
self._tokens_approximate = False
|
|
self._update_tokens(0)
|
|
# Clear status message (e.g., "Interrupted" from previous session)
|
|
self._update_status("")
|
|
# Reset thread to start fresh conversation
|
|
if self._session_state:
|
|
new_thread_id = self._session_state.reset_thread()
|
|
self._lc_thread_id = new_thread_id
|
|
try:
|
|
banner = self.query_one("#welcome-banner", WelcomeBanner)
|
|
banner.update_thread_id(new_thread_id)
|
|
except NoMatches:
|
|
pass
|
|
thread_msg_widget = AppMessage(f"Started new thread: {new_thread_id}")
|
|
await self._mount_message(thread_msg_widget)
|
|
self._schedule_thread_message_link(
|
|
thread_msg_widget,
|
|
prefix="Started new thread",
|
|
thread_id=new_thread_id,
|
|
)
|
|
elif cmd == "/copy":
|
|
await self._mount_message(UserMessage(command))
|
|
# Reverse-scan for the newest assistant message that has finished
|
|
# streaming and contains visible text. Track whether we passed over
|
|
# an in-flight stream so we can explain the skip rather than say
|
|
# "No message to copy yet." misleadingly.
|
|
content: str | None = None
|
|
streaming_pending = False
|
|
for message in reversed(self._message_store.get_all_messages()):
|
|
if message.type != MessageType.ASSISTANT:
|
|
continue
|
|
if not message.content.strip():
|
|
continue
|
|
if message.is_streaming:
|
|
streaming_pending = True
|
|
continue
|
|
content = message.content
|
|
break
|
|
|
|
if content is None:
|
|
empty_msg = (
|
|
"Latest assistant message is still streaming;"
|
|
" try again in a moment."
|
|
if streaming_pending
|
|
else "No message to copy yet."
|
|
)
|
|
await self._mount_message(AppMessage(empty_msg))
|
|
return
|
|
|
|
from deepagents_code.clipboard import copy_text_to_clipboard
|
|
|
|
success, error = copy_text_to_clipboard(self, content)
|
|
if success:
|
|
await self._mount_message(
|
|
AppMessage("Copied latest assistant message to clipboard."),
|
|
)
|
|
else:
|
|
fail_msg = (
|
|
f"Failed to copy latest assistant message to clipboard: {error}"
|
|
if error
|
|
else "Failed to copy latest assistant message to clipboard."
|
|
)
|
|
await self._mount_message(AppMessage(fail_msg))
|
|
elif cmd == "/editor":
|
|
await self.action_open_editor()
|
|
elif cmd in {"/offload", "/compact"}:
|
|
await self._mount_message(UserMessage(command))
|
|
await self._handle_offload()
|
|
elif cmd == "/threads":
|
|
await self._show_thread_selector()
|
|
elif cmd == "/trace":
|
|
await self._handle_trace_command(command)
|
|
elif cmd == "/update":
|
|
await self._handle_update_command()
|
|
elif cmd == "/auto-update":
|
|
await self._handle_auto_update_toggle()
|
|
elif cmd == "/install" or cmd.startswith("/install "):
|
|
await self._handle_install_command(command)
|
|
elif cmd == "/tokens":
|
|
await self._mount_message(UserMessage(command))
|
|
if self._context_tokens > 0:
|
|
count = self._context_tokens
|
|
formatted = format_token_count(count)
|
|
|
|
model_name = settings.model_name
|
|
context_limit = settings.model_context_limit
|
|
|
|
if context_limit is not None:
|
|
limit_str = format_token_count(context_limit)
|
|
pct = count / context_limit * 100
|
|
usage = f"{formatted} / {limit_str} tokens ({pct:.0f}%)"
|
|
else:
|
|
usage = f"{formatted} tokens used"
|
|
|
|
msg = f"{usage} \u00b7 {model_name}" if model_name else usage
|
|
|
|
conv_tokens = await self._get_conversation_token_count()
|
|
if conv_tokens is not None:
|
|
overhead = max(0, count - conv_tokens)
|
|
overhead_str = format_token_count(overhead)
|
|
conv_str = format_token_count(conv_tokens)
|
|
|
|
overhead_unit = " tokens" if overhead < 1000 else "" # noqa: PLR2004 # not bothersome, cosmetic
|
|
conv_unit = " tokens" if conv_tokens < 1000 else "" # noqa: PLR2004 # not bothersome, cosmetic
|
|
|
|
msg += (
|
|
f"\n\u251c System prompt + tools: ~{overhead_str}{overhead_unit} (fixed)" # noqa: E501
|
|
f"\n\u2514 Conversation: ~{conv_str}{conv_unit}"
|
|
)
|
|
|
|
await self._mount_message(AppMessage(msg))
|
|
else:
|
|
model_name = settings.model_name
|
|
context_limit = settings.model_context_limit
|
|
|
|
parts: list[str] = ["No token usage yet"]
|
|
if context_limit is not None:
|
|
limit_str = format_token_count(context_limit)
|
|
parts.append(f"{limit_str} token context window")
|
|
if model_name:
|
|
parts.append(model_name)
|
|
|
|
await self._mount_message(AppMessage(" · ".join(parts)))
|
|
elif cmd == "/remember" or cmd.startswith("/remember "):
|
|
# Convenience alias for /skill:remember — shorter and discoverable
|
|
# before skill loading completes.
|
|
if not await self._has_conversation_messages():
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"Nothing to remember yet. Start a conversation first,"
|
|
" then use /remember to capture learnings.",
|
|
),
|
|
)
|
|
return
|
|
args = command.strip()[len("/remember") :].strip()
|
|
rewritten = f"/skill:remember {args}" if args else "/skill:remember"
|
|
await self._handle_skill_command(rewritten)
|
|
elif cmd == "/skill-creator" or cmd.startswith("/skill-creator "):
|
|
# Convenience alias for /skill:skill-creator — shorter and
|
|
# discoverable before skill loading completes.
|
|
args = command.strip()[len("/skill-creator") :].strip()
|
|
rewritten = (
|
|
f"/skill:skill-creator {args}" if args else "/skill:skill-creator"
|
|
)
|
|
await self._handle_skill_command(rewritten)
|
|
elif cmd == "/mcp":
|
|
await self._show_mcp_viewer()
|
|
elif cmd.startswith("/mcp "):
|
|
args = command.strip()[len("/mcp ") :].strip()
|
|
await self._mount_message(UserMessage(command))
|
|
await self._handle_mcp_subcommand(args)
|
|
elif cmd in {"/auth", "/connect"}:
|
|
await self._show_auth_manager()
|
|
elif cmd == "/theme":
|
|
await self._show_theme_selector()
|
|
elif cmd == "/notifications":
|
|
await self._show_notification_settings()
|
|
elif cmd == "/model" or cmd.startswith("/model "):
|
|
model_arg = None
|
|
set_default = False
|
|
extra_kwargs: dict[str, Any] | None = None
|
|
if cmd.startswith("/model "):
|
|
raw_arg = command.strip()[len("/model ") :].strip()
|
|
try:
|
|
raw_arg, extra_kwargs = _extract_model_params_flag(raw_arg)
|
|
except (ValueError, TypeError) as exc:
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(ErrorMessage(str(exc)))
|
|
return
|
|
if raw_arg.startswith("--default"):
|
|
set_default = True
|
|
model_arg = raw_arg[len("--default") :].strip() or None
|
|
else:
|
|
model_arg = raw_arg or None
|
|
|
|
if set_default:
|
|
await self._mount_message(UserMessage(command))
|
|
if extra_kwargs:
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
"--model-params cannot be used with --default. "
|
|
"Model params are applied per-session, not "
|
|
"persisted.",
|
|
),
|
|
)
|
|
elif model_arg == "--clear":
|
|
await self._clear_default_model()
|
|
elif model_arg:
|
|
await self._set_default_model(model_arg)
|
|
else:
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"Usage: /model --default provider:model\n"
|
|
" /model --default --clear",
|
|
),
|
|
)
|
|
elif model_arg:
|
|
# Direct switch: /model claude-sonnet-4-5
|
|
await self._mount_message(UserMessage(command))
|
|
await self._switch_model(model_arg, extra_kwargs=extra_kwargs)
|
|
else:
|
|
await self._show_model_selector(extra_kwargs=extra_kwargs)
|
|
elif cmd == "/reload":
|
|
await self._mount_message(UserMessage(command))
|
|
|
|
# Snapshot pre-reload skill names so the report can show diff.
|
|
old_skill_names = {s["name"] for s in self._discovered_skills}
|
|
|
|
try:
|
|
changes = settings.reload_from_environment()
|
|
|
|
from deepagents_code.model_config import clear_caches
|
|
|
|
clear_caches()
|
|
except (OSError, ValueError):
|
|
logger.exception("Failed to reload configuration")
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"Failed to reload configuration. Check your .env "
|
|
"file and environment variables for syntax errors, "
|
|
"then try again.",
|
|
),
|
|
)
|
|
return
|
|
|
|
# Reload user themes from config.toml and re-register with Textual
|
|
theme_reload_ok = True
|
|
try:
|
|
theme.reload_registry()
|
|
self._register_custom_themes()
|
|
except Exception:
|
|
theme_reload_ok = False
|
|
logger.warning("Failed to reload user themes", exc_info=True)
|
|
|
|
# Re-discover skills so autocomplete reflects any new/removed
|
|
# skills. Run via the same exclusive-group worker used at
|
|
# startup so any in-flight startup discovery is cancelled
|
|
# rather than racing this one, then await its completion so
|
|
# the report can include the diff.
|
|
skill_worker = self.run_worker(
|
|
self._discover_skills(),
|
|
exclusive=True,
|
|
group="startup-skill-discovery",
|
|
)
|
|
await skill_worker.wait()
|
|
discovery_ok = skill_worker.result is True
|
|
new_skill_names = {s["name"] for s in self._discovered_skills}
|
|
added_skills = sorted(new_skill_names - old_skill_names)
|
|
removed_skills = sorted(old_skill_names - new_skill_names)
|
|
|
|
if changes:
|
|
report = "Configuration reloaded. Changes:\n" + "\n".join(
|
|
f" - {change}" for change in changes
|
|
)
|
|
else:
|
|
report = "Configuration reloaded. No changes detected."
|
|
report += "\nModel config caches cleared."
|
|
if theme_reload_ok:
|
|
report += "\nTheme registry reloaded."
|
|
else:
|
|
report += (
|
|
"\nTheme registry reload failed. Check config.toml for errors."
|
|
)
|
|
if not discovery_ok:
|
|
# Diff is meaningless when discovery failed: prior cache
|
|
# was preserved, so old vs. new is identical and
|
|
# `Skills reloaded. No changes detected.` would be a lie.
|
|
report += (
|
|
"\nSkill re-discovery failed; existing /skill: list left as-is."
|
|
)
|
|
elif added_skills or removed_skills:
|
|
skill_lines = []
|
|
if added_skills:
|
|
skill_lines.append(f" - Added: {', '.join(added_skills)}")
|
|
if removed_skills:
|
|
skill_lines.append(f" - Removed: {', '.join(removed_skills)}")
|
|
report += "\nSkills updated:\n" + "\n".join(skill_lines)
|
|
await self._mount_message(AppMessage(report))
|
|
await self._maybe_start_deferred_server_from_default()
|
|
elif cmd.startswith("/skill:"):
|
|
await self._handle_skill_command(command)
|
|
# -- Hidden commands (not in COMMANDS / autocomplete) -----------------
|
|
elif cmd == "/debug-error":
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
"Server failed to start: RuntimeError: Server process"
|
|
" exited with code 3",
|
|
),
|
|
)
|
|
elif cmd == "/restart":
|
|
await self._handle_restart_command(command)
|
|
else:
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(AppMessage(f"Unknown command: {cmd}"))
|
|
|
|
# Anchor to bottom so command output stays visible
|
|
with suppress(NoMatches, ScreenStackError):
|
|
self.query_one("#chat", VerticalScroll).anchor()
|
|
|
|
async def _invoke_skill(
|
|
self,
|
|
skill_name: str,
|
|
args: str = "",
|
|
*,
|
|
command: str | None = None,
|
|
) -> None:
|
|
"""Load a skill, render its widget, and send its prompt to the agent.
|
|
|
|
Looks up the skill from cached metadata (populated at startup), falling
|
|
back to a fresh filesystem walk on cache miss. Reads the `SKILL.md`
|
|
body, wraps it in a prompt envelope with any user-provided arguments,
|
|
and sends the composed message to the agent.
|
|
|
|
Args:
|
|
skill_name: Skill name to invoke.
|
|
args: Optional user request to append after the skill body.
|
|
command: Original slash command text for UI echo, if any.
|
|
"""
|
|
from deepagents_code.skills.invocation import build_skill_invocation_envelope
|
|
from deepagents_code.skills.load import load_skill_content
|
|
|
|
normalized_name = skill_name.strip().lower()
|
|
|
|
async def _mount_error(message: str) -> None:
|
|
if command is not None:
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(AppMessage(message))
|
|
|
|
if not normalized_name:
|
|
if command is not None:
|
|
await self._mount_message(UserMessage(command))
|
|
await self._mount_message(AppMessage("Usage: /skill:<name> [args]"))
|
|
else:
|
|
await self._mount_message(AppMessage("Skill name is required."))
|
|
return
|
|
|
|
# Fast path: look up from the cached discovery results
|
|
cached = next(
|
|
(s for s in self._discovered_skills if s["name"] == normalized_name),
|
|
None,
|
|
)
|
|
allowed_roots = self._skill_allowed_roots
|
|
|
|
# Cache miss — fall back to fresh discovery (offloaded to thread)
|
|
if cached is None:
|
|
try:
|
|
skills, allowed_roots = await asyncio.to_thread(
|
|
self._discover_skills_and_roots,
|
|
)
|
|
# Backfill cache so subsequent invocations are fast
|
|
self._discovered_skills = skills
|
|
self._skill_allowed_roots = allowed_roots
|
|
cached = next((s for s in skills if s["name"] == normalized_name), None)
|
|
except OSError as exc:
|
|
logger.warning(
|
|
"Filesystem error loading skill %r",
|
|
normalized_name,
|
|
exc_info=True,
|
|
)
|
|
await _mount_error(
|
|
f"Could not load skill: {normalized_name}. Filesystem error: {exc}",
|
|
)
|
|
return
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Error searching for skill %r",
|
|
normalized_name,
|
|
exc_info=True,
|
|
)
|
|
await _mount_error(
|
|
f"Error loading skill: {normalized_name}. "
|
|
f"Unexpected error: {type(exc).__name__}: {exc}",
|
|
)
|
|
return
|
|
|
|
if cached is None:
|
|
logger.warning("Skill not found: %r", normalized_name)
|
|
await _mount_error(f"Skill not found: {normalized_name}")
|
|
return
|
|
|
|
# Load SKILL.md content (filesystem I/O offloaded to thread)
|
|
skill_path = cached["path"]
|
|
|
|
def _load() -> str | None:
|
|
return load_skill_content(str(skill_path), allowed_roots=allowed_roots)
|
|
|
|
try:
|
|
content = await asyncio.to_thread(_load)
|
|
except PermissionError as exc:
|
|
logger.warning(
|
|
"Containment check failed for skill %r",
|
|
normalized_name,
|
|
exc_info=True,
|
|
)
|
|
await _mount_error(str(exc))
|
|
return
|
|
except OSError as exc:
|
|
logger.warning(
|
|
"Filesystem error loading skill %r",
|
|
normalized_name,
|
|
exc_info=True,
|
|
)
|
|
await _mount_error(
|
|
f"Could not load skill: {normalized_name}. Filesystem error: {exc}",
|
|
)
|
|
return
|
|
except Exception as exc:
|
|
logger.warning("Error reading skill %r", normalized_name, exc_info=True)
|
|
await _mount_error(
|
|
f"Error loading skill: {normalized_name}. "
|
|
f"Unexpected error: {type(exc).__name__}: {exc}",
|
|
)
|
|
return
|
|
|
|
if content is None:
|
|
await _mount_error(
|
|
f"Could not read content for skill: {normalized_name}. "
|
|
"Check that the SKILL.md file exists, is readable, "
|
|
"and is saved as UTF-8.",
|
|
)
|
|
return
|
|
|
|
if not content.strip():
|
|
await _mount_error(
|
|
f"Skill '{normalized_name}' has an empty SKILL.md file. "
|
|
"Add instructions to the file before invoking.",
|
|
)
|
|
return
|
|
|
|
envelope = build_skill_invocation_envelope(cached, content, args)
|
|
|
|
await self._mount_message(
|
|
SkillMessage(
|
|
skill_name=cached["name"],
|
|
description=str(cached.get("description", "")),
|
|
source=str(cached.get("source", "")),
|
|
body=content,
|
|
args=args,
|
|
),
|
|
)
|
|
await self._send_to_agent(
|
|
envelope.prompt,
|
|
message_kwargs=envelope.message_kwargs,
|
|
)
|
|
|
|
async def _handle_skill_command(self, command: str) -> None:
|
|
"""Handle a `/skill:<name>` command by loading and invoking a skill.
|
|
|
|
Args:
|
|
command: The full command string (e.g., `/skill:web-research find X`).
|
|
"""
|
|
from deepagents_code.command_registry import parse_skill_command
|
|
|
|
skill_name, args = parse_skill_command(command)
|
|
await self._invoke_skill(skill_name, args, command=command)
|
|
|
|
async def _has_conversation_messages(self) -> bool:
|
|
"""Check whether the current thread has at least one human message.
|
|
|
|
Returns:
|
|
`True` if the conversation contains a `HumanMessage`, `False`
|
|
otherwise. On transient errors (network, corrupt state) returns
|
|
`True` so that `/remember` is not blocked with a misleading
|
|
"nothing to remember" message.
|
|
"""
|
|
if not self._agent:
|
|
return False
|
|
try:
|
|
from langchain_core.messages import HumanMessage
|
|
|
|
config: RunnableConfig = {
|
|
"configurable": {"thread_id": self._lc_thread_id},
|
|
}
|
|
state = await self._agent.aget_state(config)
|
|
if not state or not state.values:
|
|
return False
|
|
messages = state.values.get("messages", [])
|
|
return any(isinstance(m, HumanMessage) for m in messages)
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to check conversation messages; allowing /remember to proceed",
|
|
exc_info=True,
|
|
)
|
|
return True
|
|
|
|
async def _get_conversation_token_count(self) -> int | None:
|
|
"""Return the approximate conversation-only token count.
|
|
|
|
Returns:
|
|
Token count as an integer, or `None` if state is unavailable.
|
|
"""
|
|
if not self._agent:
|
|
return None
|
|
try:
|
|
from langchain_core.messages.utils import (
|
|
count_tokens_approximately,
|
|
)
|
|
|
|
config: RunnableConfig = {
|
|
"configurable": {"thread_id": self._lc_thread_id},
|
|
}
|
|
state = await self._agent.aget_state(config)
|
|
if not state or not state.values:
|
|
return None
|
|
messages = state.values.get("messages", [])
|
|
if not messages:
|
|
return None
|
|
return count_tokens_approximately(messages)
|
|
except Exception: # best-effort for /tokens display
|
|
logger.debug("Failed to retrieve conversation token count", exc_info=True)
|
|
return None
|
|
|
|
def _resolve_offload_budget_str(self) -> str | None:
|
|
"""Resolve the offload retention budget as a human-readable string.
|
|
|
|
Instantiates a model and computes summarization defaults, so this is
|
|
not a trivial accessor.
|
|
|
|
Returns:
|
|
A string like `"20.0K (10% of 200.0K)"` or
|
|
`"last 6 messages"`, or `None` if the budget cannot be determined.
|
|
"""
|
|
from deepagents_code.config import create_model, settings
|
|
|
|
try:
|
|
from deepagents.middleware.summarization import (
|
|
compute_summarization_defaults,
|
|
)
|
|
|
|
model_spec = f"{settings.model_provider}:{settings.model_name}"
|
|
result = create_model(
|
|
model_spec,
|
|
profile_overrides=self._profile_override,
|
|
)
|
|
defaults = compute_summarization_defaults(result.model)
|
|
from deepagents_code.offload import format_offload_limit
|
|
|
|
return format_offload_limit(
|
|
defaults["keep"],
|
|
settings.model_context_limit,
|
|
)
|
|
except Exception: # best-effort for /tokens display
|
|
logger.debug("Failed to compute offload budget string", exc_info=True)
|
|
return None
|
|
|
|
async def _handle_offload(self) -> None:
|
|
"""Offload older messages to free context window space."""
|
|
from deepagents_code.config import settings
|
|
from deepagents_code.offload import (
|
|
OffloadModelError,
|
|
OffloadThresholdNotMet,
|
|
perform_offload,
|
|
)
|
|
|
|
if not self._agent or not self._lc_thread_id:
|
|
await self._mount_message(
|
|
AppMessage("Nothing to offload \u2014 start a conversation first"),
|
|
)
|
|
return
|
|
|
|
if self._agent_running:
|
|
await self._mount_message(
|
|
AppMessage("Cannot offload while agent is running"),
|
|
)
|
|
return
|
|
|
|
config: RunnableConfig = {"configurable": {"thread_id": self._lc_thread_id}}
|
|
|
|
try:
|
|
state_values = await self._get_thread_state_values(self._lc_thread_id)
|
|
except Exception as exc: # noqa: BLE001
|
|
await self._mount_message(ErrorMessage(f"Failed to read state: {exc}"))
|
|
return
|
|
|
|
if not state_values:
|
|
await self._mount_message(
|
|
AppMessage("Nothing to offload \u2014 start a conversation first"),
|
|
)
|
|
return
|
|
|
|
# Prevent concurrent user input while offload modifies state
|
|
self._agent_running = True
|
|
try:
|
|
from deepagents_code.hooks import dispatch_hook
|
|
|
|
await dispatch_hook("context.offload", {})
|
|
# Keep old hook name for backward compatibility
|
|
await dispatch_hook("context.compact", {})
|
|
await self._set_spinner("Offloading")
|
|
|
|
result = await perform_offload(
|
|
messages=state_values.get("messages", []),
|
|
prior_event=state_values.get("_summarization_event"),
|
|
thread_id=self._lc_thread_id,
|
|
model_spec=(f"{settings.model_provider}:{settings.model_name}"),
|
|
profile_overrides=self._profile_override,
|
|
context_limit=settings.model_context_limit,
|
|
total_context_tokens=self._context_tokens,
|
|
backend=self._backend,
|
|
)
|
|
|
|
if isinstance(result, OffloadThresholdNotMet):
|
|
conv_str = format_token_count(result.conversation_tokens)
|
|
if (
|
|
result.total_context_tokens > 0
|
|
and result.context_limit is not None
|
|
and result.total_context_tokens > result.context_limit
|
|
):
|
|
total_str = format_token_count(
|
|
result.total_context_tokens,
|
|
)
|
|
await self._mount_message(
|
|
AppMessage(
|
|
f"Offload threshold not met \u2014 conversation "
|
|
f"is only ~{conv_str} tokens.\n\n"
|
|
f"The remaining context "
|
|
f"({total_str} tokens) is system overhead "
|
|
f"that can't be offloaded.\n\n"
|
|
f"Use /tokens for a full breakdown.",
|
|
),
|
|
)
|
|
else:
|
|
await self._mount_message(
|
|
AppMessage(
|
|
f"Offload threshold not met \u2014 conversation "
|
|
f"(~{conv_str} tokens) is within the "
|
|
f"retention budget "
|
|
f"({result.budget_str}).\n\n"
|
|
f"Use /tokens for a full breakdown.",
|
|
),
|
|
)
|
|
return
|
|
|
|
# OffloadResult — success
|
|
if result.offload_warning:
|
|
await self._mount_message(ErrorMessage(result.offload_warning))
|
|
|
|
# Intentionally traced: the summarization event is a meaningful state
|
|
# transition that should surface in LangSmith alongside real agent turns.
|
|
# The new `_context_tokens` count rides along on the same update so it
|
|
# shares a checkpoint with the offload and doesn't create a separate
|
|
# `UpdateState` run.
|
|
await self._agent.aupdate_state(
|
|
config,
|
|
{
|
|
"_summarization_event": result.new_event,
|
|
"_context_tokens": result.tokens_after,
|
|
},
|
|
)
|
|
|
|
before = format_token_count(result.tokens_before)
|
|
after = format_token_count(result.tokens_after)
|
|
await self._mount_message(
|
|
AppMessage(
|
|
f"Offloaded {result.messages_offloaded} older messages, "
|
|
f"freeing up context window space.\n"
|
|
f"Context: {before} \u2192 {after} tokens "
|
|
f"({result.pct_decrease}% decrease), "
|
|
f"{result.messages_kept} messages kept.",
|
|
),
|
|
)
|
|
|
|
self._on_tokens_update(result.tokens_after)
|
|
|
|
except OffloadModelError as exc:
|
|
logger.warning("Offload model creation failed: %s", exc, exc_info=True)
|
|
await self._mount_message(ErrorMessage(str(exc)))
|
|
except Exception as exc: # surface offload errors to user
|
|
logger.exception("Offload failed")
|
|
await self._mount_message(ErrorMessage(f"Offload failed: {exc}"))
|
|
finally:
|
|
self._agent_running = False
|
|
try:
|
|
await self._set_spinner(None)
|
|
except Exception: # best-effort spinner cleanup
|
|
logger.exception("Failed to dismiss spinner after offload")
|
|
|
|
async def _handle_user_message(self, message: str) -> None:
|
|
"""Handle a user message to send to the agent.
|
|
|
|
Args:
|
|
message: The user's message
|
|
"""
|
|
# Mount the user message
|
|
await self._mount_message(UserMessage(message))
|
|
await self._send_to_agent(message)
|
|
|
|
async def _send_to_agent(
|
|
self,
|
|
message: str,
|
|
*,
|
|
message_kwargs: dict[str, Any] | None = None,
|
|
) -> None:
|
|
"""Send a message to the agent and start execution.
|
|
|
|
This is the low-level send path. It does NOT mount any widget — the
|
|
caller is responsible for mounting the appropriate visual representation
|
|
(e.g., `UserMessage`, `SkillMessage`) before calling this method.
|
|
|
|
Args:
|
|
message: The prompt to send to the agent.
|
|
message_kwargs: Extra fields merged into the stream input message
|
|
dict (e.g., `additional_kwargs` for skill metadata).
|
|
"""
|
|
# Anchor to bottom so streaming response stays visible
|
|
with suppress(NoMatches, ScreenStackError):
|
|
self.query_one("#chat", VerticalScroll).anchor()
|
|
|
|
# Check if agent is available
|
|
if self._agent and self._ui_adapter and self._session_state:
|
|
self._agent_running = True
|
|
|
|
if self._chat_input:
|
|
self._chat_input.set_cursor_active(active=False)
|
|
|
|
# Use run_worker to avoid blocking the main event loop
|
|
# This allows the UI to remain responsive during agent execution
|
|
self._agent_worker = self.run_worker(
|
|
self._run_agent_task(message, message_kwargs=message_kwargs),
|
|
exclusive=False,
|
|
)
|
|
elif self._server_startup_deferred:
|
|
await self._mount_message(AppMessage(_DEFERRED_START_NOTICE))
|
|
elif not self._server_startup_error:
|
|
# When a server-startup failure is in flight, the chat
|
|
# `ErrorMessage` mounted by `on_deep_agents_app_server_start_failed`
|
|
# is the single source of truth — don't duplicate it here.
|
|
await self._mount_message(
|
|
AppMessage("Agent not configured for this session."),
|
|
)
|
|
|
|
async def _mount_deferred_start_notice(self) -> None:
|
|
"""Tell first-launch users how to configure model credentials."""
|
|
if self._server_startup_deferred_notice_shown:
|
|
return
|
|
self._server_startup_deferred_notice_shown = True
|
|
await self._mount_message(AppMessage(_DEFERRED_START_NOTICE))
|
|
|
|
async def _run_agent_task(
|
|
self,
|
|
message: str,
|
|
*,
|
|
message_kwargs: dict[str, Any] | None = None,
|
|
) -> None:
|
|
"""Run the agent task in a background worker.
|
|
|
|
This runs in a Textual worker so the main event loop stays responsive.
|
|
|
|
Args:
|
|
message: The prompt to send to the agent.
|
|
message_kwargs: Extra fields merged into the stream input message
|
|
dict (e.g., `additional_kwargs` for skill metadata).
|
|
"""
|
|
# Caller ensures _ui_adapter is set (checked in _handle_user_message)
|
|
if self._ui_adapter is None:
|
|
return
|
|
from deepagents_code.textual_adapter import execute_task_textual
|
|
|
|
# Create the stats object up-front and store on the app so
|
|
# exit() can merge it synchronously if the worker is cancelled
|
|
# before this method can return (e.g. Ctrl+D during HITL).
|
|
turn_stats = SessionStats()
|
|
self._inflight_turn_stats = turn_stats
|
|
self._inflight_turn_start = time.monotonic()
|
|
try:
|
|
await execute_task_textual(
|
|
user_input=message,
|
|
agent=self._agent,
|
|
assistant_id=self._assistant_id,
|
|
session_state=self._session_state,
|
|
adapter=self._ui_adapter,
|
|
backend=self._backend,
|
|
image_tracker=self._image_tracker,
|
|
sandbox_type=self._sandbox_type,
|
|
message_kwargs=message_kwargs,
|
|
context=CLIContext(
|
|
model=self._model_override,
|
|
model_params=self._model_params_override or {},
|
|
),
|
|
turn_stats=turn_stats,
|
|
)
|
|
except Exception as e: # Resilient tool rendering
|
|
logger.exception("Agent execution failed")
|
|
# Ensure any in-flight tool calls don't remain stuck in "Running..."
|
|
# when streaming aborts before tool results arrive.
|
|
if self._ui_adapter:
|
|
self._ui_adapter.finalize_pending_tools_with_error(f"Agent error: {e}")
|
|
try:
|
|
await self._mount_message(ErrorMessage(f"Agent error: {e}"))
|
|
except Exception:
|
|
logger.debug(
|
|
"Could not mount error message (app closing?)",
|
|
exc_info=True,
|
|
)
|
|
finally:
|
|
# Merge turn stats before cleanup — _cleanup_agent_task may raise
|
|
# during teardown (widget removal on a torn-down DOM), and stats
|
|
# should ideally be captured regardless.
|
|
# exit() clears _inflight_turn_stats when it merges, so
|
|
# checking for None prevents double-counting.
|
|
if self._inflight_turn_stats is not None:
|
|
self._session_stats.merge(turn_stats)
|
|
self._inflight_turn_stats = None
|
|
await self._cleanup_agent_task()
|
|
|
|
async def _process_next_from_queue(self) -> None:
|
|
"""Process the next message from the queue if any exist.
|
|
|
|
Dequeues and processes the next pending message in FIFO order.
|
|
Uses the `_processing_pending` flag to prevent reentrant execution.
|
|
"""
|
|
if self._processing_pending or not self._pending_messages or self._exit:
|
|
return
|
|
|
|
self._processing_pending = True
|
|
try:
|
|
msg = self._pending_messages.popleft()
|
|
|
|
# Remove the ephemeral queued-message widget
|
|
if self._queued_widgets:
|
|
widget = self._queued_widgets.popleft()
|
|
await widget.remove()
|
|
|
|
await self._process_message(msg.text, msg.mode)
|
|
except Exception:
|
|
logger.exception("Failed to process queued message")
|
|
await self._mount_message(
|
|
ErrorMessage(f"Failed to process queued message: {msg.text[:60]}"),
|
|
)
|
|
finally:
|
|
self._processing_pending = False
|
|
|
|
# Command mode messages complete synchronously without spawning
|
|
# a worker, so cleanup won't fire again. Continue draining the
|
|
# queue if no worker was started.
|
|
busy = self._agent_running or self._shell_running
|
|
if not busy and self._pending_messages:
|
|
await self._process_next_from_queue()
|
|
|
|
async def _cleanup_agent_task(self) -> None:
|
|
"""Clean up after agent task completes or is cancelled."""
|
|
self._agent_running = False
|
|
self._agent_worker = None
|
|
|
|
# Remove spinner if present
|
|
await self._set_spinner(None)
|
|
|
|
if self._chat_input:
|
|
self._chat_input.set_cursor_active(active=True)
|
|
|
|
# Ensure token display is restored (in case of early cancellation).
|
|
# Pass the cached approximate flag so an interrupted "+" isn't clobbered.
|
|
self._show_tokens(approximate=self._tokens_approximate)
|
|
|
|
# Agent-executed commands and tools can mutate repo state (e.g. git
|
|
# checkout inside an execute call), so refresh the footer on turn end.
|
|
self._schedule_git_branch_refresh()
|
|
|
|
try:
|
|
await self._maybe_drain_deferred()
|
|
except Exception:
|
|
logger.exception("Failed to drain deferred actions during agent cleanup")
|
|
with suppress(Exception):
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
"A deferred action failed after task completion. "
|
|
"You may need to retry the operation.",
|
|
),
|
|
)
|
|
|
|
# Process next message from queue if any
|
|
if not self._startup_sequence_running:
|
|
await self._process_next_from_queue()
|
|
|
|
@staticmethod
|
|
def _convert_messages_to_data(messages: list[Any]) -> list[MessageData]:
|
|
"""Convert LangChain messages into lightweight `MessageData` objects.
|
|
|
|
This is a pure function with zero DOM operations. Tool call matching
|
|
happens here: `ToolMessage` results are matched by `tool_call_id` and
|
|
stored directly on the corresponding `MessageData`.
|
|
|
|
Args:
|
|
messages: LangChain message objects from a thread checkpoint.
|
|
|
|
Returns:
|
|
Ordered list of `MessageData` ready for `MessageStore.bulk_load`.
|
|
"""
|
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
|
|
result: list[MessageData] = []
|
|
# Maps tool_call_id -> index into result list
|
|
pending_tool_indices: dict[str, int] = {}
|
|
|
|
for msg in messages:
|
|
if isinstance(msg, HumanMessage):
|
|
content = (
|
|
msg.content if isinstance(msg.content, str) else str(msg.content)
|
|
)
|
|
if content.startswith("[SYSTEM]"):
|
|
continue
|
|
|
|
# Detect skill invocations persisted via additional_kwargs
|
|
skill_meta = (msg.additional_kwargs or {}).get("__skill")
|
|
if isinstance(skill_meta, dict) and skill_meta.get("name"):
|
|
result.append(
|
|
MessageData(
|
|
type=MessageType.SKILL,
|
|
content="",
|
|
skill_name=skill_meta["name"],
|
|
skill_description=str(skill_meta.get("description", "")),
|
|
skill_source=str(skill_meta.get("source", "")),
|
|
skill_args=str(skill_meta.get("args", "")),
|
|
skill_body=content,
|
|
),
|
|
)
|
|
else:
|
|
result.append(MessageData(type=MessageType.USER, content=content))
|
|
|
|
elif isinstance(msg, AIMessage):
|
|
# Extract text content
|
|
content = msg.content
|
|
text = ""
|
|
if isinstance(content, str):
|
|
text = content.strip()
|
|
elif isinstance(content, list):
|
|
for block in content:
|
|
if isinstance(block, dict) and block.get("type") == "text":
|
|
text += block.get("text", "")
|
|
elif isinstance(block, str):
|
|
text += block
|
|
text = text.strip()
|
|
|
|
if text:
|
|
result.append(MessageData(type=MessageType.ASSISTANT, content=text))
|
|
|
|
# Track tool calls for later matching
|
|
for tc in getattr(msg, "tool_calls", []):
|
|
tc_id = tc.get("id")
|
|
name = tc.get("name", "unknown")
|
|
args = tc.get("args", {})
|
|
data = MessageData(
|
|
type=MessageType.TOOL,
|
|
content="",
|
|
tool_name=name,
|
|
tool_args=args,
|
|
tool_status=ToolStatus.PENDING,
|
|
)
|
|
result.append(data)
|
|
if tc_id:
|
|
pending_tool_indices[tc_id] = len(result) - 1
|
|
else:
|
|
data.tool_status = ToolStatus.REJECTED
|
|
|
|
elif isinstance(msg, ToolMessage):
|
|
tc_id = getattr(msg, "tool_call_id", None)
|
|
if tc_id and tc_id in pending_tool_indices:
|
|
idx = pending_tool_indices.pop(tc_id)
|
|
data = result[idx]
|
|
status = getattr(msg, "status", "success")
|
|
content = (
|
|
msg.content
|
|
if isinstance(msg.content, str)
|
|
else str(msg.content)
|
|
)
|
|
if status == "success":
|
|
data.tool_status = ToolStatus.SUCCESS
|
|
else:
|
|
data.tool_status = ToolStatus.ERROR
|
|
data.tool_output = content
|
|
else:
|
|
logger.debug(
|
|
"ToolMessage with tool_call_id=%r could not be "
|
|
"matched to a pending tool call",
|
|
tc_id,
|
|
)
|
|
|
|
else:
|
|
logger.debug(
|
|
"Skipping unsupported message type %s during history conversion",
|
|
type(msg).__name__,
|
|
)
|
|
|
|
# Mark unmatched tool calls as rejected
|
|
for idx in pending_tool_indices.values():
|
|
result[idx].tool_status = ToolStatus.REJECTED
|
|
|
|
return result
|
|
|
|
async def _get_thread_state_values(self, thread_id: str) -> dict[str, Any]:
|
|
"""Fetch thread state values for a thread.
|
|
|
|
In server mode the LangGraph dev server starts with an empty in-memory
|
|
thread store, so `aget_state` returns empty state for any thread that
|
|
was not registered in the current server session. Calling
|
|
`aensure_thread` first registers the thread idempotently so the
|
|
subsequent `aget_state` call can read from the checkpointer correctly,
|
|
including proper reconstruction of delta channels.
|
|
|
|
Args:
|
|
thread_id: Thread ID to fetch from checkpoint storage.
|
|
|
|
Returns:
|
|
Thread state values keyed by channel name. Returns an empty dict
|
|
when no checkpointed values are available.
|
|
"""
|
|
if not self._agent:
|
|
return {}
|
|
|
|
config: RunnableConfig = {"configurable": {"thread_id": thread_id}}
|
|
remote_config: dict[str, Any] = {"configurable": {"thread_id": thread_id}}
|
|
|
|
if remote := self._remote_agent():
|
|
await remote.aensure_thread(remote_config)
|
|
|
|
state = await self._agent.aget_state(config)
|
|
|
|
if state and state.values:
|
|
return dict(state.values)
|
|
return {}
|
|
|
|
async def _fetch_thread_history_data(self, thread_id: str) -> _ThreadHistoryPayload:
|
|
"""Fetch and convert stored messages for a thread.
|
|
|
|
Args:
|
|
thread_id: Thread ID to fetch from checkpoint storage.
|
|
|
|
Returns:
|
|
Payload containing converted message data and the persisted
|
|
context-token count.
|
|
"""
|
|
state_values = await self._get_thread_state_values(thread_id)
|
|
raw_tokens = state_values.get("_context_tokens")
|
|
context_tokens = (
|
|
raw_tokens if isinstance(raw_tokens, int) and raw_tokens >= 0 else 0
|
|
)
|
|
messages = state_values.get("messages", [])
|
|
|
|
if not messages:
|
|
return _ThreadHistoryPayload([], context_tokens)
|
|
|
|
# RemoteGraph.aget_state returns values as raw JSON dicts; convert to
|
|
# LangChain message objects so _convert_messages_to_data works.
|
|
if any(isinstance(m, dict) for m in messages):
|
|
from langchain_core.messages.utils import convert_to_messages
|
|
|
|
messages = convert_to_messages(messages)
|
|
|
|
# Offload conversion so large histories don't block the UI loop.
|
|
data = await asyncio.to_thread(self._convert_messages_to_data, messages)
|
|
return _ThreadHistoryPayload(data, context_tokens)
|
|
|
|
async def _upgrade_thread_message_link(
|
|
self,
|
|
widget: AppMessage,
|
|
*,
|
|
prefix: str,
|
|
thread_id: str,
|
|
) -> None:
|
|
"""Upgrade a plain thread message to a linked one when URL resolves.
|
|
|
|
Args:
|
|
widget: The already-mounted app message.
|
|
prefix: Text prefix before thread ID.
|
|
thread_id: Thread ID to resolve.
|
|
"""
|
|
try:
|
|
thread_msg = await self._build_thread_message(prefix, thread_id)
|
|
if not isinstance(thread_msg, Content):
|
|
logger.debug(
|
|
"Skipping thread link upgrade for %s: URL did not resolve",
|
|
thread_id,
|
|
)
|
|
return
|
|
if widget.parent is None:
|
|
logger.debug(
|
|
"Skipping thread link upgrade for %s: widget no longer mounted",
|
|
thread_id,
|
|
)
|
|
return
|
|
# Keep serialized content in sync with the rendered content.
|
|
widget._content = thread_msg
|
|
widget.update(thread_msg)
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to upgrade thread message link for %s",
|
|
thread_id,
|
|
exc_info=True,
|
|
)
|
|
|
|
def _schedule_thread_message_link(
|
|
self,
|
|
widget: AppMessage,
|
|
*,
|
|
prefix: str,
|
|
thread_id: str,
|
|
) -> None:
|
|
"""Schedule thread URL link resolution and apply updates in the background.
|
|
|
|
Args:
|
|
widget: The message widget to update.
|
|
prefix: Text prefix before thread ID.
|
|
thread_id: Thread ID to resolve.
|
|
"""
|
|
self.run_worker(
|
|
self._upgrade_thread_message_link(
|
|
widget,
|
|
prefix=prefix,
|
|
thread_id=thread_id,
|
|
),
|
|
exclusive=False,
|
|
)
|
|
|
|
async def _load_thread_history(
|
|
self,
|
|
*,
|
|
thread_id: str | None = None,
|
|
preloaded_payload: _ThreadHistoryPayload | None = None,
|
|
) -> None:
|
|
"""Load and render message history when resuming a thread.
|
|
|
|
When `preloaded_payload` is provided (e.g., from `_resume_thread`),
|
|
this reuses that data. Otherwise, it fetches checkpoint state from the
|
|
agent and converts stored messages into lightweight `MessageData`
|
|
objects. The method then bulk-loads into the `MessageStore` and mounts
|
|
only the last `WINDOW_SIZE` widgets to reduce DOM operations on large
|
|
threads.
|
|
|
|
Args:
|
|
thread_id: Optional explicit thread ID to load.
|
|
|
|
Defaults to current.
|
|
preloaded_payload: Optional pre-fetched history payload for the
|
|
thread.
|
|
"""
|
|
history_thread_id = thread_id or self._lc_thread_id
|
|
if not history_thread_id:
|
|
logger.debug("Skipping history load: no thread ID available")
|
|
return
|
|
if preloaded_payload is None and not self._agent:
|
|
logger.debug(
|
|
"Skipping history load for %s: no active agent and no preloaded data",
|
|
history_thread_id,
|
|
)
|
|
return
|
|
|
|
try:
|
|
# Fetch + convert, or reuse preloaded payload on thread switch.
|
|
payload = (
|
|
preloaded_payload
|
|
if preloaded_payload is not None
|
|
else await self._fetch_thread_history_data(history_thread_id)
|
|
)
|
|
if not payload.messages:
|
|
return
|
|
|
|
# Seed token cache from persisted state
|
|
if payload.context_tokens > 0:
|
|
self._on_tokens_update(payload.context_tokens)
|
|
|
|
# 3. Bulk load into store (sets visible window)
|
|
_archived, visible = self._message_store.bulk_load(payload.messages)
|
|
|
|
# 5. Cache container ref (single query)
|
|
try:
|
|
messages_container = self.query_one("#messages", Container)
|
|
except NoMatches:
|
|
return
|
|
|
|
# 6-7. Create and mount only visible widgets (max WINDOW_SIZE)
|
|
widgets = [msg_data.to_widget() for msg_data in visible]
|
|
if widgets:
|
|
await messages_container.mount(*widgets)
|
|
|
|
# 8. Render content for AssistantMessage after mount
|
|
assistant_updates = [
|
|
widget.set_content(msg_data.content)
|
|
for widget, msg_data in zip(widgets, visible, strict=False)
|
|
if isinstance(widget, AssistantMessage) and msg_data.content
|
|
]
|
|
if assistant_updates:
|
|
assistant_results = await asyncio.gather(
|
|
*assistant_updates,
|
|
return_exceptions=True,
|
|
)
|
|
for error in assistant_results:
|
|
if isinstance(error, Exception):
|
|
logger.warning(
|
|
"Failed to render assistant history message for %s: %s",
|
|
history_thread_id,
|
|
error,
|
|
)
|
|
|
|
# 9. Add footer immediately and resolve link asynchronously
|
|
thread_msg_widget = AppMessage(f"Resumed thread: {history_thread_id}")
|
|
await self._mount_message(thread_msg_widget)
|
|
self._schedule_thread_message_link(
|
|
thread_msg_widget,
|
|
prefix="Resumed thread",
|
|
thread_id=history_thread_id,
|
|
)
|
|
|
|
# 10. Scroll once to bottom after history loads
|
|
def scroll_to_end() -> None:
|
|
with suppress(NoMatches):
|
|
chat = self.query_one("#chat", VerticalScroll)
|
|
chat.scroll_end(animate=False, immediate=True)
|
|
|
|
self.set_timer(0.1, scroll_to_end)
|
|
|
|
except Exception as e: # Resilient history loading
|
|
logger.exception(
|
|
"Failed to load thread history for %s",
|
|
history_thread_id,
|
|
)
|
|
await self._mount_message(AppMessage(f"Could not load history: {e}"))
|
|
|
|
async def _mount_message(
|
|
self,
|
|
widget: Static | AssistantMessage | ToolCallMessage | SkillMessage,
|
|
) -> None:
|
|
"""Mount a message widget to the messages area.
|
|
|
|
This method also stores the message data and handles pruning
|
|
when the widget count exceeds the maximum.
|
|
|
|
If the `#messages` container is not present (e.g. the screen has
|
|
been torn down during an interruption), the call is silently skipped
|
|
to avoid cascading `NoMatches` errors.
|
|
|
|
Args:
|
|
widget: The message widget to mount
|
|
"""
|
|
try:
|
|
messages = self.query_one("#messages", Container)
|
|
except NoMatches:
|
|
return
|
|
|
|
# During shutdown (e.g. Ctrl+D mid-stream) the container may still
|
|
# be in the DOM tree but already detached, so mount() would raise
|
|
# MountError. Bail out silently — the app is exiting anyway.
|
|
if not messages.is_attached:
|
|
return
|
|
|
|
# Store message data for virtualization
|
|
message_data = MessageData.from_widget(widget)
|
|
# Ensure the widget's DOM id matches the store id so that
|
|
# features like click-to-show-timestamp can look it up.
|
|
if not widget.id:
|
|
widget.id = message_data.id
|
|
self._message_store.append(message_data)
|
|
|
|
# Queued-message widgets must always stay at the bottom so they
|
|
# remain visually anchored below the current agent response.
|
|
if isinstance(widget, QueuedUserMessage):
|
|
await messages.mount(widget)
|
|
else:
|
|
await self._mount_before_queued(messages, widget)
|
|
|
|
# Prune old widgets if window exceeded
|
|
await self._prune_old_messages()
|
|
|
|
# Scroll to keep input bar visible
|
|
try:
|
|
input_container = self.query_one("#bottom-app-container", Container)
|
|
input_container.scroll_visible()
|
|
except NoMatches:
|
|
pass
|
|
|
|
async def _prune_old_messages(self) -> None:
|
|
"""Prune oldest message widgets if we exceed the window size.
|
|
|
|
This removes widgets from the DOM but keeps data in MessageStore
|
|
for potential re-hydration when scrolling up.
|
|
"""
|
|
if not self._message_store.window_exceeded():
|
|
return
|
|
|
|
try:
|
|
messages_container = self.query_one("#messages", Container)
|
|
except NoMatches:
|
|
logger.debug("Skipping pruning: #messages container not found")
|
|
return
|
|
|
|
to_prune = self._message_store.get_messages_to_prune()
|
|
if not to_prune:
|
|
return
|
|
|
|
pruned_ids: list[str] = []
|
|
for msg_data in to_prune:
|
|
try:
|
|
widget = messages_container.query_one(f"#{msg_data.id}")
|
|
await widget.remove()
|
|
pruned_ids.append(msg_data.id)
|
|
except NoMatches:
|
|
# Widget not found -- do NOT mark as pruned to avoid
|
|
# desyncing the store from the actual DOM state
|
|
logger.debug(
|
|
"Widget %s not found during pruning, skipping",
|
|
msg_data.id,
|
|
)
|
|
|
|
if pruned_ids:
|
|
self._message_store.mark_pruned(pruned_ids)
|
|
|
|
def _set_active_message(self, message_id: str | None) -> None:
|
|
"""Set the active streaming message (won't be pruned).
|
|
|
|
Args:
|
|
message_id: The ID of the active message, or None to clear.
|
|
"""
|
|
self._message_store.set_active_message(message_id)
|
|
|
|
def _sync_message_content(self, message_id: str, content: str) -> None:
|
|
"""Sync final message content back to the store after streaming.
|
|
|
|
Called when streaming finishes so the store holds the full text
|
|
instead of the empty string captured at mount time.
|
|
|
|
Args:
|
|
message_id: The ID of the message to update.
|
|
content: The final content after streaming.
|
|
"""
|
|
self._message_store.update_message(
|
|
message_id,
|
|
content=content,
|
|
is_streaming=False,
|
|
)
|
|
|
|
async def _clear_messages(self) -> None:
|
|
"""Clear the messages area and message store."""
|
|
# Clear the message store first
|
|
self._message_store.clear()
|
|
try:
|
|
messages = self.query_one("#messages", Container)
|
|
await messages.remove_children()
|
|
except NoMatches:
|
|
logger.warning(
|
|
"Messages container (#messages) not found during clear; "
|
|
"UI may be out of sync with message store",
|
|
)
|
|
|
|
def _pop_last_queued_message(self) -> None:
|
|
"""Remove the most recently queued message (LIFO).
|
|
|
|
If the chat input is empty the evicted text is restored there so the
|
|
user can edit and re-submit. Otherwise the message is discarded. The
|
|
toast message distinguishes between the two outcomes.
|
|
|
|
Caller must ensure `_pending_messages` is non-empty. A defensive guard
|
|
is included in case of async TOCTOU races.
|
|
"""
|
|
if not self._pending_messages:
|
|
return
|
|
msg = self._pending_messages.pop()
|
|
if self._queued_widgets:
|
|
widget = self._queued_widgets.pop()
|
|
widget.remove()
|
|
else:
|
|
logger.warning(
|
|
"Queued-widget deque empty while pending-messages was not; "
|
|
"widget/message tracking may be out of sync",
|
|
)
|
|
|
|
if not self._chat_input:
|
|
logger.warning(
|
|
"Chat input unavailable during queue pop; "
|
|
"message text cannot be restored: %s",
|
|
msg.text[:60],
|
|
)
|
|
self.notify("Queued message discarded", timeout=2)
|
|
return
|
|
|
|
if not self._chat_input.value.strip():
|
|
self._chat_input.value = msg.text
|
|
self.notify("Queued message moved to input", timeout=2)
|
|
else:
|
|
self.notify("Queued message discarded (input not empty)", timeout=3)
|
|
|
|
def _cleanup_external_event_source_sync(self) -> None:
|
|
"""Synchronously close the external event listener and unlink its socket.
|
|
|
|
Called from `exit()` because the event loop is about to be torn
|
|
down and the task's async `finally` would never complete. Close
|
|
the asyncio server (releases the file descriptor) and unlink the
|
|
socket path so we never leave stale entries on disk.
|
|
"""
|
|
source = self._external_event_source
|
|
if source is None:
|
|
return
|
|
from deepagents_code.event_bus import UnixSocketEventSource
|
|
|
|
if isinstance(source, UnixSocketEventSource):
|
|
server = source._server # synchronous teardown peer
|
|
source._server = None
|
|
if server is not None:
|
|
with suppress(Exception):
|
|
server.close()
|
|
with suppress(FileNotFoundError):
|
|
from deepagents_code.event_bus import _unlink_existing_socket
|
|
|
|
try:
|
|
_unlink_existing_socket(source.path)
|
|
except FileExistsError:
|
|
logger.warning(
|
|
"Leaving non-socket entry at %s during exit",
|
|
source.path,
|
|
)
|
|
except OSError as exc:
|
|
logger.warning(
|
|
"Failed to unlink event socket %s: %s",
|
|
source.path,
|
|
exc,
|
|
)
|
|
|
|
def _discard_queue(self) -> None:
|
|
"""Clear pending messages, deferred actions, and queued widgets."""
|
|
self._pending_messages.clear()
|
|
for w in self._queued_widgets:
|
|
w.remove()
|
|
self._queued_widgets.clear()
|
|
self._deferred_actions.clear()
|
|
|
|
def _force_interrupt_active_work(self) -> None:
|
|
"""Cancel in-flight work before the standard `/clear` path runs.
|
|
|
|
Rejects pending approvals, cancels pending ask-user prompts, kills
|
|
the shell worker, kills the agent worker, and drops the queued
|
|
message backlog. UI clearing itself happens in the calling
|
|
`/clear` handler. Each widget interaction is best-effort: a torn-
|
|
down widget should not abort the interrupt sequence, but the
|
|
underlying error is logged so regressions are visible.
|
|
"""
|
|
if self._pending_approval_widget:
|
|
try:
|
|
self._pending_approval_widget.action_select_reject()
|
|
except (AttributeError, RuntimeError):
|
|
logger.exception("force-clear: failed to reject pending approval")
|
|
if self._pending_ask_user_widget:
|
|
try:
|
|
self._pending_ask_user_widget.action_cancel()
|
|
except (AttributeError, RuntimeError):
|
|
logger.exception("force-clear: failed to cancel pending ask-user")
|
|
if self._shell_running and self._shell_worker:
|
|
self._shell_worker.cancel()
|
|
if self._agent_running and self._agent_worker:
|
|
self._agent_worker.cancel()
|
|
self._discard_queue()
|
|
|
|
def _defer_action(self, action: DeferredAction) -> None:
|
|
"""Queue a deferred action, replacing any existing action of the same kind.
|
|
|
|
Last-write-wins: if the user selects a model twice while busy, only the
|
|
final selection runs.
|
|
|
|
Args:
|
|
action: The deferred action to queue.
|
|
"""
|
|
self._deferred_actions = [
|
|
a for a in self._deferred_actions if a.kind != action.kind
|
|
]
|
|
self._deferred_actions.append(action)
|
|
|
|
async def _maybe_drain_deferred(self) -> None:
|
|
"""Drain deferred actions unless startup sequencing is still in progress."""
|
|
if not self._connecting and not self._startup_sequence_running:
|
|
await self._drain_deferred_actions()
|
|
|
|
async def _drain_deferred_actions(self) -> None:
|
|
"""Execute deferred actions queued while busy (e.g. model/thread switch)."""
|
|
while self._deferred_actions:
|
|
action = self._deferred_actions.pop(0)
|
|
try:
|
|
await action.execute()
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to execute deferred action %r (callable=%r)",
|
|
action.kind,
|
|
action.execute,
|
|
)
|
|
label = action.kind.replace("_", " ")
|
|
try:
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
f"Deferred {label} failed unexpectedly. "
|
|
"You may need to retry the operation.",
|
|
),
|
|
)
|
|
except Exception:
|
|
logger.debug(
|
|
"Could not mount error message for deferred %r",
|
|
action.kind,
|
|
exc_info=True,
|
|
)
|
|
|
|
def _cancel_worker(self, worker: Worker[None] | None) -> None:
|
|
"""Discard the message queue and cancel an active worker.
|
|
|
|
Args:
|
|
worker: The worker to cancel.
|
|
"""
|
|
self._discard_queue()
|
|
if worker is not None:
|
|
worker.cancel()
|
|
|
|
def action_quit_or_interrupt(self) -> None:
|
|
"""Handle Ctrl+C - interrupt agent, reject approval, or quit on double press.
|
|
|
|
Priority order:
|
|
1. If shell command is running, kill it
|
|
2. If approval menu is active, reject it
|
|
3. If agent is running, interrupt it (preserve input)
|
|
4. If double press (quit_pending), quit
|
|
5. Otherwise show quit hint
|
|
"""
|
|
# If shell command is running, cancel the worker
|
|
if self._shell_running and self._shell_worker:
|
|
self._cancel_worker(self._shell_worker)
|
|
self._quit_pending = False
|
|
return
|
|
|
|
# If approval menu is active, reject it before cancelling the agent worker.
|
|
# During HITL the agent worker remains active while awaiting approval,
|
|
# so this must be checked before the worker cancellation branch to
|
|
# avoid leaving a stale approval widget interactive after interruption.
|
|
if self._pending_approval_widget:
|
|
self._pending_approval_widget.action_select_reject()
|
|
self._quit_pending = False
|
|
return
|
|
|
|
# If ask_user menu is active, cancel it before cancelling the agent
|
|
# worker, following the same pattern as the approval widget above.
|
|
if self._pending_ask_user_widget:
|
|
self._pending_ask_user_widget.action_cancel()
|
|
self._quit_pending = False
|
|
return
|
|
|
|
# If agent is running, interrupt it and discard queued messages
|
|
if self._agent_running and self._agent_worker:
|
|
self._cancel_worker(self._agent_worker)
|
|
self._quit_pending = False
|
|
return
|
|
|
|
# Double Ctrl+C to quit
|
|
if self._quit_pending:
|
|
self.exit()
|
|
else:
|
|
self._arm_quit_pending("Ctrl+C")
|
|
|
|
def _arm_quit_pending(self, shortcut: str) -> None:
|
|
"""Set the pending-quit flag and show a matching hint.
|
|
|
|
Args:
|
|
shortcut: The key chord to show in the quit hint.
|
|
"""
|
|
self._quit_pending = True
|
|
quit_timeout = 3
|
|
self.notify(
|
|
f"Press {shortcut} again to quit",
|
|
timeout=quit_timeout,
|
|
markup=False,
|
|
)
|
|
self.set_timer(quit_timeout, lambda: setattr(self, "_quit_pending", False))
|
|
|
|
def action_interrupt(self) -> None:
|
|
"""Handle escape key.
|
|
|
|
Priority order:
|
|
1. If modal screen is active, dismiss it
|
|
2. If completion popup is open, dismiss it
|
|
3. If input is in command/shell mode, exit to normal mode
|
|
4. If shell command is running, kill it
|
|
5. If approval menu is active, reject it
|
|
6. If ask-user menu is active, cancel it
|
|
7. If queued messages exist, pop the last one (LIFO)
|
|
8. If agent is running, interrupt it
|
|
"""
|
|
from deepagents_code.widgets.thread_selector import ThreadSelectorScreen
|
|
|
|
if (
|
|
isinstance(self.screen, ThreadSelectorScreen)
|
|
and self.screen.is_delete_confirmation_open
|
|
):
|
|
self.screen.action_cancel()
|
|
return
|
|
|
|
# If a modal screen is active, let it cancel itself (so it can
|
|
# restore state, e.g. the theme selector reverts the previewed theme).
|
|
# Fall back to a plain dismiss for modals without action_cancel.
|
|
if isinstance(self.screen, ModalScreen):
|
|
cancel = getattr(self.screen, "action_cancel", None)
|
|
if cancel is not None:
|
|
cancel()
|
|
else:
|
|
self.screen.dismiss(None)
|
|
return
|
|
|
|
# Close completion popup or exit slash/shell command mode
|
|
if self._chat_input:
|
|
if self._chat_input.dismiss_completion():
|
|
return
|
|
if self._chat_input.exit_mode():
|
|
return
|
|
|
|
# If shell command is running, cancel the worker
|
|
if self._shell_running and self._shell_worker:
|
|
self._cancel_worker(self._shell_worker)
|
|
return
|
|
|
|
# If approval menu is active, reject it before cancelling the agent worker.
|
|
# During HITL the agent worker remains active while awaiting approval,
|
|
# so this must be checked before the worker cancellation branch to
|
|
# avoid leaving a stale approval widget interactive after interruption.
|
|
if self._pending_approval_widget:
|
|
self._pending_approval_widget.action_select_reject()
|
|
return
|
|
|
|
# If ask_user menu is active, cancel it before cancelling the agent
|
|
# worker, following the same pattern as the approval widget above.
|
|
if self._pending_ask_user_widget:
|
|
self._pending_ask_user_widget.action_cancel()
|
|
return
|
|
|
|
# If queued messages exist, pop the last one (LIFO) instead of
|
|
# interrupting the agent. This lets the user retract queued messages
|
|
# one at a time; once the queue is empty the next ESC will interrupt.
|
|
if self._pending_messages:
|
|
self._pop_last_queued_message()
|
|
return
|
|
|
|
# If agent is running, interrupt it and discard queued messages
|
|
if self._agent_running and self._agent_worker:
|
|
self._cancel_worker(self._agent_worker)
|
|
return
|
|
|
|
def action_quit_app(self) -> None:
|
|
"""Handle quit action (Ctrl+D)."""
|
|
from deepagents_code.widgets.auth import (
|
|
AuthPromptScreen,
|
|
DeleteCredentialConfirmScreen,
|
|
)
|
|
from deepagents_code.widgets.thread_selector import (
|
|
DeleteThreadConfirmScreen,
|
|
ThreadSelectorScreen,
|
|
)
|
|
|
|
if isinstance(self.screen, ThreadSelectorScreen):
|
|
self.screen.action_delete_thread()
|
|
return
|
|
if isinstance(self.screen, AuthPromptScreen):
|
|
self.screen.action_delete_stored()
|
|
return
|
|
if isinstance(
|
|
self.screen,
|
|
(DeleteThreadConfirmScreen, DeleteCredentialConfirmScreen),
|
|
):
|
|
if self._quit_pending:
|
|
self.exit()
|
|
return
|
|
self._arm_quit_pending("Ctrl+D")
|
|
return
|
|
self.exit()
|
|
|
|
def exit(
|
|
self,
|
|
result: Any = None, # noqa: ANN401 # Dynamic LangGraph stream result type
|
|
return_code: int = 0,
|
|
message: Any = None, # noqa: ANN401 # Dynamic LangGraph message type
|
|
) -> None:
|
|
"""Exit the app after shutting down background resources.
|
|
|
|
Args:
|
|
result: Return value passed to the app runner.
|
|
return_code: Exit code (non-zero for errors).
|
|
message: Optional message to display on exit.
|
|
"""
|
|
# Merge in-flight turn stats before any cleanup that might raise.
|
|
# When the agent worker is cancelled (e.g. Ctrl+D during a pending tool
|
|
# call), the worker's finally block will see _inflight_turn_stats is
|
|
# already None and skip the merge.
|
|
inflight = self._inflight_turn_stats
|
|
if inflight is not None:
|
|
self._inflight_turn_stats = None
|
|
if not inflight.wall_time_seconds:
|
|
inflight.wall_time_seconds = (
|
|
time.monotonic() - self._inflight_turn_start
|
|
)
|
|
self._session_stats.merge(inflight)
|
|
|
|
# Discard queued messages so _cleanup_agent_task won't try to
|
|
# process them after the event loop is torn down, and cancel
|
|
# active workers so their subprocesses are terminated
|
|
# (SIGTERM → SIGKILL) instead of being orphaned.
|
|
self._discard_queue()
|
|
|
|
if self._shell_running and self._shell_worker:
|
|
self._shell_worker.cancel()
|
|
if self._agent_running and self._agent_worker:
|
|
self._agent_worker.cancel()
|
|
if self._git_branch_refresh_task is not None:
|
|
self._git_branch_refresh_task.cancel()
|
|
if self._external_event_source_task is not None:
|
|
self._external_event_source_task.cancel()
|
|
# Cancellation alone is not enough: the task's `finally` block runs
|
|
# asynchronously, and the event loop is about to be torn down by
|
|
# `super().exit()`. Synchronously close the server and unlink the
|
|
# socket file so we never leave a stale entry on disk.
|
|
if self._external_event_source is not None:
|
|
self._cleanup_external_event_source_sync()
|
|
self._external_event_source = None
|
|
|
|
# Dispatch synchronously — the event loop is about to be torn down by
|
|
# super().exit(), so an async task would never complete.
|
|
from deepagents_code.hooks import _dispatch_hook_sync, _load_hooks
|
|
|
|
hooks = _load_hooks()
|
|
if hooks:
|
|
payload = json.dumps(
|
|
{
|
|
"event": "session.end",
|
|
"thread_id": getattr(self, "_lc_thread_id", ""),
|
|
},
|
|
).encode()
|
|
_dispatch_hook_sync("session.end", payload, hooks)
|
|
|
|
from deepagents_code.terminal_escape import reset_terminal_background
|
|
|
|
try:
|
|
reset_terminal_background()
|
|
except Exception:
|
|
# Cosmetic only: must never raise during shutdown.
|
|
logger.warning(
|
|
"reset_terminal_background raised unexpectedly",
|
|
exc_info=True,
|
|
)
|
|
restore_iterm_cursor_guide()
|
|
super().exit(result=result, return_code=return_code, message=message)
|
|
|
|
def action_toggle_auto_approve(self) -> None:
|
|
"""Toggle auto-approve mode for the current session.
|
|
|
|
When enabled, all tool calls (shell execution, file writes/edits,
|
|
web search, URL fetch) run without prompting. Updates the status
|
|
bar indicator and session state.
|
|
"""
|
|
from deepagents_code.widgets.agent_selector import AgentSelectorScreen
|
|
from deepagents_code.widgets.auth import AuthManagerScreen
|
|
from deepagents_code.widgets.mcp_viewer import MCPViewerScreen
|
|
from deepagents_code.widgets.notification_center import (
|
|
NotificationCenterScreen,
|
|
)
|
|
from deepagents_code.widgets.notification_detail import NotificationDetailScreen
|
|
from deepagents_code.widgets.notification_settings import (
|
|
NotificationSettingsScreen,
|
|
)
|
|
from deepagents_code.widgets.theme_selector import ThemeSelectorScreen
|
|
from deepagents_code.widgets.thread_selector import ThreadSelectorScreen
|
|
from deepagents_code.widgets.update_available import UpdateAvailableScreen
|
|
|
|
if isinstance(self.screen, ThreadSelectorScreen):
|
|
self.screen.action_focus_previous_filter()
|
|
return
|
|
if isinstance(
|
|
self.screen,
|
|
(ThemeSelectorScreen, AgentSelectorScreen, AuthManagerScreen),
|
|
):
|
|
self.screen.action_cursor_up()
|
|
return
|
|
if isinstance(self.screen, NotificationSettingsScreen):
|
|
self.screen.focus_previous()
|
|
return
|
|
if isinstance(
|
|
self.screen,
|
|
(UpdateAvailableScreen, NotificationCenterScreen, NotificationDetailScreen),
|
|
):
|
|
self.screen.action_move_up()
|
|
return
|
|
if isinstance(self.screen, MCPViewerScreen):
|
|
self.screen.action_move_up()
|
|
return
|
|
# shift+tab is reused for navigation inside modal screens (e.g.
|
|
# ModelSelectorScreen); skip the toggle so it doesn't fire through.
|
|
if isinstance(self.screen, ModalScreen):
|
|
return
|
|
# Delegate shift+tab to ask_user navigation when interview is active.
|
|
if self._pending_ask_user_widget is not None:
|
|
self._pending_ask_user_widget.action_previous_question()
|
|
return
|
|
self._auto_approve = not self._auto_approve
|
|
if self._status_bar:
|
|
self._status_bar.set_auto_approve(enabled=self._auto_approve)
|
|
if self._session_state:
|
|
self._session_state.auto_approve = self._auto_approve
|
|
|
|
def action_toggle_tool_output(self) -> None:
|
|
"""Toggle expand/collapse of the most recent tool output or skill body."""
|
|
# Pending ask_user takes precedence so Ctrl+O toggles the question card.
|
|
if self._pending_ask_user_widget is not None:
|
|
try:
|
|
tool_messages = list(self.query(ToolCallMessage))
|
|
except NoMatches:
|
|
tool_messages = []
|
|
for tool_msg in reversed(tool_messages):
|
|
if tool_msg.has_expandable_args:
|
|
tool_msg.toggle_args()
|
|
return
|
|
|
|
# Try skill messages first (most recent collapsible content)
|
|
try:
|
|
skill_messages = list(self.query(SkillMessage))
|
|
except NoMatches:
|
|
skill_messages = []
|
|
for skill_msg in reversed(skill_messages):
|
|
if skill_msg._stripped_body.strip():
|
|
skill_msg.toggle_body()
|
|
return
|
|
|
|
# Fall back to tool messages with output or expandable args
|
|
try:
|
|
tool_messages = list(self.query(ToolCallMessage))
|
|
except NoMatches:
|
|
tool_messages = []
|
|
for tool_msg in reversed(tool_messages):
|
|
if tool_msg.has_output:
|
|
tool_msg.toggle_output()
|
|
return
|
|
if tool_msg.has_expandable_args:
|
|
tool_msg.toggle_args()
|
|
return
|
|
|
|
# Approval menu action handlers (delegated from App-level bindings)
|
|
# NOTE: These only activate when approval widget is pending
|
|
# AND input is not focused
|
|
def action_approval_up(self) -> None:
|
|
"""Handle up arrow in approval menu."""
|
|
# Only handle if approval is active
|
|
# (input handles its own up for history/completion)
|
|
if self._pending_approval_widget and not self._is_input_focused():
|
|
self._pending_approval_widget.action_move_up()
|
|
|
|
def action_approval_down(self) -> None:
|
|
"""Handle down arrow in approval menu."""
|
|
if self._pending_approval_widget and not self._is_input_focused():
|
|
self._pending_approval_widget.action_move_down()
|
|
|
|
def action_approval_select(self) -> None:
|
|
"""Handle enter in approval menu."""
|
|
# Only handle if approval is active AND input is not focused
|
|
if self._pending_approval_widget and not self._is_input_focused():
|
|
self._pending_approval_widget.action_select()
|
|
|
|
def _is_input_focused(self) -> bool:
|
|
"""Check if the chat input (or its text area) has focus.
|
|
|
|
Returns:
|
|
True if the input widget has focus, False otherwise.
|
|
"""
|
|
if not self._chat_input:
|
|
return False
|
|
focused = self.focused
|
|
if focused is None:
|
|
return False
|
|
# Check if focused widget is the text area inside chat input
|
|
return focused.id == "chat-input" or focused in self._chat_input.walk_children()
|
|
|
|
def action_approval_yes(self) -> None:
|
|
"""Handle yes/1 in approval menu."""
|
|
if self._pending_approval_widget:
|
|
self._pending_approval_widget.action_select_approve()
|
|
|
|
def action_approval_auto(self) -> None:
|
|
"""Handle auto/2 in approval menu."""
|
|
if self._pending_approval_widget:
|
|
self._pending_approval_widget.action_select_auto()
|
|
|
|
def action_approval_no(self) -> None:
|
|
"""Handle no/3 in approval menu."""
|
|
if self._pending_approval_widget:
|
|
self._pending_approval_widget.action_select_reject()
|
|
|
|
def action_approval_escape(self) -> None:
|
|
"""Handle escape in approval menu - reject."""
|
|
if self._pending_approval_widget:
|
|
self._pending_approval_widget.action_select_reject()
|
|
|
|
async def action_open_editor(self) -> None:
|
|
"""Open the current prompt text in an external editor ($VISUAL/$EDITOR)."""
|
|
from deepagents_code.editor import open_in_editor
|
|
|
|
chat_input = self._chat_input
|
|
if not chat_input or not chat_input._text_area:
|
|
return
|
|
|
|
current_text = chat_input._text_area.text or ""
|
|
|
|
edited: str | None = None
|
|
try:
|
|
with self.suspend():
|
|
edited = open_in_editor(current_text)
|
|
except Exception:
|
|
logger.warning("External editor failed", exc_info=True)
|
|
self.notify(
|
|
"External editor failed. Check $VISUAL/$EDITOR.",
|
|
severity="error",
|
|
timeout=5,
|
|
)
|
|
chat_input.focus_input()
|
|
return
|
|
|
|
if edited is not None:
|
|
chat_input._text_area.text = edited
|
|
lines = edited.split("\n")
|
|
chat_input._text_area.move_cursor((len(lines) - 1, len(lines[-1])))
|
|
chat_input.focus_input()
|
|
|
|
def on_paste(self, event: Paste) -> None:
|
|
"""Route unfocused paste events to chat input for drag/drop reliability."""
|
|
if not self._chat_input:
|
|
return
|
|
if isinstance(self.screen, ModalScreen):
|
|
return
|
|
if (
|
|
self._pending_approval_widget
|
|
or self._pending_ask_user_widget
|
|
or self._is_input_focused()
|
|
):
|
|
return
|
|
if self._chat_input.handle_external_paste(event.text):
|
|
event.prevent_default()
|
|
event.stop()
|
|
|
|
def on_app_focus(self) -> None:
|
|
"""Restore chat input focus and resume cursor blink on terminal focus regain.
|
|
|
|
When the user opens a link via `webbrowser.open`, OS focus shifts to
|
|
the browser. On returning to the terminal, Textual fires `AppFocus`
|
|
(requires a terminal that supports FocusIn events). Re-focusing the chat
|
|
input here keeps it ready for typing.
|
|
"""
|
|
if self._chat_input is None:
|
|
return
|
|
self._chat_input.set_cursor_blink(blink=True)
|
|
if isinstance(self.screen, ModalScreen):
|
|
return
|
|
if self._pending_approval_widget or self._pending_ask_user_widget:
|
|
return
|
|
self._chat_input.focus_input()
|
|
|
|
def on_app_blur(self) -> None:
|
|
"""Pause the chat input cursor blink when the terminal loses OS focus.
|
|
|
|
`TextArea` pauses its own blink when its `has_focus` flips, but
|
|
`AppBlur` does not change widget focus, so we toggle `cursor_blink`
|
|
manually.
|
|
"""
|
|
if self._chat_input is None:
|
|
return
|
|
self._chat_input.set_cursor_blink(blink=False)
|
|
|
|
def on_click(self, event: Click) -> None:
|
|
"""Handle clicks anywhere in the terminal.
|
|
|
|
Clicks on registered actionable toasts open the notification
|
|
center. The toast itself dismisses as normal; we only piggyback
|
|
on the click. Other clicks restore focus to the chat input.
|
|
"""
|
|
widget = event.widget
|
|
if isinstance(widget, _Toast):
|
|
identity = _toast_identity(widget, app=self)
|
|
if identity is not None and self._notice_registry.is_actionable_toast(
|
|
identity,
|
|
):
|
|
self.call_after_refresh(self._open_notification_center)
|
|
return
|
|
|
|
if not self._chat_input:
|
|
return
|
|
if isinstance(self.screen, ModalScreen):
|
|
return
|
|
# Don't steal focus from approval or ask_user widgets
|
|
if self._pending_approval_widget or self._pending_ask_user_widget:
|
|
return
|
|
self.call_after_refresh(self._chat_input.focus_input)
|
|
|
|
def on_mouse_up(self, event: MouseUp) -> None: # noqa: ARG002 # Textual event handler signature
|
|
"""Copy selection to clipboard on mouse release."""
|
|
from deepagents_code.clipboard import copy_selection_to_clipboard
|
|
|
|
copy_selection_to_clipboard(self)
|
|
|
|
# =========================================================================
|
|
# Model Switching
|
|
# =========================================================================
|
|
|
|
def _build_model_selector_screen(
|
|
self,
|
|
*,
|
|
curated: bool = False,
|
|
result_callback: Callable[[tuple[str, str] | None], None] | None = None,
|
|
) -> ModalScreen:
|
|
"""Build the model selector screen with current app model state.
|
|
|
|
Args:
|
|
curated: Whether to use a shorter onboarding model list.
|
|
result_callback: Optional direct callback for selector results.
|
|
|
|
Returns:
|
|
Configured model selector modal.
|
|
"""
|
|
from deepagents_code.config import settings
|
|
from deepagents_code.widgets.model_selector import ModelSelectorScreen
|
|
|
|
return ModelSelectorScreen(
|
|
current_model=settings.model_name,
|
|
current_provider=settings.model_provider,
|
|
cli_profile_override=self._profile_override,
|
|
curated=curated,
|
|
title="Choose a Recommended Model" if curated else None,
|
|
description=(
|
|
"These models have performed well in Deep Agents evals and are "
|
|
"a solid starting set. You can explore the full model list "
|
|
"later with /model."
|
|
if curated
|
|
else None
|
|
),
|
|
result_callback=result_callback,
|
|
)
|
|
|
|
async def _prompt_model_selector(
|
|
self,
|
|
*,
|
|
curated: bool = False,
|
|
) -> tuple[str, str] | None:
|
|
"""Show the model selector and wait for a choice.
|
|
|
|
Args:
|
|
curated: Whether to use the shorter onboarding model list.
|
|
|
|
Returns:
|
|
`(model_spec, provider)` on selection, or `None` on cancel.
|
|
"""
|
|
screen = self._build_model_selector_screen(curated=curated)
|
|
result = await self._push_screen_wait(screen)
|
|
return result if result is None or isinstance(result, tuple) else None
|
|
|
|
async def _show_model_selector(
|
|
self,
|
|
*,
|
|
extra_kwargs: dict[str, Any] | None = None,
|
|
) -> None:
|
|
"""Show interactive model selector as a modal screen.
|
|
|
|
Args:
|
|
extra_kwargs: Extra constructor kwargs from `--model-params`.
|
|
"""
|
|
from functools import partial
|
|
|
|
def handle_result(result: tuple[str, str] | None) -> None:
|
|
"""Handle the model selector result."""
|
|
if result is not None:
|
|
model_spec, _ = result
|
|
if self._agent_running or self._shell_running or self._connecting:
|
|
self._defer_action(
|
|
DeferredAction(
|
|
kind="model_switch",
|
|
execute=partial(
|
|
self._switch_model,
|
|
model_spec,
|
|
extra_kwargs=extra_kwargs,
|
|
),
|
|
),
|
|
)
|
|
self.notify(
|
|
"Model will switch after current task completes.",
|
|
timeout=3,
|
|
)
|
|
else:
|
|
self.call_later(
|
|
partial(
|
|
self._switch_model,
|
|
model_spec,
|
|
extra_kwargs=extra_kwargs,
|
|
),
|
|
)
|
|
# Refocus input after modal closes
|
|
if self._chat_input:
|
|
self._chat_input.focus_input()
|
|
|
|
screen = self._build_model_selector_screen()
|
|
self.push_screen(screen, handle_result)
|
|
|
|
def _register_custom_themes(self) -> None:
|
|
"""Register all custom themes (built-in LC + user-defined) with Textual."""
|
|
for name, entry in theme.get_registry().items():
|
|
if entry.custom:
|
|
c = entry.colors
|
|
try:
|
|
self.register_theme(
|
|
Theme(
|
|
name=name,
|
|
primary=c.primary,
|
|
secondary=c.secondary,
|
|
accent=c.accent,
|
|
foreground=c.foreground,
|
|
background=c.background,
|
|
surface=c.surface,
|
|
panel=c.panel,
|
|
warning=c.warning,
|
|
error=c.error,
|
|
success=c.success,
|
|
dark=entry.dark,
|
|
variables={
|
|
"footer-key-foreground": c.primary,
|
|
},
|
|
),
|
|
)
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to register theme '%s'; skipping",
|
|
name,
|
|
exc_info=True,
|
|
)
|
|
|
|
async def _show_theme_selector(self) -> None:
|
|
"""Show interactive theme selector as a modal screen."""
|
|
from deepagents_code.widgets.theme_selector import ThemeSelectorScreen
|
|
|
|
# Capture scroll state. The submit handler may have already caused
|
|
# a reflow that re-anchored to the bottom, so we save the *current*
|
|
# offset and release the anchor to prevent further drift while the
|
|
# modal is open.
|
|
chat = self.query_one("#chat", VerticalScroll)
|
|
saved_y = chat.scroll_y
|
|
was_anchored = chat.is_anchored
|
|
chat.release_anchor()
|
|
|
|
def handle_result(result: str | None) -> None:
|
|
"""Handle the theme selector result."""
|
|
if result is not None:
|
|
self.theme = result
|
|
self.sync_terminal_background()
|
|
self.refresh_css(animate=False)
|
|
|
|
async def _persist() -> None:
|
|
try:
|
|
status = await asyncio.to_thread(
|
|
_save_theme_preference_result,
|
|
result,
|
|
)
|
|
if status.message is not None:
|
|
self.notify(
|
|
status.message,
|
|
severity=status.severity,
|
|
timeout=6,
|
|
markup=False,
|
|
)
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to persist theme preference",
|
|
exc_info=True,
|
|
)
|
|
self.notify(
|
|
"Theme applied for this session but could not be saved.",
|
|
severity="warning",
|
|
timeout=6,
|
|
markup=False,
|
|
)
|
|
|
|
self.call_later(_persist)
|
|
# Restore scroll position, then re-anchor if it was anchored.
|
|
chat.scroll_to(y=saved_y, animate=False)
|
|
if was_anchored:
|
|
chat.anchor()
|
|
if self._chat_input:
|
|
self._chat_input.focus_input()
|
|
|
|
screen = ThemeSelectorScreen(
|
|
current_theme=self.theme,
|
|
terminal_default=_load_terminal_default(),
|
|
)
|
|
self.push_screen(screen, handle_result)
|
|
|
|
async def _show_agent_selector(self) -> None:
|
|
"""Show the interactive agent selector modal."""
|
|
from deepagents_code.agent import get_available_agent_names
|
|
from deepagents_code.model_config import load_default_agent
|
|
from deepagents_code.widgets.agent_selector import AgentSelectorScreen
|
|
|
|
agent_names, default_agent = await asyncio.gather(
|
|
asyncio.to_thread(get_available_agent_names),
|
|
asyncio.to_thread(load_default_agent),
|
|
)
|
|
|
|
def handle_result(result: str | None) -> None:
|
|
"""Handle the agent selector result."""
|
|
if result is not None and result != self._assistant_id:
|
|
self._switch_agent(result)
|
|
if self._chat_input:
|
|
self._chat_input.focus_input()
|
|
|
|
screen = AgentSelectorScreen(
|
|
current_agent=self._assistant_id,
|
|
agent_names=agent_names,
|
|
default_agent=default_agent,
|
|
)
|
|
self.push_screen(screen, handle_result)
|
|
|
|
async def _show_auth_manager(self) -> None:
|
|
"""Show the `/auth` credential manager modal.
|
|
|
|
State changes persist via `auth_store`; the manager refreshes its
|
|
own option labels after each save/delete, so this caller only needs
|
|
to refocus the chat input on close.
|
|
"""
|
|
from deepagents_code.widgets.auth import AuthManagerScreen
|
|
|
|
def handle_result(_result: None) -> None:
|
|
if self._chat_input:
|
|
self._chat_input.focus_input()
|
|
task = asyncio.create_task(self._maybe_start_deferred_server_from_default())
|
|
task.add_done_callback(_log_task_exception)
|
|
|
|
self.push_screen(AuthManagerScreen(), handle_result)
|
|
|
|
def _switch_agent(self, agent_name: str) -> None:
|
|
"""Switch to a different agent and hot-restart the backing server.
|
|
|
|
Runs guard checks (remote-server mode, mid-run, re-entry, missing
|
|
agent directory), then kicks off `_restart_server_for_agent_swap` as
|
|
a worker. That worker restarts the langgraph subprocess with the new
|
|
`assistant_id` so the new agent's `AGENTS.md` is actually loaded —
|
|
memory, skills, thread, and system prompt all align.
|
|
|
|
Args:
|
|
agent_name: The name of the agent to switch to.
|
|
"""
|
|
from deepagents_code.config import settings
|
|
|
|
if agent_name == self._assistant_id:
|
|
return
|
|
|
|
if self._server_kwargs is None:
|
|
# Remote-server mode: we don't own the subprocess, so we can't
|
|
# restart it. Changing identity locally would leave the running
|
|
# server's system prompt pointing at a different agent.
|
|
self.notify(
|
|
"Cannot switch agents against a remote server. "
|
|
"Relaunch the app with -a <name> instead.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
if self._server_proc is None:
|
|
if self._connecting:
|
|
|
|
async def _deferred_switch() -> None: # noqa: RUF029 # DeferredAction requires an awaitable; the UI mutation must stay on the main thread.
|
|
self._switch_agent(agent_name)
|
|
|
|
self._defer_action(
|
|
DeferredAction(
|
|
kind="agent_switch",
|
|
execute=_deferred_switch,
|
|
),
|
|
)
|
|
self.notify(
|
|
"Agent will switch after connection completes.",
|
|
timeout=3,
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
self.notify(
|
|
"Cannot switch agents until the local server is ready.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
if self._agent_running or self._shell_running:
|
|
self.notify(
|
|
"Cannot switch agents while a task is running. "
|
|
"Interrupt or wait for it to finish first.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
if self._agent_switching:
|
|
self.notify(
|
|
"Agent switch already in progress.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
try:
|
|
agent_dir_exists = (settings.user_deepagents_dir / agent_name).is_dir()
|
|
except OSError:
|
|
logger.warning(
|
|
"Could not stat agent directory for %r",
|
|
agent_name,
|
|
exc_info=True,
|
|
)
|
|
agent_dir_exists = False
|
|
|
|
if not agent_dir_exists:
|
|
self.notify(
|
|
f"Agent {agent_name!r} is no longer available.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
self._agent_switching = True
|
|
self.run_worker(
|
|
self._restart_server_for_agent_swap(agent_name),
|
|
exclusive=True,
|
|
group="agent-switch-restart",
|
|
)
|
|
|
|
async def _restart_server_for_agent_swap(self, agent_name: str) -> None:
|
|
"""Restart the langgraph server with a new `assistant_id`.
|
|
|
|
Runs in three phases so failures are attributable:
|
|
|
|
1. **UI teardown** — flip banner to connecting, clear chat, reject
|
|
pending HITL widgets, reset the thread. Failures here notify the
|
|
user and return early; the previous server is still alive and
|
|
identity is untouched.
|
|
2. **Server restart** — mutate `_assistant_id`, stage the new
|
|
`DEEPAGENTS_CODE_SERVER_ASSISTANT_ID` env var, call
|
|
`ServerProcess.restart()`, and rebuild the `RemoteAgent` against
|
|
the (possibly new) server URL. A failure rolls back identity and
|
|
posts `ServerStartFailed` because the old subprocess is dead.
|
|
3. **Confirmation** — show "Switched to X", optional resume hint,
|
|
persist the recent agent, and drain any messages queued during
|
|
the swap.
|
|
|
|
Args:
|
|
agent_name: The name of the agent to switch to.
|
|
"""
|
|
from deepagents_code._env_vars import SERVER_ENV_PREFIX
|
|
from deepagents_code.remote_client import RemoteAgent as _RemoteAgent
|
|
|
|
def _build_agent(url: str) -> Any: # noqa: ANN401 # see docstring
|
|
"""Build a new `RemoteAgent` typed as `Any`.
|
|
|
|
Returns `Any` so `self._agent`'s attribute type stays aligned
|
|
with the permissive type the startup path assigns, avoiding a
|
|
union that would trip call-site type checks on
|
|
`aget_state(config)` et al.
|
|
|
|
Args:
|
|
url: Server base URL to point the new client at.
|
|
|
|
Returns:
|
|
A fresh `RemoteAgent`, exposed as `Any`.
|
|
"""
|
|
return _RemoteAgent(url=url, graph_name="agent")
|
|
|
|
previous_agent = self._assistant_id
|
|
previous_default_agent = self._default_assistant_id
|
|
previous_thread_id = self._lc_thread_id
|
|
# Only offer a resume hint if the previous thread produced agent-side
|
|
# output. `USER` alone is not enough: local-only flows (`/update`,
|
|
# `!shell`, most slash commands) mount a `UserMessage` widget without
|
|
# ever invoking the server, so no checkpoint exists and `-r <thread>`
|
|
# would fail. `ASSISTANT` / `TOOL` / `SKILL` entries only land in the
|
|
# store after a server round-trip, which implies a checkpoint row.
|
|
checkpoint_signal_types = {
|
|
MessageType.ASSISTANT,
|
|
MessageType.TOOL,
|
|
MessageType.SKILL,
|
|
}
|
|
previous_thread_has_agent_output = any(
|
|
msg.type in checkpoint_signal_types
|
|
for msg in self._message_store.get_all_messages()
|
|
)
|
|
server_proc = self._server_proc
|
|
if server_proc is None:
|
|
# Guarded in _switch_agent, but the worker runs in the next tick
|
|
# so re-check to keep the type narrow.
|
|
self._agent_switching = False
|
|
return
|
|
|
|
try:
|
|
# Phase 1: UI teardown. A failure here does NOT mean the server
|
|
# is gone — we notify the user and bail out with the previous
|
|
# agent still live. Only Phase 2 escalates to ServerStartFailed.
|
|
try:
|
|
self._connecting = True
|
|
self._agent = None
|
|
try:
|
|
banner = self.query_one("#welcome-banner", WelcomeBanner)
|
|
banner.set_connecting()
|
|
except NoMatches:
|
|
pass
|
|
|
|
if self._chat_input:
|
|
self._chat_input.set_cursor_active(active=False)
|
|
|
|
# Reject pending HITL prompts — they're bound to the old
|
|
# server's in-flight request and won't be resolved after
|
|
# restart. Wrap each call narrowly so a widget-cleanup bug
|
|
# can't abort the swap.
|
|
if self._pending_approval_widget is not None:
|
|
try:
|
|
self._pending_approval_widget.action_select_reject()
|
|
except Exception:
|
|
logger.debug(
|
|
"Failed to reject pending approval during agent swap",
|
|
exc_info=True,
|
|
)
|
|
if self._pending_ask_user_widget is not None:
|
|
try:
|
|
await self._pending_ask_user_widget.remove()
|
|
except Exception:
|
|
logger.debug(
|
|
"Failed to remove pending ask_user during agent swap",
|
|
exc_info=True,
|
|
)
|
|
self._pending_ask_user_widget = None
|
|
|
|
self._pending_messages.clear()
|
|
for widget in self._queued_widgets:
|
|
try:
|
|
await widget.remove()
|
|
except Exception:
|
|
logger.debug(
|
|
"Failed to remove queued widget during agent swap",
|
|
exc_info=True,
|
|
)
|
|
self._queued_widgets.clear()
|
|
self._deferred_actions.clear()
|
|
|
|
await self._clear_messages()
|
|
self._context_tokens = 0
|
|
self._tokens_approximate = False
|
|
self._update_tokens(0)
|
|
self._update_status("")
|
|
|
|
if self._session_state:
|
|
new_thread_id = self._session_state.reset_thread()
|
|
self._lc_thread_id = new_thread_id
|
|
self._update_welcome_banner(
|
|
new_thread_id,
|
|
missing_message=(
|
|
"Welcome banner not found during agent switch to %s"
|
|
),
|
|
warn_if_missing=False,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"UI teardown failed during agent swap to %r",
|
|
agent_name,
|
|
)
|
|
# Restore the previous-agent UI state so the user isn't
|
|
# stuck on a permanent "Connecting..." banner.
|
|
self._connecting = False
|
|
try:
|
|
banner = self.query_one("#welcome-banner", WelcomeBanner)
|
|
banner.set_connected(
|
|
self._mcp_tool_count,
|
|
mcp_unauthenticated=self._mcp_unauthenticated,
|
|
mcp_errored=self._mcp_errored,
|
|
)
|
|
except NoMatches:
|
|
pass
|
|
self.notify(
|
|
f"Could not prepare to switch to {agent_name!r}. "
|
|
"Staying on current agent.",
|
|
severity="error",
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
# Phase 2: server restart. Identity is mutated BEFORE
|
|
# `restart()` so the subprocess picks up the new assistant_id
|
|
# from the staged env override; on failure, both are rolled
|
|
# back and the old server is confirmed dead (ServerStartFailed).
|
|
# Picker switches are explicit user choice, so update both the
|
|
# session id and the persisted default.
|
|
self._assistant_id = agent_name
|
|
self._default_assistant_id = agent_name
|
|
if self._server_kwargs is not None:
|
|
self._server_kwargs["assistant_id"] = agent_name
|
|
|
|
try:
|
|
server_proc.update_env(
|
|
**{f"{SERVER_ENV_PREFIX}ASSISTANT_ID": agent_name},
|
|
)
|
|
await server_proc.restart()
|
|
# `ServerProcess.restart()` may rebind to a different port
|
|
# if the original is still in TIME_WAIT, so rebuild the
|
|
# client against the current URL rather than reusing it.
|
|
self._agent = _build_agent(server_proc.url)
|
|
except Exception as exc:
|
|
self._assistant_id = previous_agent
|
|
self._default_assistant_id = previous_default_agent
|
|
if self._server_kwargs is not None:
|
|
self._server_kwargs["assistant_id"] = previous_agent
|
|
self._agent = None
|
|
self._connecting = False
|
|
logger.exception(
|
|
"Server restart failed during agent swap to %r",
|
|
agent_name,
|
|
)
|
|
self.post_message(self.ServerStartFailed(error=exc))
|
|
return
|
|
|
|
# Phase 3: confirmation. Past here all failures are
|
|
# cosmetic — the new server is healthy.
|
|
self._connecting = False
|
|
try:
|
|
banner = self.query_one("#welcome-banner", WelcomeBanner)
|
|
banner.set_connected(self._mcp_tool_count)
|
|
except NoMatches:
|
|
pass
|
|
|
|
# Refresh skills so /skill: autocomplete reflects the new agent's
|
|
# SKILL.md files.
|
|
self.run_worker(
|
|
self._discover_skills(),
|
|
exclusive=True,
|
|
group="agent-switch-skill-discovery",
|
|
)
|
|
|
|
# Persist the swap so a bare `deepagents` relaunch brings the
|
|
# user back to this agent (same pattern as `save_recent_model`).
|
|
# Offloaded to a thread to avoid blocking the event loop on disk I/O.
|
|
from deepagents_code.model_config import save_recent_agent
|
|
|
|
saved = await asyncio.to_thread(save_recent_agent, agent_name)
|
|
if not saved:
|
|
logger.warning(
|
|
"Could not persist recent agent %r to config; "
|
|
"next bare launch will not return to it",
|
|
agent_name,
|
|
)
|
|
|
|
# Mount the "Switched to X" confirmation BEFORE surfacing any
|
|
# save-failure toast. Otherwise the toast hovers next to a
|
|
# success line that scrolls past, which makes the causality
|
|
# confusing — the user reads success while the toast warns.
|
|
confirmation = Content.from_markup(
|
|
"Switched to $name. New thread started.",
|
|
name=agent_name,
|
|
)
|
|
await self._mount_message(AppMessage(confirmation))
|
|
|
|
if not saved:
|
|
# Surface the failure visibly — silent logger.warnings
|
|
# leave users wondering why their picker selection didn't
|
|
# stick across launches. See `model_config.save_recent_agent`
|
|
# for the underlying I/O codepath.
|
|
self.notify(
|
|
"Could not save recent agent to config; "
|
|
"next bare launch will not return to it.",
|
|
severity="warning",
|
|
timeout=6,
|
|
markup=False,
|
|
)
|
|
|
|
# Surface a resume command for the previous session so the
|
|
# previous thread isn't stranded out of reach. `-r <thread>`
|
|
# alone is enough: `_resolve_resume_thread` infers the owning
|
|
# agent from persisted thread metadata via `get_thread_agent`.
|
|
# Build via `from_markup` so a thread ID with stray brackets
|
|
# can't corrupt rendering. See checkpoint-gating rationale on
|
|
# `previous_thread_has_agent_output` above.
|
|
if previous_thread_id and previous_thread_has_agent_output:
|
|
resume_hint = Content.from_markup(
|
|
"[dim]Relaunch with[/dim] dcode -r $thread "
|
|
"[dim]to resume the previous thread.[/dim]",
|
|
thread=previous_thread_id,
|
|
)
|
|
await self._mount_message(AppMessage(resume_hint))
|
|
|
|
# Drain any messages the user typed after we cleared the queue
|
|
# but before the new server was ready.
|
|
if self._pending_messages and not self._agent_running:
|
|
self.call_after_refresh(
|
|
lambda: asyncio.create_task(self._process_next_from_queue()),
|
|
)
|
|
finally:
|
|
self._agent_switching = False
|
|
if self._chat_input:
|
|
self._chat_input.set_cursor_active(active=not self._agent_running)
|
|
|
|
async def _show_notification_settings(self) -> None:
|
|
"""Show notification settings modal."""
|
|
from deepagents_code.model_config import is_warning_suppressed
|
|
from deepagents_code.widgets.notification_settings import (
|
|
WARNING_TOGGLES,
|
|
NotificationSettingsScreen,
|
|
)
|
|
|
|
suppressed: set[str] = set()
|
|
try:
|
|
for key, _ in WARNING_TOGGLES:
|
|
if await asyncio.to_thread(is_warning_suppressed, key):
|
|
suppressed.add(key)
|
|
except Exception:
|
|
logger.warning("Failed to read notification settings", exc_info=True)
|
|
suppressed = set()
|
|
self.notify(
|
|
"Could not read notification preferences. Showing defaults.",
|
|
severity="warning",
|
|
timeout=6,
|
|
markup=False,
|
|
)
|
|
|
|
def handle_result(_result: None) -> None:
|
|
if self._chat_input:
|
|
self._chat_input.focus_input()
|
|
|
|
screen = NotificationSettingsScreen(suppressed=suppressed)
|
|
self.push_screen(screen, handle_result)
|
|
|
|
def _notify_actionable(
|
|
self,
|
|
notification: PendingNotification,
|
|
*,
|
|
severity: Literal["information", "warning", "error"] = "information",
|
|
timeout: float | None = None,
|
|
action_hint: str = "Press ctrl+n to review and take action.",
|
|
) -> None:
|
|
"""Register *notification* and post its actionable toast.
|
|
|
|
Posts the toast as a raw `Notification` so the identity can be
|
|
captured and bound to the registry entry for click routing.
|
|
|
|
Args:
|
|
notification: Registry entry to register and surface.
|
|
severity: Toast severity banner color.
|
|
timeout: Seconds the toast stays on screen (defaults to
|
|
`App.NOTIFICATION_TIMEOUT`).
|
|
action_hint: Final call-to-action line for the toast.
|
|
"""
|
|
self._notice_registry.add(notification)
|
|
|
|
toast_body = f"{notification.body}\n\n{action_hint}"
|
|
effective_timeout = (
|
|
timeout if timeout is not None else self.NOTIFICATION_TIMEOUT
|
|
)
|
|
# `markup=False` is load-bearing: `notification.body` can carry
|
|
# dynamic content (tool names, versions, URLs, exception text)
|
|
# with square brackets that would crash Textual's toast
|
|
# renderer if parsed as Rich markup.
|
|
toast = _Notification(
|
|
message=toast_body,
|
|
title=notification.title,
|
|
severity=severity,
|
|
timeout=effective_timeout,
|
|
markup=False,
|
|
)
|
|
self._notice_registry.bind_toast(notification.key, toast.identity)
|
|
self.post_message(_Notify(toast))
|
|
|
|
def _inject_debug_notifications(self) -> None:
|
|
"""Register sample missing-dependency entries for UI testing.
|
|
|
|
Gated by `DEEPAGENTS_CODE_DEBUG_NOTIFICATIONS`; no-op without it.
|
|
Uses `_notify_actionable` so each entry also posts a clickable
|
|
toast — mirroring the real missing-dep path and exercising both
|
|
the toast surface and the notification center.
|
|
|
|
Deliberately does *not* register an update-available entry or
|
|
open the update modal — that flow is exercised via
|
|
`DEEPAGENTS_CODE_DEBUG_UPDATE` / `_inject_debug_update`, so the
|
|
notification center can be browsed without focus being stolen
|
|
by the update modal.
|
|
"""
|
|
try:
|
|
from deepagents_code.main import build_missing_tool_notification
|
|
except ImportError:
|
|
logger.warning(
|
|
"Could not inject debug notifications; main import failed",
|
|
exc_info=True,
|
|
)
|
|
return
|
|
|
|
for tool in ("ripgrep", "tavily"):
|
|
self._notify_actionable(
|
|
build_missing_tool_notification(tool),
|
|
severity="warning",
|
|
timeout=15,
|
|
)
|
|
|
|
def _inject_debug_update(self) -> None:
|
|
"""Register a sample update entry and auto-open the update modal.
|
|
|
|
Gated by `DEEPAGENTS_CODE_DEBUG_UPDATE`; no-op without it.
|
|
Mirrors the real update-check path so the dedicated modal can
|
|
be exercised without waiting for a PyPI release.
|
|
"""
|
|
update_notification = self._build_update_notification(
|
|
latest="9.9.9",
|
|
cli_version="0.1.0",
|
|
release_age=" (released 2 days ago)",
|
|
installed_age="",
|
|
upgrade_cmd="uv tool upgrade deepagents-code",
|
|
)
|
|
self._notice_registry.add(update_notification)
|
|
self._update_modal_pending.set()
|
|
self.call_after_refresh(self._open_update_available_modal, update_notification)
|
|
|
|
def action_open_notifications(self) -> None:
|
|
"""Open the notification center via the `ctrl+n` keybind."""
|
|
self._open_notification_center()
|
|
|
|
def _open_notification_center(self) -> None:
|
|
"""Push the notification center modal, or toast when empty."""
|
|
from deepagents_code.widgets.notification_center import (
|
|
NotificationActionResult,
|
|
NotificationCenterScreen,
|
|
)
|
|
|
|
if isinstance(self.screen, ModalScreen):
|
|
# Don't stack on top of another modal (e.g. approval, model
|
|
# selector). Surface feedback so the user knows why ctrl+n
|
|
# appeared to do nothing.
|
|
self.notify(
|
|
"Close the current dialog to view notifications.",
|
|
severity="information",
|
|
timeout=3,
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
pending = self._notice_registry.list_all()
|
|
if not pending:
|
|
self.notify(
|
|
"No pending notifications.",
|
|
severity="information",
|
|
timeout=2,
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
self._dismiss_registered_toasts()
|
|
|
|
def handle_result(result: NotificationActionResult | None) -> None:
|
|
if result is not None:
|
|
self.run_worker(
|
|
self._dispatch_notification_action(result.key, result.action_id),
|
|
exclusive=False,
|
|
group=f"notification-action-{result.key}",
|
|
)
|
|
elif self._chat_input:
|
|
self._chat_input.focus_input()
|
|
|
|
self.push_screen(NotificationCenterScreen(pending), handle_result)
|
|
|
|
def _dismiss_registered_toasts(self) -> None:
|
|
"""Drop toasts bound to pending notifications.
|
|
|
|
Called when the notification center opens so the live toast
|
|
surface doesn't duplicate the modal list. Only toasts classified
|
|
as actionable by `NotificationRegistry.is_actionable_toast` are
|
|
dismissed; unrelated toasts (errors, generic info toasts) stay
|
|
visible.
|
|
"""
|
|
to_dismiss = [
|
|
notif
|
|
for notif in list(self._notifications)
|
|
if self._notice_registry.is_actionable_toast(notif.identity)
|
|
]
|
|
if not to_dismiss:
|
|
return
|
|
for notif in to_dismiss:
|
|
self._unnotify(notif, refresh=False)
|
|
self._notice_registry.unbind_toast(notif.identity)
|
|
self._refresh_notifications()
|
|
|
|
async def on_notification_suppress_requested(
|
|
self,
|
|
message: NotificationSuppressRequested,
|
|
) -> None:
|
|
"""Suppress the notice in place and refresh the open center."""
|
|
from deepagents_code.widgets.notification_center import NotificationCenterScreen
|
|
|
|
message.stop()
|
|
await self._dispatch_notification_action(message.key, ActionId.SUPPRESS)
|
|
screen = self.screen
|
|
if not isinstance(screen, NotificationCenterScreen):
|
|
return
|
|
try:
|
|
await screen.reload(self._notice_registry.list_all())
|
|
except Exception as exc: # defend against dismiss/mount races
|
|
# A concurrent dismissal can detach the VerticalScroll before
|
|
# `reload` queries it. The worst case is a stale row list,
|
|
# which the next open of the center will heal. Log + toast
|
|
# so the failure surfaces instead of vanishing into a worker.
|
|
logger.warning(
|
|
"Failed to refresh notification center after suppress: %s",
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
self.notify(
|
|
f"Could not refresh notifications: {type(exc).__name__}: {exc}",
|
|
severity="warning",
|
|
timeout=6,
|
|
markup=False,
|
|
)
|
|
|
|
def _open_update_available_modal(self, entry: PendingNotification) -> None:
|
|
"""Push the dedicated update-available modal for *entry*.
|
|
|
|
When another modal is already open the entry stays registered
|
|
and a toast hint points the user at `ctrl+n` once the blocking
|
|
modal closes. Also clears `_update_modal_pending` so
|
|
missing-dep toasts stop suppressing themselves.
|
|
"""
|
|
from deepagents_code.widgets.update_available import UpdateAvailableScreen
|
|
|
|
if isinstance(self.screen, ModalScreen):
|
|
# We can't stack; leave the entry in the registry and tell
|
|
# the user how to reach it.
|
|
self._update_modal_pending.clear()
|
|
self.notify(
|
|
"Update available. Your session will not be interrupted. "
|
|
"Press ctrl+n to review it.",
|
|
severity="information",
|
|
timeout=8,
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
# Textual layers are per-screen, so base-screen toasts visually
|
|
# bleed through the modal's dim. Drop them before opening so
|
|
# the modal reads cleanly; underlying notification entries
|
|
# stay in the registry and remain reachable via ctrl+n.
|
|
self.clear_notifications()
|
|
|
|
def handle_result(result: ActionId | None) -> None:
|
|
if result is not None:
|
|
self.run_worker(
|
|
self._dispatch_notification_action(entry.key, result),
|
|
exclusive=False,
|
|
group=f"notification-action-{entry.key}",
|
|
)
|
|
elif self._chat_input:
|
|
self._chat_input.focus_input()
|
|
|
|
self.push_screen(UpdateAvailableScreen(entry), handle_result)
|
|
|
|
async def _dispatch_notification_action(
|
|
self,
|
|
key: str,
|
|
action_id: ActionId,
|
|
) -> None:
|
|
"""Execute the side effect for a notification action.
|
|
|
|
Catches `Exception` broadly so any failure in the handler
|
|
surfaces as a warning toast instead of vanishing into the
|
|
background worker's log — this is the user-visibility guarantee
|
|
the registry is designed to provide.
|
|
|
|
Args:
|
|
key: Registry key of the notification.
|
|
action_id: The action the user selected.
|
|
"""
|
|
entry = self._notice_registry.get(key)
|
|
if entry is None:
|
|
return
|
|
|
|
action_label = _action_label(entry, action_id)
|
|
try:
|
|
await self._route_payload_action(entry, action_id)
|
|
except Exception as exc: # every failure surfaces to the user
|
|
logger.warning(
|
|
"Action %r on %r failed: %s",
|
|
action_id,
|
|
key,
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
self.notify(
|
|
f"{action_label} failed: {type(exc).__name__}: {exc}",
|
|
severity="warning",
|
|
timeout=8,
|
|
markup=False,
|
|
)
|
|
|
|
if self._chat_input:
|
|
self._chat_input.focus_input()
|
|
|
|
async def _route_payload_action(
|
|
self,
|
|
entry: PendingNotification,
|
|
action_id: ActionId,
|
|
) -> None:
|
|
"""Dispatch *action_id* to the payload-specific handler.
|
|
|
|
Raises:
|
|
TypeError: When `entry.payload` has no registered handler.
|
|
"""
|
|
if isinstance(entry.payload, MissingDepPayload):
|
|
await self._handle_missing_dep_action(entry, entry.payload, action_id)
|
|
return
|
|
if isinstance(entry.payload, UpdateAvailablePayload):
|
|
await self._handle_update_action(entry, entry.payload, action_id)
|
|
return
|
|
msg = f"unhandled payload type {type(entry.payload).__name__}"
|
|
raise TypeError(msg)
|
|
|
|
@staticmethod
|
|
def _log_unknown_action(entry: PendingNotification, action_id: ActionId) -> None:
|
|
"""Log a warning for an action id the handler does not recognize."""
|
|
logger.warning(
|
|
"Unknown action_id %r for %s entry %s",
|
|
action_id,
|
|
type(entry.payload).__name__,
|
|
entry.key,
|
|
)
|
|
|
|
async def _handle_missing_dep_action(
|
|
self,
|
|
entry: PendingNotification,
|
|
payload: MissingDepPayload,
|
|
action_id: ActionId,
|
|
) -> None:
|
|
"""Complete a missing-dependency action.
|
|
|
|
Args:
|
|
entry: The notification entry for the affected tool.
|
|
payload: Typed payload (tool name + install hint or URL).
|
|
action_id: The specific action the user selected.
|
|
Unknown ids are logged and treated as a no-op.
|
|
"""
|
|
if action_id == ActionId.SUPPRESS:
|
|
from deepagents_code._env_vars import DEBUG_NOTIFICATIONS
|
|
from deepagents_code.model_config import suppress_warning
|
|
|
|
# Debug mode injects sample entries via `_inject_debug_notifications`
|
|
# — persisted suppressions would silence the real warning on
|
|
# subsequent runs, defeating the point of replaying the UI.
|
|
if os.environ.get(DEBUG_NOTIFICATIONS):
|
|
self._notice_registry.remove(entry.key)
|
|
self.notify(
|
|
f"Suppressed {payload.tool} (debug mode; not persisted).",
|
|
severity="information",
|
|
timeout=4,
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
if await asyncio.to_thread(suppress_warning, payload.tool):
|
|
self._notice_registry.remove(entry.key)
|
|
self.notify(
|
|
f"Won't warn about {payload.tool} again.",
|
|
severity="information",
|
|
timeout=4,
|
|
markup=False,
|
|
)
|
|
else:
|
|
self.notify(
|
|
"Could not save notification preference. "
|
|
"Check file permissions for ~/.deepagents/config.toml.",
|
|
severity="warning",
|
|
timeout=6,
|
|
markup=False,
|
|
)
|
|
return
|
|
if action_id == ActionId.COPY_INSTALL:
|
|
if payload.install_command is None:
|
|
logger.warning(
|
|
"COPY_INSTALL action fired without install_command on %r",
|
|
entry.key,
|
|
)
|
|
self.notify(
|
|
"No install command recorded for this notification.",
|
|
severity="warning",
|
|
timeout=6,
|
|
markup=False,
|
|
)
|
|
return
|
|
self.copy_to_clipboard(payload.install_command)
|
|
self.notify(
|
|
f"Copied: {payload.install_command}",
|
|
severity="information",
|
|
timeout=4,
|
|
markup=False,
|
|
)
|
|
return
|
|
if action_id == ActionId.OPEN_WEBSITE:
|
|
if payload.url is None:
|
|
logger.warning("OPEN_WEBSITE action fired without url on %r", entry.key)
|
|
self.notify(
|
|
"No URL recorded for this notification.",
|
|
severity="warning",
|
|
timeout=6,
|
|
markup=False,
|
|
)
|
|
return
|
|
if await open_url_async(payload.url, app=self):
|
|
self.notify(
|
|
f"Opened {payload.url}",
|
|
severity="information",
|
|
timeout=3,
|
|
markup=False,
|
|
)
|
|
return
|
|
self._log_unknown_action(entry, action_id)
|
|
|
|
async def _handle_update_action(
|
|
self,
|
|
entry: PendingNotification,
|
|
payload: UpdateAvailablePayload,
|
|
action_id: ActionId,
|
|
) -> None:
|
|
"""Complete an update-available action.
|
|
|
|
Args:
|
|
entry: The update notification entry.
|
|
payload: Typed payload (target version + upgrade command).
|
|
action_id: The specific action the user selected.
|
|
Unknown ids are logged and treated as a no-op.
|
|
"""
|
|
from deepagents_code.update_check import (
|
|
clear_update_notified,
|
|
create_update_log_path,
|
|
mark_update_notified,
|
|
perform_upgrade,
|
|
upgrade_command,
|
|
)
|
|
|
|
if action_id == ActionId.INSTALL:
|
|
from deepagents_code._env_vars import DEBUG_UPDATE
|
|
|
|
if self._update_install_running:
|
|
self.notify(
|
|
"Update already running.",
|
|
severity="information",
|
|
timeout=4,
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
from deepagents_code.widgets.update_progress import UpdateProgressScreen
|
|
|
|
cmd = upgrade_command()
|
|
log_path = create_update_log_path()
|
|
screen = UpdateProgressScreen(
|
|
latest=payload.latest,
|
|
command=cmd,
|
|
log_path=log_path,
|
|
)
|
|
progress_modal_visible = not isinstance(self.screen, ModalScreen)
|
|
if progress_modal_visible:
|
|
await self.push_screen(screen)
|
|
else:
|
|
self.notify(
|
|
f"Updating to v{payload.latest}... Logs: {log_path}",
|
|
severity="information",
|
|
timeout=8,
|
|
markup=False,
|
|
)
|
|
self._update_install_running = True
|
|
try:
|
|
if os.environ.get(DEBUG_UPDATE):
|
|
await self._run_debug_update_install(
|
|
entry=entry,
|
|
payload=payload,
|
|
screen=screen,
|
|
log_path=log_path,
|
|
show_toast=not progress_modal_visible,
|
|
)
|
|
return
|
|
success, output = await perform_upgrade(
|
|
progress=screen.append_line,
|
|
log_path=log_path,
|
|
)
|
|
if success:
|
|
self._notice_registry.remove(entry.key)
|
|
screen.mark_success()
|
|
if not progress_modal_visible:
|
|
self.notify(
|
|
f"Updated to v{payload.latest}. "
|
|
"Restart to use the new version.",
|
|
severity="information",
|
|
timeout=10,
|
|
markup=False,
|
|
)
|
|
return
|
|
logger.warning(
|
|
"Auto-upgrade failed for v%s. Output:\n%s",
|
|
payload.latest,
|
|
output,
|
|
)
|
|
self._notice_registry.remove(entry.key)
|
|
screen.mark_failure(cmd)
|
|
snippet = _truncate(output, limit=160) if output else ""
|
|
message = f"Auto-update failed. Run manually: {cmd}"
|
|
if snippet:
|
|
message = f"{message}\n{snippet}"
|
|
self.notify(
|
|
message,
|
|
severity="warning",
|
|
timeout=15,
|
|
markup=False,
|
|
)
|
|
finally:
|
|
self._update_install_running = False
|
|
return
|
|
if action_id == ActionId.SKIP_VERSION:
|
|
await asyncio.to_thread(mark_update_notified, payload.latest)
|
|
self._notice_registry.remove(entry.key)
|
|
self.notify(
|
|
f"Skipped v{payload.latest}.",
|
|
severity="information",
|
|
timeout=4,
|
|
markup=False,
|
|
)
|
|
return
|
|
if action_id == ActionId.SKIP_ONCE:
|
|
await asyncio.to_thread(clear_update_notified)
|
|
self._notice_registry.remove(entry.key)
|
|
self.notify(
|
|
"We'll remind you next launch.",
|
|
severity="information",
|
|
timeout=4,
|
|
markup=False,
|
|
)
|
|
return
|
|
self._log_unknown_action(entry, action_id)
|
|
|
|
async def _run_debug_update_install(
|
|
self,
|
|
*,
|
|
entry: PendingNotification,
|
|
payload: UpdateAvailablePayload,
|
|
screen: UpdateProgressScreen,
|
|
log_path: Path,
|
|
show_toast: bool,
|
|
) -> None:
|
|
"""Exercise the update progress UI without invoking a package manager.
|
|
|
|
Args:
|
|
entry: The update notification entry to clear when complete.
|
|
payload: Update payload with the mocked target version.
|
|
screen: Progress modal to update.
|
|
log_path: Debug log path to write mock output into.
|
|
show_toast: Whether to show a completion toast.
|
|
"""
|
|
steps = (
|
|
("Debug mode: no package manager command was started.", 0.3),
|
|
(f"Resolving deepagents-code v{payload.latest}...", 0.8),
|
|
("Looking up compatible build tags...", 0.2),
|
|
("Downloading wheel metadata...", 0.5),
|
|
("Downloading deepagents_code-9.9.9-py3-none-any.whl...", 0.2),
|
|
("Downloading dependency metadata...", 0.2),
|
|
("Unpacking wheel...", 0.9),
|
|
("Checking installed entry points...", 0.2),
|
|
("Removing previous console script...", 0.2),
|
|
("Installing files...", 0.7),
|
|
("Writing dist-info metadata...", 0.2),
|
|
("Rebuilding executable shims...", 0.2),
|
|
("Validating import metadata...", 0.2),
|
|
("Verifying console script...", 0.4),
|
|
("Cleaning temporary build directory...", 0.2),
|
|
("Recording update receipt...", 0.2),
|
|
("Update complete.", 0.2),
|
|
)
|
|
wrote_log = False
|
|
try:
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with log_path.open("w", encoding="utf-8") as log:
|
|
log.write("$ debug mock update\n")
|
|
for line, delay in steps:
|
|
log.write(f"{line}\n")
|
|
log.flush()
|
|
screen.append_line(line)
|
|
await asyncio.sleep(delay)
|
|
wrote_log = True
|
|
except OSError:
|
|
logger.debug("Could not write debug update log", exc_info=True)
|
|
|
|
if not wrote_log:
|
|
for line, delay in steps:
|
|
screen.append_line(line)
|
|
await asyncio.sleep(delay)
|
|
self._notice_registry.remove(entry.key)
|
|
screen.mark_success()
|
|
if show_toast:
|
|
self.notify(
|
|
"Mock update complete (debug mode).",
|
|
severity="information",
|
|
timeout=5,
|
|
markup=False,
|
|
)
|
|
|
|
async def _handle_mcp_subcommand(self, args: str) -> None:
|
|
"""Dispatch `/mcp <subcommand>` strings.
|
|
|
|
Currently supports `login <server>`; unknown subcommands surface
|
|
an inline help message.
|
|
|
|
Args:
|
|
args: Everything after `/mcp ` (already stripped).
|
|
"""
|
|
parts = args.split(maxsplit=1)
|
|
if not parts:
|
|
await self._show_mcp_viewer()
|
|
return
|
|
subcommand = parts[0].lower()
|
|
rest = parts[1].strip() if len(parts) > 1 else ""
|
|
if subcommand == "login":
|
|
if not rest:
|
|
await self._mount_message(AppMessage("Usage: /mcp login <server>"))
|
|
return
|
|
server_name = rest.split()[0]
|
|
self._start_mcp_login(server_name)
|
|
return
|
|
if subcommand == "reconnect":
|
|
force, valid = _parse_reconnect_args(rest)
|
|
if not valid:
|
|
await self._mount_message(
|
|
AppMessage("Usage: /mcp reconnect [force]"),
|
|
)
|
|
return
|
|
await self._handle_mcp_reconnect_command(force=force)
|
|
return
|
|
await self._mount_message(
|
|
AppMessage(
|
|
f"Unknown `/mcp` subcommand: {subcommand!r}. "
|
|
"Try `/mcp`, `/mcp login <server>`, or `/mcp reconnect`.",
|
|
),
|
|
)
|
|
|
|
def _sync_pending_mcp_reconnect(self) -> None:
|
|
"""Refresh the aggregate MCP reconnect flag from tracked reasons."""
|
|
self._pending_mcp_reconnect = self._pending_mcp_login_reconnect or bool(
|
|
self._pending_mcp_disable_reconnect_servers
|
|
)
|
|
|
|
async def _handle_mcp_reconnect_command(self, *, force: bool = False) -> None:
|
|
"""Restart the server to pick up any deferred MCP login tokens.
|
|
|
|
No-op (with an inline notice) when nothing is pending so the
|
|
command is safe to run idempotently. `force=True` bypasses the
|
|
no-op guard via a confirmation modal — the escape hatch for
|
|
stale-cache or externally-edited-config cases where the server
|
|
needs a fresh load even though no login is queued in this
|
|
session.
|
|
|
|
Args:
|
|
force: When `True`, prompt to restart unconditionally even
|
|
if no MCP login is queued.
|
|
"""
|
|
if self._pending_mcp_reconnect:
|
|
await self._restart_server_for_mcp_refresh("pending login")
|
|
return
|
|
if not force:
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"No MCP login is queued in this session. "
|
|
"If you logged in during an earlier run, relaunch "
|
|
"dcode to pick up the token. "
|
|
"Run `/mcp reconnect force` to restart anyway.",
|
|
),
|
|
)
|
|
return
|
|
from deepagents_code.widgets.mcp_reconnect import (
|
|
MCPReconnectForceConfirmScreen,
|
|
)
|
|
|
|
confirmed = await self._push_screen_wait(MCPReconnectForceConfirmScreen())
|
|
if confirmed:
|
|
await self._restart_server_for_mcp_refresh("forced reconnect")
|
|
|
|
async def _show_mcp_viewer(self) -> None:
|
|
"""Show the MCP server/tool viewer as a modal screen.
|
|
|
|
The viewer may dismiss with a server name (when the user activates
|
|
an `unauthenticated` header row to start in-TUI OAuth login) or
|
|
with `None` (close without action).
|
|
"""
|
|
from deepagents_code.widgets.mcp_viewer import (
|
|
MCP_VIEWER_RECONNECT_REQUEST,
|
|
MCPViewerScreen,
|
|
)
|
|
|
|
screen = MCPViewerScreen(
|
|
server_info=self._mcp_server_info or [],
|
|
connecting=self._connecting,
|
|
pending_reconnect=self._pending_mcp_reconnect,
|
|
on_toggle_disable=self._toggle_mcp_server_disabled,
|
|
)
|
|
self._active_mcp_viewer = screen
|
|
|
|
def handle_result(result: str | None) -> None:
|
|
self._active_mcp_viewer = None
|
|
if result == MCP_VIEWER_RECONNECT_REQUEST:
|
|
# `action_reconnect` gates dismiss on pending state, so
|
|
# `force=False` is correct.
|
|
self.call_later(self._reconnect_from_viewer_safe)
|
|
return
|
|
if result:
|
|
# User picked an unauthenticated server — start login.
|
|
self._start_mcp_login(result)
|
|
elif self._chat_input:
|
|
self._chat_input.focus_input()
|
|
|
|
self.push_screen(screen, handle_result)
|
|
|
|
async def _reconnect_from_viewer_safe(self) -> None:
|
|
"""Run the post-viewer reconnect and surface unexpected failures.
|
|
|
|
`call_later` schedules this on Textual's message pump, which
|
|
logs but does not display exceptions. Re-checks pending state
|
|
so a flip between dismiss and the pump tick silently no-ops
|
|
instead of degrading to the CLI no-op notice.
|
|
"""
|
|
if not self._pending_mcp_reconnect:
|
|
return
|
|
try:
|
|
await self._handle_mcp_reconnect_command()
|
|
except Exception as exc:
|
|
logger.exception("Reconnect after viewer dismiss failed")
|
|
await self._mount_message(
|
|
ErrorMessage(f"Reconnect failed: {type(exc).__name__}: {exc}"),
|
|
)
|
|
|
|
async def _toggle_mcp_server_disabled(self, server_name: str) -> None:
|
|
"""Flip the persistent disabled state for `server_name` and signal a reconnect.
|
|
|
|
Looks up the current state from the loaded `MCPServerInfo` list so
|
|
the toggle is correct regardless of whether the server was disabled
|
|
in a previous session or by an external edit of `config.toml`.
|
|
Persists the new value, updates pending reconnect state, and
|
|
refreshes the open viewer in-place — keeping the cursor on the
|
|
same server header — so the user sees the updated status without
|
|
a screen-swap flicker.
|
|
|
|
Args:
|
|
server_name: Name of the MCP server to toggle. Empty names
|
|
are impossible by construction (the only caller pulls
|
|
from `MCPServerHeaderItem.server.name`) and silently
|
|
no-op as defense-in-depth. Unknown names — possible if
|
|
config was reloaded between the viewer opening and F2 —
|
|
surface a toast so the user knows F2 didn't take effect.
|
|
"""
|
|
if not server_name:
|
|
logger.debug("Empty server name in disable toggle; ignoring")
|
|
return
|
|
known_names = {info.name for info in self._mcp_server_info or ()}
|
|
if server_name not in known_names:
|
|
logger.warning(
|
|
"Unknown server %r in disable toggle; ignoring",
|
|
server_name,
|
|
)
|
|
self.notify(
|
|
f"MCP server {server_name!r} is no longer configured.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
from deepagents_code.mcp_disabled import (
|
|
is_server_disabled,
|
|
set_server_disabled,
|
|
)
|
|
|
|
currently_disabled = await asyncio.to_thread(is_server_disabled, server_name)
|
|
new_state = not currently_disabled
|
|
ok, detail = await asyncio.to_thread(
|
|
set_server_disabled,
|
|
server_name,
|
|
new_state,
|
|
)
|
|
if not ok:
|
|
message = f"Could not persist disabled state for {server_name!r}"
|
|
if detail:
|
|
message += f": {detail}"
|
|
else:
|
|
message += "."
|
|
self.notify(
|
|
message,
|
|
severity="error",
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
had_original = server_name in self._mcp_optimistic_original_server_info
|
|
verb = "disabled" if new_state else "enabled"
|
|
self._apply_optimistic_disabled_state(server_name, disabled=new_state)
|
|
if new_state:
|
|
self._pending_mcp_disable_reconnect_servers.add(server_name)
|
|
message = (
|
|
f"MCP server {server_name!r} {verb}. Run `/mcp reconnect` to apply."
|
|
)
|
|
else:
|
|
message = f"MCP server {server_name!r} {verb}."
|
|
if had_original:
|
|
self._pending_mcp_disable_reconnect_servers.discard(server_name)
|
|
else:
|
|
self._pending_mcp_disable_reconnect_servers.add(server_name)
|
|
message += " Run `/mcp reconnect` to apply."
|
|
self._sync_pending_mcp_reconnect()
|
|
self.notify(message, markup=False)
|
|
# Refresh the viewer in place so the new status glyph and the
|
|
# `Ctrl+R` reconnect hint appear without tearing the screen
|
|
# down. Persistence already succeeded and the user has seen
|
|
# the toast, so a failed in-place patch is non-fatal — but log
|
|
# with traceback so a real bug (e.g. signature drift,
|
|
# `DuplicateIds`) isn't masked the way `suppress(Exception)`
|
|
# would have masked it.
|
|
viewer = self._active_mcp_viewer
|
|
if viewer is not None:
|
|
try:
|
|
await viewer.apply_server_disable_toggle(
|
|
self._mcp_server_info or [],
|
|
toggled_server=server_name,
|
|
pending_reconnect=self._pending_mcp_reconnect,
|
|
)
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to refresh MCP viewer in place after toggle "
|
|
"of %r; state has been persisted but the open "
|
|
"viewer will not reflect it until reopened",
|
|
server_name,
|
|
exc_info=True,
|
|
)
|
|
|
|
def _apply_optimistic_disabled_state(
|
|
self,
|
|
server_name: str,
|
|
*,
|
|
disabled: bool,
|
|
) -> None:
|
|
"""Update `_mcp_server_info` so the viewer reflects the toggle immediately.
|
|
|
|
The authoritative state is recomputed on the next reconnect; this is
|
|
purely cosmetic so the user sees their action take effect without
|
|
waiting for the server restart.
|
|
"""
|
|
from deepagents_code.mcp_tools import MCPServerInfo
|
|
|
|
info = self._mcp_server_info
|
|
if not info:
|
|
if disabled:
|
|
self._mcp_server_info = [
|
|
MCPServerInfo(
|
|
name=server_name,
|
|
transport="unknown",
|
|
status="disabled",
|
|
error="Disabled by user (pending reconnect).",
|
|
),
|
|
]
|
|
return
|
|
|
|
updated: list[MCPServerInfo] = []
|
|
for entry in info:
|
|
if entry.name != server_name:
|
|
updated.append(entry)
|
|
continue
|
|
if disabled:
|
|
if entry.status != "disabled":
|
|
self._mcp_optimistic_original_server_info[server_name] = entry
|
|
updated.append(
|
|
MCPServerInfo(
|
|
name=entry.name,
|
|
transport=entry.transport,
|
|
status="disabled",
|
|
error="Disabled by user (pending reconnect).",
|
|
),
|
|
)
|
|
else:
|
|
original = self._mcp_optimistic_original_server_info.pop(
|
|
server_name,
|
|
None,
|
|
)
|
|
if original is not None:
|
|
updated.append(original)
|
|
else:
|
|
# Best-effort re-enable when the app started with this
|
|
# server disabled. Keep `status="disabled"` so the muted
|
|
# pause glyph is shown instead of a red error badge —
|
|
# the real status will be recomputed by the reconnect.
|
|
updated.append(
|
|
MCPServerInfo(
|
|
name=entry.name,
|
|
transport=entry.transport,
|
|
status="disabled",
|
|
error="Re-enabled — run `/mcp reconnect` to load.",
|
|
),
|
|
)
|
|
self._mcp_server_info = updated
|
|
|
|
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
"""Surface login worker failures that escaped the inner error handling."""
|
|
from textual.worker import WorkerState
|
|
|
|
worker = event.worker
|
|
if not (worker.group or "").startswith("mcp-login-"):
|
|
return
|
|
if event.state == WorkerState.ERROR and worker.error is not None:
|
|
logger.warning(
|
|
"MCP login worker failed unexpectedly: %s",
|
|
worker.error,
|
|
exc_info=worker.error,
|
|
)
|
|
self.call_later(
|
|
self._mount_message,
|
|
ErrorMessage(
|
|
f"MCP login failed unexpectedly: {worker.error}. "
|
|
"You may need to retry.",
|
|
),
|
|
)
|
|
|
|
def _start_mcp_login(self, server_name: str) -> None:
|
|
"""Begin in-TUI OAuth login for `server_name`.
|
|
|
|
Guards against remote-server mode (no owned server to restart),
|
|
an absent local server, missing MCP config, an unknown server
|
|
name, and busy states. When the session is mid-run, the login
|
|
attempt is queued via `_defer_action` and runs once the user is
|
|
idle.
|
|
|
|
Args:
|
|
server_name: MCP server name from `mcpServers`.
|
|
"""
|
|
if self._mcp_preload_kwargs is None:
|
|
self.notify(
|
|
"MCP is disabled in this session; nothing to log into.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
if self._server_kwargs is None:
|
|
# Remote-server mode: we cannot restart the server, so the new
|
|
# token would never reach the MCP tool factory.
|
|
self.notify(
|
|
"Cannot log into MCP servers against a remote server. "
|
|
"Relaunch dcode locally to authenticate.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
if self._connecting or self._server_proc is None:
|
|
self.notify(
|
|
"MCP login is unavailable until the local server is ready.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
if self._agent_switching:
|
|
self.notify(
|
|
"An agent switch is in progress; try again once it completes.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
if self._agent_running or self._shell_running:
|
|
self.notify(
|
|
"MCP login will start once the current task completes.",
|
|
timeout=5,
|
|
markup=False,
|
|
)
|
|
self._defer_action(
|
|
DeferredAction(
|
|
kind="mcp_login",
|
|
execute=lambda: self._run_mcp_login_worker(server_name),
|
|
),
|
|
)
|
|
return
|
|
|
|
self.run_worker(
|
|
self._run_mcp_login_worker(server_name),
|
|
exclusive=False,
|
|
group=f"mcp-login-{server_name}",
|
|
)
|
|
|
|
async def _run_mcp_login_worker(self, server_name: str) -> None:
|
|
"""Resolve config, run the login modal, and refresh on success.
|
|
|
|
Args:
|
|
server_name: MCP server name from `mcpServers`.
|
|
"""
|
|
from deepagents_code.mcp_login_service import (
|
|
ConfigResolution,
|
|
ConfigResolutionError,
|
|
resolve_mcp_config,
|
|
select_server,
|
|
)
|
|
|
|
if self._mcp_preload_kwargs is None:
|
|
return
|
|
config_path = self._mcp_preload_kwargs.get("mcp_config_path")
|
|
resolution = resolve_mcp_config(config_path)
|
|
if isinstance(resolution, ConfigResolutionError):
|
|
await self._mount_message(
|
|
ErrorMessage(f"MCP login failed: {resolution.message}"),
|
|
)
|
|
return
|
|
if not isinstance(resolution, ConfigResolution): # pragma: no cover - safety
|
|
return
|
|
|
|
selection = select_server(resolution, server_name)
|
|
if isinstance(selection, ConfigResolutionError):
|
|
await self._mount_message(
|
|
ErrorMessage(f"MCP login failed: {selection.message}"),
|
|
)
|
|
return
|
|
|
|
if selection.server_config.get("auth") != "oauth":
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
f"MCP server {server_name!r} does not use OAuth; "
|
|
"nothing to log into.",
|
|
),
|
|
)
|
|
return
|
|
|
|
from deepagents_code.mcp_auth import login as mcp_login
|
|
from deepagents_code.widgets.mcp_login import (
|
|
LoginOutcome,
|
|
MCPLoginCancelledError,
|
|
MCPLoginScreen,
|
|
)
|
|
|
|
screen = MCPLoginScreen(server_name)
|
|
outcome_future: asyncio.Future[LoginOutcome | None] = (
|
|
asyncio.get_running_loop().create_future()
|
|
)
|
|
|
|
def _on_dismiss(outcome: LoginOutcome | None) -> None:
|
|
if not outcome_future.done():
|
|
outcome_future.set_result(outcome)
|
|
|
|
self.push_screen(screen, _on_dismiss)
|
|
|
|
# Pump one event loop iteration so `compose`/`on_mount` run before
|
|
# the worker awaits its first interaction method.
|
|
await asyncio.sleep(0)
|
|
|
|
login_error: Exception | None = None
|
|
try:
|
|
await mcp_login(
|
|
server_name=server_name,
|
|
server_config=selection.server_config,
|
|
ui=screen,
|
|
)
|
|
except MCPLoginCancelledError:
|
|
screen.finish(success=False, message="Login cancelled.")
|
|
await asyncio.wait_for(outcome_future, timeout=5.0)
|
|
await self._mount_message(
|
|
AppMessage(f"MCP login for {server_name!r} cancelled."),
|
|
)
|
|
return
|
|
except Exception as exc: # noqa: BLE001 # surface unexpected errors
|
|
login_error = exc
|
|
except BaseException:
|
|
# Worker cancelled or app shutdown — unblock the modal and
|
|
# let the cancellation propagate.
|
|
if not screen.is_done:
|
|
screen.finish(success=False, message="Login interrupted.")
|
|
if not outcome_future.done():
|
|
outcome_future.set_result(None)
|
|
raise
|
|
|
|
if login_error is not None:
|
|
from deepagents_code.mcp_auth import format_login_failure
|
|
|
|
# Token-safe: never `%r`, `str()`, or `exc_info=` on the raw
|
|
# exception — its `args`/repr may include an `OAuthToken` from
|
|
# the MCP SDK. `format_login_failure` unwraps `ExceptionGroup`
|
|
# roots and degrades to a class-name chain for unknown types.
|
|
summary = format_login_failure(login_error)
|
|
logger.warning("MCP login for %r failed: %s", server_name, summary)
|
|
screen.finish(success=False, message=f"Login failed: {summary}")
|
|
await asyncio.wait_for(outcome_future, timeout=5.0)
|
|
await self._mount_message(
|
|
ErrorMessage(f"MCP login for {server_name!r} failed: {summary}"),
|
|
)
|
|
return
|
|
|
|
screen.finish(
|
|
success=True,
|
|
message=(
|
|
f"Logged in to {server_name!r}. Reconnect required to load new tools."
|
|
),
|
|
)
|
|
await asyncio.wait_for(outcome_future, timeout=5.0)
|
|
|
|
# Ask the user whether to restart now or defer. Deferring lets them
|
|
# authenticate against additional MCP servers before paying the
|
|
# restart cost. `/mcp reconnect` (or another login confirmed with
|
|
# "reconnect") drives the restart later.
|
|
try:
|
|
await self._prompt_mcp_reconnect(server_name)
|
|
except Exception:
|
|
# The token is already on disk — surface the failure and
|
|
# remember the pending state so `/mcp reconnect` still works
|
|
# even though the prompt never reached the user.
|
|
logger.exception(
|
|
"MCP reconnect prompt for %r raised after successful login",
|
|
server_name,
|
|
)
|
|
self._pending_mcp_login_reconnect = True
|
|
self._sync_pending_mcp_reconnect()
|
|
self.notify(
|
|
f"Logged in to {server_name!r} but the reconnect prompt "
|
|
"failed. Run `/mcp reconnect` when ready to load the new tools.",
|
|
severity="warning",
|
|
timeout=8,
|
|
markup=False,
|
|
)
|
|
|
|
async def _prompt_mcp_reconnect(self, server_name: str) -> None:
|
|
"""Ask whether to restart now or defer after an MCP login succeeds.
|
|
|
|
Args:
|
|
server_name: Server whose login just completed — surfaced in the
|
|
modal title and downstream messages only.
|
|
"""
|
|
from deepagents_code.widgets.mcp_reconnect import (
|
|
MCPReconnectPromptScreen,
|
|
ReconnectChoice,
|
|
)
|
|
|
|
choice_future: asyncio.Future[ReconnectChoice | None] = (
|
|
asyncio.get_running_loop().create_future()
|
|
)
|
|
|
|
def _on_dismiss(result: ReconnectChoice | None) -> None:
|
|
if not choice_future.done():
|
|
choice_future.set_result(result)
|
|
|
|
choice: ReconnectChoice | None
|
|
try:
|
|
self.push_screen(MCPReconnectPromptScreen(server_name), _on_dismiss)
|
|
except Exception:
|
|
# Modal could not be mounted (e.g. another modal hijacked the
|
|
# stack). Fall back to defer so the login isn't silently lost.
|
|
logger.exception("Failed to mount MCP reconnect prompt for %r", server_name)
|
|
choice = "later"
|
|
else:
|
|
try:
|
|
# Watchdog: guard against a screen that never resolves
|
|
# (compose crash, programmatic teardown that skips the
|
|
# callback). 10 minutes is well past any human latency
|
|
# but bounds the worker if the modal is genuinely broken.
|
|
choice = await asyncio.wait_for(choice_future, timeout=600.0)
|
|
except TimeoutError:
|
|
logger.warning(
|
|
"MCP reconnect prompt for %r timed out; defaulting to defer",
|
|
server_name,
|
|
)
|
|
choice = "later"
|
|
|
|
if choice == "reconnect":
|
|
self._pending_mcp_login_reconnect = False
|
|
self._pending_mcp_disable_reconnect_servers.clear()
|
|
self._sync_pending_mcp_reconnect()
|
|
await self._restart_server_for_mcp_refresh(server_name)
|
|
return
|
|
|
|
# Defer: keep the running server in place so the user can authenticate
|
|
# with additional MCP servers. The token is on disk either way, so
|
|
# remember the pending state regardless of how the modal closed.
|
|
# Only notify on an explicit "later" choice — `None` (programmatic
|
|
# dismiss / timeout) stays quiet to avoid telling the user about
|
|
# an action they didn't take.
|
|
self._pending_mcp_login_reconnect = True
|
|
self._sync_pending_mcp_reconnect()
|
|
if choice == "later":
|
|
self.notify(
|
|
f"Logged in to {server_name!r}. Run `/mcp reconnect` when ready "
|
|
"to load the new tools.",
|
|
severity="information",
|
|
timeout=8,
|
|
markup=False,
|
|
)
|
|
# Defer is the "log into another server first" path, so route the
|
|
# user back to the switcher where the next unauthenticated server
|
|
# is one click away. Timeout and push_screen-failure fallbacks
|
|
# also land here (both set `choice = "later"`), so they share
|
|
# both the notify above and this navigation — acceptable
|
|
# degradation since the viewer push is itself best-effort.
|
|
try:
|
|
await self._show_mcp_viewer()
|
|
except Exception:
|
|
# Broad catch: real failures here are Textual mount/stack
|
|
# errors plus the deferred SDK import — none worth crashing
|
|
# the worker for, since the token is already on disk.
|
|
# Surface a toast so the user knows why the switcher didn't
|
|
# come back; without it, the "logged in" notify is the only
|
|
# signal and the missing viewer looks like a UI hang.
|
|
logger.exception(
|
|
"Failed to reopen MCP viewer after deferring reconnect for %r",
|
|
server_name,
|
|
)
|
|
self.notify(
|
|
"Couldn't reopen the MCP viewer — run `/mcp` to open it manually.",
|
|
severity="warning",
|
|
timeout=8,
|
|
markup=False,
|
|
)
|
|
|
|
async def _restart_server_for_mcp_refresh(self, server_name: str) -> None:
|
|
"""Restart the app-owned LangGraph server to pick up new MCP tokens.
|
|
|
|
Skips and notifies when the app does not own a server process.
|
|
Failures roll back to the previous state via `ServerStartFailed`
|
|
from `_start_server_background`.
|
|
|
|
Args:
|
|
server_name: Server whose login just completed — used in user
|
|
messages only.
|
|
"""
|
|
# Clear the pending flag up front so deferred state can't leak
|
|
# past a no-op early return — e.g. the server died between defer
|
|
# and `/mcp reconnect`. The token is on disk; the user must
|
|
# relaunch dcode to pick it up, and `/mcp reconnect` shouldn't
|
|
# keep claiming there's something to do.
|
|
self._pending_mcp_login_reconnect = False
|
|
self._pending_mcp_disable_reconnect_servers.clear()
|
|
self._sync_pending_mcp_reconnect()
|
|
|
|
if self._server_kwargs is None or self._server_proc is None:
|
|
self.notify(
|
|
"Cannot restart the LangGraph server automatically; "
|
|
"relaunch dcode to pick up the new MCP token.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
await self._respawn_server(
|
|
log_message=(f"Server restart after MCP login for {server_name!r} failed"),
|
|
mcp_failure_log="MCP metadata preload after login refresh failed",
|
|
mcp_failure_toast=(
|
|
"MCP tool metadata could not be refreshed after login. "
|
|
"Your tool list may be stale — use /mcp to check."
|
|
),
|
|
)
|
|
|
|
async def _handle_restart_command(self, command: str) -> None:
|
|
"""Drive the hidden `/restart` slash command.
|
|
|
|
Superset of `/reload`: re-reads `.env` / environment, clears
|
|
configuration caches, then respawns the app-owned LangGraph
|
|
server subprocess. Used as a recovery escape hatch when the
|
|
server wedges; intentionally hidden from autocomplete and help.
|
|
|
|
Cancels any in-flight agent work and drops the queued message
|
|
backlog before respawning. The streaming HTTP connection to the
|
|
dying subprocess would otherwise raise into the Textual reactor
|
|
after the new server advertises ready, leaving the UI wedged.
|
|
|
|
Args:
|
|
command: Raw command string for echoing back to chat.
|
|
"""
|
|
from deepagents_code.config import settings
|
|
from deepagents_code.model_config import clear_caches
|
|
|
|
await self._mount_message(UserMessage(command))
|
|
|
|
# Sever in-flight work bound to the dying subprocess. `_cancel_worker`
|
|
# discards the queued backlog too — those messages would otherwise
|
|
# fire against the freshly respawned agent silently.
|
|
if self._agent_running and self._agent_worker:
|
|
self._cancel_worker(self._agent_worker)
|
|
self._agent_running = False
|
|
else:
|
|
self._discard_queue()
|
|
|
|
try:
|
|
settings.reload_from_environment()
|
|
clear_caches()
|
|
except (OSError, ValueError, KeyError, TypeError, ImportError) as exc:
|
|
logger.exception("Failed to reload configuration during /restart")
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"Failed to reload configuration "
|
|
f"({type(exc).__name__}: {exc}). Check your .env "
|
|
"file and environment variables for syntax errors, "
|
|
"then try again.",
|
|
),
|
|
)
|
|
return
|
|
|
|
if self._server_proc is None or self._server_kwargs is None:
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"Cannot restart: this app is connected to a remote "
|
|
"LangGraph server (no owned subprocess). Configuration "
|
|
"was reloaded; relaunch dcode to fully restart.",
|
|
),
|
|
)
|
|
return
|
|
|
|
await self._restart_server_manual()
|
|
|
|
async def _restart_server_manual(self) -> None:
|
|
"""Respawn the app-owned LangGraph server for `/restart`.
|
|
|
|
Shares the respawn/preload sequence with
|
|
`_restart_server_for_mcp_refresh` via `_respawn_server`. Failures
|
|
surface via `ServerStartFailed` so the existing recovery UI takes
|
|
over.
|
|
"""
|
|
await self._respawn_server(
|
|
log_message="Manual /restart of server failed",
|
|
mcp_failure_log="MCP metadata preload after /restart failed",
|
|
mcp_failure_toast=(
|
|
"MCP tool metadata could not be refreshed. Use /mcp to check."
|
|
),
|
|
)
|
|
|
|
async def _respawn_server(
|
|
self,
|
|
*,
|
|
log_message: str,
|
|
mcp_failure_log: str,
|
|
mcp_failure_toast: str,
|
|
restart_timeout: float = 30.0,
|
|
) -> None:
|
|
"""Stop the app-owned server subprocess and rebuild the agent.
|
|
|
|
Used by `_restart_server_manual` (the `/restart` command) and
|
|
`_restart_server_for_mcp_refresh` (post-OAuth-login refresh).
|
|
|
|
Args:
|
|
log_message: Error log written when `server_proc.restart()`
|
|
raises or times out.
|
|
mcp_failure_log: Error log written when post-restart MCP
|
|
metadata preload raises.
|
|
mcp_failure_toast: User-facing toast shown when MCP preload
|
|
fails. Restart still succeeds; the agent comes up with
|
|
`mcp_info=None`.
|
|
restart_timeout: Seconds to wait for the subprocess restart
|
|
before giving up. Bounded so a wedged shutdown — the very
|
|
condition `/restart` exists to recover from — cannot
|
|
deadlock the handler.
|
|
"""
|
|
server_proc = self._server_proc
|
|
if self._server_kwargs is None or server_proc is None:
|
|
return
|
|
|
|
try:
|
|
self._connecting = True
|
|
self._agent = None
|
|
try:
|
|
banner = self.query_one("#welcome-banner", WelcomeBanner)
|
|
banner.set_connecting()
|
|
except NoMatches:
|
|
pass
|
|
|
|
try:
|
|
await asyncio.wait_for(server_proc.restart(), timeout=restart_timeout)
|
|
except (Exception, TimeoutError) as exc:
|
|
self._connecting = False
|
|
logger.exception(log_message)
|
|
self.post_message(self.ServerStartFailed(error=exc))
|
|
return
|
|
|
|
from deepagents_code.main import _preload_session_mcp_server_info
|
|
from deepagents_code.remote_client import RemoteAgent as _RemoteAgent
|
|
|
|
mcp_info = None
|
|
try:
|
|
mcp_info = await _preload_session_mcp_server_info(
|
|
**self._mcp_preload_kwargs, # type: ignore[arg-type]
|
|
)
|
|
except Exception as exc:
|
|
logger.exception(mcp_failure_log)
|
|
self.notify(
|
|
f"{mcp_failure_toast} ({type(exc).__name__})",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
|
|
def _build_agent(url: str) -> Any: # noqa: ANN401 # union narrowed elsewhere
|
|
return _RemoteAgent(url=url, graph_name="agent")
|
|
|
|
self._agent = _build_agent(server_proc.url)
|
|
self.post_message(
|
|
self.ServerReady(
|
|
agent=self._agent,
|
|
server_proc=server_proc,
|
|
mcp_server_info=mcp_info,
|
|
),
|
|
)
|
|
except BaseException:
|
|
self._connecting = False
|
|
raise
|
|
finally:
|
|
if self._chat_input:
|
|
self._chat_input.set_cursor_active(active=not self._agent_running)
|
|
|
|
async def _show_thread_selector(self) -> None:
|
|
"""Show interactive thread selector as a modal screen."""
|
|
from functools import partial
|
|
|
|
from deepagents_code.sessions import get_cached_threads, get_thread_limit
|
|
from deepagents_code.widgets.thread_selector import ThreadSelectorScreen
|
|
|
|
current = self._session_state.thread_id if self._session_state else None
|
|
thread_limit = get_thread_limit()
|
|
|
|
initial_threads = get_cached_threads(limit=thread_limit)
|
|
|
|
def handle_result(result: str | None) -> None:
|
|
"""Handle the thread selector result."""
|
|
if result is not None:
|
|
if self._agent_running or self._shell_running or self._connecting:
|
|
self._defer_action(
|
|
DeferredAction(
|
|
kind="thread_switch",
|
|
execute=partial(self._resume_thread, result),
|
|
),
|
|
)
|
|
self.notify(
|
|
"Thread will switch after current task completes.",
|
|
timeout=3,
|
|
)
|
|
else:
|
|
self.call_later(self._resume_thread, result)
|
|
if self._chat_input:
|
|
self._chat_input.focus_input()
|
|
|
|
screen = ThreadSelectorScreen(
|
|
current_thread=current,
|
|
thread_limit=thread_limit,
|
|
initial_threads=initial_threads,
|
|
)
|
|
self.push_screen(screen, handle_result)
|
|
|
|
def _update_welcome_banner(
|
|
self,
|
|
thread_id: str,
|
|
*,
|
|
missing_message: str,
|
|
warn_if_missing: bool,
|
|
) -> None:
|
|
"""Update the welcome banner thread ID when the banner is mounted.
|
|
|
|
Args:
|
|
thread_id: Thread ID to display on the banner.
|
|
missing_message: Log message template when banner is missing.
|
|
warn_if_missing: Whether to log missing-banner cases at warning level.
|
|
"""
|
|
try:
|
|
banner = self.query_one("#welcome-banner", WelcomeBanner)
|
|
banner.update_thread_id(thread_id)
|
|
except NoMatches:
|
|
if warn_if_missing:
|
|
logger.warning(missing_message, thread_id)
|
|
else:
|
|
logger.debug(missing_message, thread_id)
|
|
|
|
async def _resume_thread(self, thread_id: str) -> None:
|
|
"""Resume a previously saved thread.
|
|
|
|
Fetches the selected thread history, then atomically switches UI state.
|
|
Prefetching first avoids clearing the active chat when history loading
|
|
fails.
|
|
|
|
Args:
|
|
thread_id: The thread ID to resume.
|
|
"""
|
|
if not self._agent:
|
|
await self._mount_message(
|
|
AppMessage("Cannot switch threads: no active agent"),
|
|
)
|
|
return
|
|
|
|
if not self._session_state:
|
|
await self._mount_message(
|
|
AppMessage("Cannot switch threads: no active session"),
|
|
)
|
|
return
|
|
|
|
# Skip if already on this thread
|
|
if self._session_state.thread_id == thread_id:
|
|
await self._mount_message(AppMessage(f"Already on thread: {thread_id}"))
|
|
return
|
|
|
|
if self._thread_switching:
|
|
await self._mount_message(AppMessage("Thread switch already in progress."))
|
|
return
|
|
|
|
# Save previous state for rollback on failure
|
|
prev_thread_id = self._lc_thread_id
|
|
prev_session_thread = self._session_state.thread_id
|
|
self._thread_switching = True
|
|
if self._chat_input:
|
|
self._chat_input.set_cursor_active(active=False)
|
|
|
|
prefetched_payload: _ThreadHistoryPayload | None = None
|
|
try:
|
|
self._update_status(f"Loading thread: {thread_id}")
|
|
prefetched_payload = await self._fetch_thread_history_data(thread_id)
|
|
|
|
# Clear conversation (similar to /clear, without creating a new thread)
|
|
self._pending_messages.clear()
|
|
self._queued_widgets.clear()
|
|
await self._clear_messages()
|
|
self._context_tokens = 0
|
|
self._tokens_approximate = False
|
|
self._update_tokens(0)
|
|
self._update_status("")
|
|
|
|
# Switch to the selected thread
|
|
self._session_state.thread_id = thread_id
|
|
self._lc_thread_id = thread_id
|
|
|
|
self._update_welcome_banner(
|
|
thread_id,
|
|
missing_message="Welcome banner not found during thread switch to %s",
|
|
warn_if_missing=False,
|
|
)
|
|
|
|
# Load thread history
|
|
await self._load_thread_history(
|
|
thread_id=thread_id,
|
|
preloaded_payload=prefetched_payload,
|
|
)
|
|
except Exception as exc:
|
|
if prefetched_payload is None:
|
|
logger.exception("Failed to prefetch history for thread %s", thread_id)
|
|
await self._mount_message(
|
|
AppMessage(
|
|
f"Failed to switch to thread {thread_id}: {exc}. "
|
|
"Use /threads to try again.",
|
|
),
|
|
)
|
|
return
|
|
logger.exception("Failed to switch to thread %s", thread_id)
|
|
# Restore previous thread IDs so the user can retry
|
|
self._session_state.thread_id = prev_session_thread
|
|
self._lc_thread_id = prev_thread_id
|
|
self._update_welcome_banner(
|
|
prev_session_thread,
|
|
missing_message=(
|
|
"Welcome banner not found during rollback to thread %s; "
|
|
"banner may display stale thread ID"
|
|
),
|
|
warn_if_missing=True,
|
|
)
|
|
rollback_restore_failed = False
|
|
# Attempt to restore the previous thread's visible history
|
|
try:
|
|
await self._clear_messages()
|
|
await self._load_thread_history(thread_id=prev_session_thread)
|
|
except Exception: # Resilient session state saving
|
|
rollback_restore_failed = True
|
|
msg = (
|
|
"Could not restore previous thread history after failed "
|
|
"switch to %s"
|
|
)
|
|
logger.warning(msg, thread_id, exc_info=True)
|
|
error_message = f"Failed to switch to thread {thread_id}: {exc}."
|
|
if rollback_restore_failed:
|
|
error_message += " Previous thread history could not be restored."
|
|
error_message += " Use /threads to try again."
|
|
await self._mount_message(AppMessage(error_message))
|
|
finally:
|
|
self._thread_switching = False
|
|
self._update_status("")
|
|
if self._chat_input:
|
|
self._chat_input.set_cursor_active(active=not self._agent_running)
|
|
|
|
async def _switch_model(
|
|
self,
|
|
model_spec: str,
|
|
*,
|
|
extra_kwargs: dict[str, Any] | None = None,
|
|
announce_unchanged: bool = True,
|
|
) -> None:
|
|
"""Switch to a new model, preserving conversation history.
|
|
|
|
This requires a server-backed interactive session. It sets a model
|
|
override that `ConfigurableModelMiddleware` picks up on the next
|
|
invocation, so the conversation thread stays intact and no server
|
|
restart is required.
|
|
|
|
Args:
|
|
model_spec: The model specification to switch to.
|
|
|
|
Can be in `provider:model` format
|
|
(e.g., `'anthropic:claude-sonnet-4-5'`) or just the model name
|
|
for auto-detection.
|
|
extra_kwargs: Extra constructor kwargs from `--model-params`.
|
|
announce_unchanged: Whether to mount a message when the requested
|
|
model is already active.
|
|
"""
|
|
from deepagents_code.config import create_model, detect_provider, settings
|
|
from deepagents_code.model_config import (
|
|
ModelSpec,
|
|
ProviderAuthState,
|
|
get_provider_auth_status,
|
|
save_recent_model,
|
|
touch_recent_model,
|
|
)
|
|
|
|
logger.info("Switching model to %s", model_spec)
|
|
|
|
if self._model_switching:
|
|
await self._mount_message(AppMessage("Model switch already in progress."))
|
|
return
|
|
|
|
self._model_switching = True
|
|
try:
|
|
# Defensively strip leading colon in case of empty provider,
|
|
# treat ":claude-opus-4-6" as "claude-opus-4-6"
|
|
model_spec = model_spec.removeprefix(":")
|
|
|
|
if not self._remote_agent():
|
|
if self._connecting:
|
|
from functools import partial
|
|
|
|
self._defer_action(
|
|
DeferredAction(
|
|
kind="model_switch",
|
|
execute=partial(
|
|
self._switch_model,
|
|
model_spec,
|
|
extra_kwargs=extra_kwargs,
|
|
announce_unchanged=announce_unchanged,
|
|
),
|
|
),
|
|
)
|
|
self.notify(
|
|
"Model will switch once the session is ready.",
|
|
timeout=3,
|
|
)
|
|
return
|
|
# Recover from a startup that has not produced a server yet:
|
|
# either a deferred first launch with no credentials, or a
|
|
# failed startup such as `MissingCredentialsError`.
|
|
if (
|
|
self._server_startup_deferred
|
|
or self._server_startup_error is not None
|
|
) and self._server_kwargs is not None:
|
|
await self._retry_startup_with_model(
|
|
model_spec,
|
|
extra_kwargs=extra_kwargs,
|
|
)
|
|
return
|
|
await self._mount_message(
|
|
ErrorMessage("Model switching requires a server-backed session."),
|
|
)
|
|
return
|
|
|
|
parsed = ModelSpec.try_parse(model_spec)
|
|
if parsed:
|
|
provider: str | None = parsed.provider
|
|
model_name = parsed.model
|
|
else:
|
|
model_name = model_spec
|
|
provider = detect_provider(model_spec)
|
|
|
|
# Check credentials
|
|
auth_status = get_provider_auth_status(provider) if provider else None
|
|
if auth_status is not None and auth_status.blocks_start:
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
f"Missing credentials: {auth_status.missing_detail()}\n\n"
|
|
f"Run `/auth` for the '{auth_status.provider}' provider, then "
|
|
f"re-issue `/model {model_spec}`.",
|
|
),
|
|
)
|
|
return
|
|
if (
|
|
auth_status is not None
|
|
and auth_status.state is ProviderAuthState.UNKNOWN
|
|
):
|
|
logger.debug(
|
|
"Credentials for provider '%s' cannot be verified;"
|
|
" proceeding anyway",
|
|
provider,
|
|
)
|
|
|
|
# Check if already using this exact model
|
|
if model_name == settings.model_name and (
|
|
not provider or provider == settings.model_provider
|
|
):
|
|
current = f"{settings.model_provider}:{settings.model_name}"
|
|
# Mirror the regular-switch path so `--model-params` semantics
|
|
# are consistent across same-model and different-model cases:
|
|
# passing params applies them, omitting params clears any
|
|
# prior per-session override.
|
|
self._model_override = current
|
|
self._model_params_override = extra_kwargs
|
|
params_suffix = _format_model_params(extra_kwargs)
|
|
if announce_unchanged:
|
|
await self._mount_message(
|
|
AppMessage(f"Already using {current}{params_suffix}"),
|
|
)
|
|
logger.info(
|
|
"Model unchanged (%s); model_params=%s",
|
|
current,
|
|
extra_kwargs,
|
|
)
|
|
return
|
|
|
|
# Build the provider:model spec for the configurable middleware.
|
|
display = model_spec
|
|
if provider and not parsed:
|
|
display = f"{provider}:{model_name}"
|
|
|
|
try:
|
|
result = create_model(
|
|
display,
|
|
extra_kwargs=extra_kwargs,
|
|
profile_overrides=self._profile_override,
|
|
)
|
|
result.apply_to_settings()
|
|
except Exception as exc:
|
|
logger.exception("Failed to resolve model metadata for %s", display)
|
|
await self._mount_message(
|
|
ErrorMessage(_build_model_switch_error_body(exc)),
|
|
)
|
|
return
|
|
|
|
# Set the model override for ConfigurableModelMiddleware.
|
|
# The next stream call passes CLIContext via context= and the
|
|
# middleware swaps the model per-invocation — no graph recreation.
|
|
self._model_override = display
|
|
self._model_params_override = extra_kwargs
|
|
|
|
if self._status_bar:
|
|
self._status_bar.set_model(
|
|
provider=settings.model_provider or "",
|
|
model=settings.model_name or "",
|
|
)
|
|
|
|
if not await asyncio.to_thread(save_recent_model, display):
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
"Model switched for this session, but could not save "
|
|
"preference. Check permissions for ~/.deepagents/",
|
|
),
|
|
)
|
|
else:
|
|
params_suffix = _format_model_params(extra_kwargs)
|
|
await self._mount_message(
|
|
AppMessage(f"Switched to {display}{params_suffix}"),
|
|
)
|
|
# Best-effort MRU update for the `/model` Recent section.
|
|
# `display` may be a bare model name when provider auto-detection
|
|
# fails; use the post-resolution spec so touch_recent_model always
|
|
# gets a valid "provider:model" string. Silent on failure —
|
|
# debug log captures it when DEEPAGENTS_CODE_DEBUG=1.
|
|
resolved_spec = f"{result.provider}:{result.model_name}"
|
|
await asyncio.to_thread(touch_recent_model, resolved_spec)
|
|
logger.info(
|
|
"Model switched to %s (via configurable middleware); model_params=%s",
|
|
display,
|
|
extra_kwargs,
|
|
)
|
|
|
|
# Anchor to bottom so the confirmation message is visible
|
|
with suppress(NoMatches, ScreenStackError):
|
|
self.query_one("#chat", VerticalScroll).anchor()
|
|
finally:
|
|
self._model_switching = False
|
|
|
|
async def _retry_startup_with_model(
|
|
self,
|
|
model_spec: str,
|
|
*,
|
|
extra_kwargs: dict[str, Any] | None = None,
|
|
) -> None:
|
|
"""Retry deferred server startup after a failed initial startup.
|
|
|
|
Exists because the server never came up (typically a
|
|
`MissingCredentialsError`), so the only escape without restarting
|
|
the app is re-running the deferred startup worker with a new spec.
|
|
|
|
Args:
|
|
model_spec: The new model specification (`provider:model` or bare
|
|
model name for auto-detection).
|
|
extra_kwargs: Extra constructor kwargs from `--model-params`.
|
|
"""
|
|
from deepagents_code.config import detect_provider
|
|
from deepagents_code.model_config import ModelSpec, get_provider_auth_status
|
|
|
|
if self._server_kwargs is None:
|
|
await self._mount_message(
|
|
ErrorMessage("Cannot retry startup: server is not app-owned."),
|
|
)
|
|
return
|
|
|
|
parsed = ModelSpec.try_parse(model_spec)
|
|
if parsed:
|
|
provider: str | None = parsed.provider
|
|
model_name = parsed.model
|
|
else:
|
|
model_name = model_spec
|
|
provider = detect_provider(model_spec)
|
|
|
|
# Tri-state credentials check (`UNKNOWN` = unknown provider, treated
|
|
# as proceed); bail early so retrying with still-missing creds doesn't
|
|
# loop right back into the same `MissingCredentialsError`.
|
|
auth_status = get_provider_auth_status(provider) if provider else None
|
|
if auth_status is not None and auth_status.blocks_start:
|
|
await self._mount_message(
|
|
ErrorMessage(f"Missing credentials: {auth_status.missing_detail()}"),
|
|
)
|
|
return
|
|
|
|
display = model_spec
|
|
if provider and not parsed:
|
|
display = f"{provider}:{model_name}"
|
|
|
|
new_model_kwargs: dict[str, Any] = {
|
|
"model_spec": display,
|
|
"extra_kwargs": extra_kwargs,
|
|
"profile_overrides": self._profile_override,
|
|
}
|
|
self._model_kwargs = new_model_kwargs
|
|
self._server_kwargs["model_name"] = display
|
|
if extra_kwargs is not None:
|
|
self._server_kwargs["model_params"] = extra_kwargs
|
|
|
|
self._server_startup_error = None
|
|
self._server_startup_missing_credentials_provider = None
|
|
self._server_startup_missing_provider_package = None
|
|
self._server_startup_deferred = False
|
|
self._connecting = True
|
|
try:
|
|
banner = self.query_one("#welcome-banner", WelcomeBanner)
|
|
banner.set_connecting()
|
|
except (NoMatches, ScreenStackError):
|
|
logger.debug("Welcome banner not found during startup retry", exc_info=True)
|
|
|
|
if self._retry_status_widget is not None:
|
|
with suppress(NoMatches, ScreenStackError):
|
|
await self._retry_status_widget.remove()
|
|
self._retry_status_widget = None
|
|
try:
|
|
messages = self.query_one("#messages", Container)
|
|
except (NoMatches, ScreenStackError):
|
|
messages = None
|
|
if messages is not None and messages.is_attached:
|
|
new_widget = AppMessage(f"Retrying startup with {display}…")
|
|
# Mount before storing the reference so `on_deep_agents_app_server_ready`
|
|
# cannot observe a half-mounted widget if it races during this await.
|
|
await self._mount_before_queued(messages, new_widget)
|
|
self._retry_status_widget = new_widget
|
|
logger.info("Retrying server startup with model %s", display)
|
|
|
|
self.run_worker(
|
|
self._start_server_background,
|
|
exclusive=True,
|
|
group="server-startup",
|
|
)
|
|
|
|
async def _maybe_start_deferred_server_from_default(self) -> bool:
|
|
"""Start a deferred first-launch server once a default model resolves.
|
|
|
|
Returns:
|
|
`True` when startup was kicked off, otherwise `False`.
|
|
"""
|
|
if not self._server_startup_deferred:
|
|
return False
|
|
|
|
from deepagents_code.config import _get_default_model_spec
|
|
from deepagents_code.model_config import (
|
|
ModelConfigError,
|
|
NoCredentialsConfiguredError,
|
|
)
|
|
|
|
try:
|
|
model_spec = _get_default_model_spec()
|
|
except NoCredentialsConfiguredError:
|
|
return False
|
|
except ModelConfigError as exc:
|
|
await self._mount_message(ErrorMessage(str(exc)))
|
|
return False
|
|
|
|
await self._retry_startup_with_model(model_spec)
|
|
return True
|
|
|
|
async def _set_default_model(self, model_spec: str) -> None:
|
|
"""Set the default model in config without switching the current session.
|
|
|
|
Updates `[models].default` in `~/.deepagents/config.toml` so that
|
|
future app launches use this model. Does not affect the running session.
|
|
|
|
Args:
|
|
model_spec: The model specification (e.g., `'anthropic:claude-opus-4-6'`).
|
|
"""
|
|
from deepagents_code.config import detect_provider
|
|
from deepagents_code.model_config import ModelSpec, save_default_model
|
|
|
|
model_spec = model_spec.removeprefix(":")
|
|
|
|
parsed = ModelSpec.try_parse(model_spec)
|
|
if not parsed:
|
|
provider = detect_provider(model_spec)
|
|
if provider:
|
|
model_spec = f"{provider}:{model_spec}"
|
|
|
|
if await asyncio.to_thread(save_default_model, model_spec):
|
|
await self._mount_message(AppMessage(f"Default model set to {model_spec}"))
|
|
else:
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
"Could not save default model. "
|
|
"Check permissions for ~/.deepagents/",
|
|
),
|
|
)
|
|
|
|
async def _clear_default_model(self) -> None:
|
|
"""Remove the default model from config.
|
|
|
|
After clearing, future launches fall back to `[models].recent` or
|
|
environment auto-detection.
|
|
"""
|
|
from deepagents_code.model_config import clear_default_model
|
|
|
|
if await asyncio.to_thread(clear_default_model):
|
|
await self._mount_message(
|
|
AppMessage(
|
|
"Default model cleared. "
|
|
"Future launches will use recent model or auto-detect.",
|
|
),
|
|
)
|
|
else:
|
|
await self._mount_message(
|
|
ErrorMessage(
|
|
"Could not clear default model. "
|
|
"Check permissions for ~/.deepagents/",
|
|
),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AppResult:
|
|
"""Result from running the Textual application."""
|
|
|
|
return_code: int
|
|
"""Exit code (0 for success, non-zero for error)."""
|
|
|
|
thread_id: str | None
|
|
"""The final thread ID at shutdown. May differ from the initial thread ID if
|
|
the user switched threads via `/threads`."""
|
|
|
|
session_stats: SessionStats = field(default_factory=SessionStats)
|
|
"""Cumulative usage stats across all turns in the session."""
|
|
|
|
update_available: tuple[bool, str | None] = (False, None)
|
|
"""`(is_available, latest_version)` for post-exit update warning."""
|
|
|
|
|
|
async def run_textual_app(
|
|
*,
|
|
agent: Any = None, # noqa: ANN401
|
|
assistant_id: str | None = None,
|
|
backend: CompositeBackend | None = None,
|
|
auto_approve: bool = False,
|
|
cwd: str | Path | None = None,
|
|
thread_id: str | None = None,
|
|
resume_thread: str | None = None,
|
|
initial_prompt: str | None = None,
|
|
initial_skill: str | None = None,
|
|
startup_cmd: str | None = None,
|
|
launch_init: bool = False,
|
|
mcp_server_info: list[MCPServerInfo] | None = None,
|
|
profile_override: dict[str, Any] | None = None,
|
|
server_proc: ServerProcess | None = None,
|
|
server_kwargs: dict[str, Any] | None = None,
|
|
mcp_preload_kwargs: dict[str, Any] | None = None,
|
|
model_kwargs: dict[str, Any] | None = None,
|
|
defer_server_start: bool = False,
|
|
title: str | None = None,
|
|
sub_title: str | None = None,
|
|
) -> AppResult:
|
|
"""Run the Textual application.
|
|
|
|
When `server_kwargs` is provided (and `agent` is `None`), the app starts
|
|
immediately with a "Connecting..." banner and launches the server in the
|
|
background. Server cleanup is handled automatically after the app exits.
|
|
|
|
Args:
|
|
agent: Pre-configured LangGraph agent (optional).
|
|
assistant_id: Agent identifier for memory storage.
|
|
backend: Backend for file operations.
|
|
auto_approve: Whether to start with auto-approve enabled.
|
|
cwd: Current working directory to display.
|
|
thread_id: Thread ID for the session.
|
|
|
|
`None` when `resume_thread` is provided (the TUI resolves the final
|
|
ID asynchronously).
|
|
resume_thread: Raw resume intent from `-r` flag. `'__MOST_RECENT__'` for
|
|
bare `-r`, a thread ID string for `-r <id>`, or `None` for new
|
|
sessions.
|
|
|
|
Resolved asynchronously during TUI startup.
|
|
initial_prompt: Optional prompt to auto-submit when session starts.
|
|
initial_skill: Optional skill name to invoke when session starts.
|
|
startup_cmd: Optional shell command to run at startup before the first
|
|
prompt is accepted. Output is rendered in the transcript and
|
|
non-zero exits warn but do not abort the session.
|
|
launch_init: Whether to run the first-run onboarding setup flow
|
|
(name entry, dependency summary, model picker) before accepting
|
|
the first prompt.
|
|
mcp_server_info: MCP server metadata for the `/mcp` viewer.
|
|
profile_override: Extra profile fields from `--profile-override`,
|
|
retained so later profile-aware behavior stays consistent with
|
|
the app override, including model selection details, offload
|
|
budget display, and on-demand `create_model()` calls such
|
|
as `/offload`.
|
|
server_proc: LangGraph server process for the interactive session.
|
|
server_kwargs: Kwargs for deferred `start_server_and_get_agent` call.
|
|
mcp_preload_kwargs: Kwargs for concurrent MCP metadata preload.
|
|
model_kwargs: Kwargs for deferred `create_model()` call.
|
|
|
|
When provided, model creation runs in a background worker after
|
|
first paint so the splash screen appears immediately.
|
|
defer_server_start: Whether to keep app-owned server startup paused
|
|
until credentials or a model are configured from inside the TUI.
|
|
title: Override the Textual `App.title` shown in the optional header
|
|
bar (gated on `DEEPAGENTS_CODE_SHOW_HEADER`). When `None`, the
|
|
default `"Deep Agents"` is used.
|
|
sub_title: Override the Textual `App.sub_title` shown in the optional
|
|
header bar.
|
|
|
|
Returns:
|
|
An `AppResult` with the return code and final thread ID.
|
|
"""
|
|
app = DeepAgentsApp(
|
|
agent=agent,
|
|
assistant_id=assistant_id,
|
|
backend=backend,
|
|
auto_approve=auto_approve,
|
|
cwd=cwd,
|
|
thread_id=thread_id,
|
|
resume_thread=resume_thread,
|
|
initial_prompt=initial_prompt,
|
|
initial_skill=initial_skill,
|
|
startup_cmd=startup_cmd,
|
|
launch_init=launch_init,
|
|
mcp_server_info=mcp_server_info,
|
|
profile_override=profile_override,
|
|
server_proc=server_proc,
|
|
server_kwargs=server_kwargs,
|
|
mcp_preload_kwargs=mcp_preload_kwargs,
|
|
model_kwargs=model_kwargs,
|
|
defer_server_start=defer_server_start,
|
|
title=title,
|
|
sub_title=sub_title,
|
|
)
|
|
try:
|
|
await app.run_async()
|
|
finally:
|
|
# Guarantee server cleanup regardless of how the app exits.
|
|
# Covers both the pre-started server_proc path and the deferred
|
|
# server_kwargs path (where the background worker sets _server_proc).
|
|
if app._server_proc is not None:
|
|
app._server_proc.stop()
|
|
|
|
return AppResult(
|
|
return_code=app.return_code or 0,
|
|
thread_id=app._lc_thread_id,
|
|
session_stats=app._session_stats,
|
|
update_available=app._update_available,
|
|
)
|