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.
375 lines
12 KiB
Python
375 lines
12 KiB
Python
"""Unified slash-command registry.
|
|
|
|
Every slash command is declared once as a `SlashCommand` entry in `COMMANDS`.
|
|
Bypass-tier frozensets and autocomplete entries are derived automatically — no
|
|
other file should hard-code command metadata.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
from typing import TYPE_CHECKING, NamedTuple
|
|
|
|
if TYPE_CHECKING:
|
|
from deepagents_code.skills.load import ExtendedSkillMetadata
|
|
|
|
|
|
class BypassTier(StrEnum):
|
|
"""Classification that controls whether a command can skip the message queue."""
|
|
|
|
ALWAYS = "always"
|
|
"""Execute regardless of any busy state, including mid-thread-switch."""
|
|
|
|
CONNECTING = "connecting"
|
|
"""Bypass only during initial server connection, not during agent/shell."""
|
|
|
|
IMMEDIATE_UI = "immediate_ui"
|
|
"""Open modal UI immediately; real work deferred via `_defer_action` callback."""
|
|
|
|
SIDE_EFFECT_FREE = "side_effect_free"
|
|
"""Execute the side effect immediately; defer chat output until idle."""
|
|
|
|
QUEUED = "queued"
|
|
"""Must wait in the queue when the app is busy."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class SlashCommand:
|
|
"""A single slash-command definition."""
|
|
|
|
name: str
|
|
"""Canonical command name (e.g. `/quit`)."""
|
|
|
|
description: str
|
|
"""Short user-facing description."""
|
|
|
|
bypass_tier: BypassTier
|
|
"""Queue-bypass classification."""
|
|
|
|
hidden_keywords: str = ""
|
|
"""Space-separated terms for fuzzy matching (never displayed)."""
|
|
|
|
argument_hint: str = ""
|
|
"""Placeholder text for autocomplete when the command accepts args."""
|
|
|
|
aliases: tuple[str, ...] = ()
|
|
"""Alternative names (e.g. `("/q",)` for `/quit`)."""
|
|
|
|
def to_entry(self) -> CommandEntry:
|
|
"""Project this command into a `CommandEntry` for autocomplete.
|
|
|
|
Returns:
|
|
A `CommandEntry` carrying only the fields the autocomplete
|
|
layer needs.
|
|
"""
|
|
return CommandEntry(
|
|
name=self.name,
|
|
description=self.description,
|
|
hidden_keywords=self.hidden_keywords,
|
|
argument_hint=self.argument_hint,
|
|
)
|
|
|
|
|
|
COMMANDS: tuple[SlashCommand, ...] = (
|
|
SlashCommand(
|
|
name="/agents",
|
|
description="Browse and switch between available agents",
|
|
bypass_tier=BypassTier.IMMEDIATE_UI,
|
|
hidden_keywords="switch profile persona",
|
|
),
|
|
SlashCommand(
|
|
name="/auth",
|
|
description="Manage stored API keys for model providers",
|
|
bypass_tier=BypassTier.IMMEDIATE_UI,
|
|
hidden_keywords="key keys credential credentials login token api",
|
|
aliases=("/connect",),
|
|
),
|
|
SlashCommand(
|
|
name="/clear",
|
|
description="Clear chat and start new thread",
|
|
bypass_tier=BypassTier.QUEUED,
|
|
hidden_keywords="reset",
|
|
),
|
|
SlashCommand(
|
|
name="/copy",
|
|
description="Copy latest assistant message to clipboard",
|
|
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
|
|
),
|
|
SlashCommand(
|
|
name="/force-clear",
|
|
description="Interrupt active work, clear chat, and start new thread",
|
|
bypass_tier=BypassTier.ALWAYS,
|
|
hidden_keywords="reset interrupt",
|
|
),
|
|
SlashCommand(
|
|
name="/editor",
|
|
description="Open prompt in external editor ($EDITOR)",
|
|
bypass_tier=BypassTier.QUEUED,
|
|
),
|
|
SlashCommand(
|
|
name="/mcp",
|
|
description=(
|
|
"Show MCP servers; `/mcp login <server>` to authenticate, "
|
|
"`/mcp reconnect` to load deferred logins, F2 in the viewer "
|
|
"to disable/enable a server"
|
|
),
|
|
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
|
|
hidden_keywords="servers oauth authenticate reconnect disable enable",
|
|
argument_hint="[login <server> | reconnect]",
|
|
),
|
|
SlashCommand(
|
|
name="/model",
|
|
description="Switch or configure model (--model-params, --default)",
|
|
bypass_tier=BypassTier.IMMEDIATE_UI,
|
|
),
|
|
SlashCommand(
|
|
name="/notifications",
|
|
description="Configure startup warning preferences",
|
|
bypass_tier=BypassTier.IMMEDIATE_UI,
|
|
hidden_keywords="warnings alerts suppress",
|
|
),
|
|
SlashCommand(
|
|
name="/offload",
|
|
description="Free up context window space by offloading older messages",
|
|
bypass_tier=BypassTier.QUEUED,
|
|
hidden_keywords="compact",
|
|
aliases=("/compact",),
|
|
),
|
|
SlashCommand( # Static alias; not auto-generated from skill discovery
|
|
name="/remember",
|
|
description="Update memory and skills from conversation",
|
|
bypass_tier=BypassTier.QUEUED,
|
|
argument_hint="[context]",
|
|
),
|
|
SlashCommand( # Static alias; not auto-generated from skill discovery
|
|
name="/skill-creator",
|
|
description="Guide for creating effective agent skills",
|
|
bypass_tier=BypassTier.QUEUED,
|
|
argument_hint="[task]",
|
|
),
|
|
SlashCommand(
|
|
name="/threads",
|
|
description="Browse and resume previous threads",
|
|
bypass_tier=BypassTier.IMMEDIATE_UI,
|
|
hidden_keywords="continue history sessions",
|
|
),
|
|
SlashCommand(
|
|
name="/trace",
|
|
description="Open current thread in LangSmith",
|
|
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
|
|
),
|
|
SlashCommand(
|
|
name="/tokens",
|
|
description="Token usage",
|
|
bypass_tier=BypassTier.QUEUED,
|
|
hidden_keywords="cost",
|
|
),
|
|
SlashCommand(
|
|
name="/reload",
|
|
description="Reload config from environment variables and .env",
|
|
bypass_tier=BypassTier.QUEUED,
|
|
hidden_keywords="refresh",
|
|
),
|
|
SlashCommand(
|
|
name="/theme",
|
|
description="Switch color theme",
|
|
bypass_tier=BypassTier.IMMEDIATE_UI,
|
|
hidden_keywords="dark light color appearance",
|
|
),
|
|
SlashCommand(
|
|
name="/update",
|
|
description="Check for and install updates",
|
|
bypass_tier=BypassTier.QUEUED,
|
|
hidden_keywords="upgrade",
|
|
),
|
|
SlashCommand(
|
|
name="/install",
|
|
description="Install an optional extra (e.g. quickjs, daytona, fireworks)",
|
|
bypass_tier=BypassTier.QUEUED,
|
|
hidden_keywords="extra extras add provider sandbox dependency",
|
|
argument_hint="<extra> [--force]",
|
|
),
|
|
SlashCommand(
|
|
name="/auto-update",
|
|
description="Toggle automatic updates on or off",
|
|
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
|
|
),
|
|
SlashCommand(
|
|
name="/changelog",
|
|
description="Open changelog in browser",
|
|
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
|
|
),
|
|
SlashCommand(
|
|
name="/version",
|
|
description="Show version",
|
|
bypass_tier=BypassTier.CONNECTING,
|
|
aliases=("/about",),
|
|
),
|
|
SlashCommand(
|
|
name="/feedback",
|
|
description="Submit a bug report or feature request",
|
|
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
|
|
),
|
|
SlashCommand(
|
|
name="/docs",
|
|
description="Open documentation in browser",
|
|
bypass_tier=BypassTier.SIDE_EFFECT_FREE,
|
|
),
|
|
SlashCommand(
|
|
name="/help",
|
|
description="Show help",
|
|
bypass_tier=BypassTier.QUEUED,
|
|
),
|
|
SlashCommand(
|
|
name="/quit",
|
|
description="Exit app",
|
|
bypass_tier=BypassTier.ALWAYS,
|
|
hidden_keywords="close leave",
|
|
aliases=("/q",),
|
|
),
|
|
)
|
|
"""All slash commands."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Derived bypass-tier frozensets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_bypass_set(tier: BypassTier) -> frozenset[str]:
|
|
"""Build a frozenset of command names (including aliases) for a tier.
|
|
|
|
Args:
|
|
tier: The bypass tier to collect.
|
|
|
|
Returns:
|
|
Frozenset of all names and aliases that belong to `tier`.
|
|
"""
|
|
names: set[str] = set()
|
|
for cmd in COMMANDS:
|
|
if cmd.bypass_tier == tier:
|
|
names.add(cmd.name)
|
|
names.update(cmd.aliases)
|
|
return frozenset(names)
|
|
|
|
|
|
ALWAYS_IMMEDIATE: frozenset[str] = _build_bypass_set(BypassTier.ALWAYS)
|
|
"""Commands that execute regardless of any busy state."""
|
|
|
|
BYPASS_WHEN_CONNECTING: frozenset[str] = _build_bypass_set(BypassTier.CONNECTING)
|
|
"""Commands that bypass only during initial server connection."""
|
|
|
|
IMMEDIATE_UI: frozenset[str] = _build_bypass_set(BypassTier.IMMEDIATE_UI)
|
|
"""Commands that open modal UI immediately, deferring real work."""
|
|
|
|
SIDE_EFFECT_FREE: frozenset[str] = _build_bypass_set(BypassTier.SIDE_EFFECT_FREE)
|
|
"""Commands whose side effect fires immediately; chat output deferred until idle."""
|
|
|
|
QUEUE_BOUND: frozenset[str] = _build_bypass_set(BypassTier.QUEUED)
|
|
"""Commands that must wait in the queue when the app is busy."""
|
|
|
|
HIDDEN_COMMANDS: frozenset[str] = frozenset({"/debug-error", "/restart"})
|
|
"""Power-user commands kept out of autocomplete and help.
|
|
|
|
Includes both debug helpers (`/debug-error`) and recovery escape hatches
|
|
(`/restart` — hot-respawn the app-owned LangGraph server).
|
|
"""
|
|
|
|
ALL_CLASSIFIED: frozenset[str] = (
|
|
ALWAYS_IMMEDIATE
|
|
| BYPASS_WHEN_CONNECTING
|
|
| IMMEDIATE_UI
|
|
| SIDE_EFFECT_FREE
|
|
| QUEUE_BOUND
|
|
| HIDDEN_COMMANDS
|
|
)
|
|
"""Union of all tiers plus hidden commands — used by drift tests."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Autocomplete entries
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class CommandEntry(NamedTuple):
|
|
"""A single autocomplete entry for the slash-command controller."""
|
|
|
|
name: str
|
|
"""Canonical command name (e.g. `/quit`)."""
|
|
|
|
description: str
|
|
"""Short user-facing description."""
|
|
|
|
hidden_keywords: str
|
|
"""Space-separated terms for fuzzy matching (never displayed)."""
|
|
|
|
argument_hint: str
|
|
"""Placeholder text shown when the command accepts arguments (e.g. `[context]`)."""
|
|
|
|
|
|
SLASH_COMMANDS: list[CommandEntry] = [cmd.to_entry() for cmd in COMMANDS]
|
|
"""Autocomplete entries derived from `COMMANDS` for `SlashCommandController`."""
|
|
|
|
|
|
def parse_skill_command(command: str) -> tuple[str, str]:
|
|
"""Extract skill name and args from a `/skill:<name>` command.
|
|
|
|
Args:
|
|
command: The full command string (e.g., `/skill:web-research find X`).
|
|
|
|
Returns:
|
|
Tuple of `(skill_name, args)`.
|
|
|
|
The skill name is normalized to lowercase. Both are empty strings
|
|
when the command has no skill name after the prefix.
|
|
"""
|
|
after_prefix = command[len("/skill:") :].strip()
|
|
parts = after_prefix.split(maxsplit=1)
|
|
if not parts or not parts[0]:
|
|
return "", ""
|
|
skill_name = parts[0].lower()
|
|
args = parts[1] if len(parts) > 1 else ""
|
|
return skill_name, args
|
|
|
|
|
|
_STATIC_SKILL_ALIASES: frozenset[str] = frozenset({"remember", "skill-creator"})
|
|
"""Built-in skill names that have a dedicated top-level slash command.
|
|
|
|
Only list skills whose `/skill:<name>` form is redundant because a `/<name>`
|
|
convenience alias exists in `COMMANDS`. Do **not** add every command name
|
|
here — that would silently suppress unrelated user skills that happen to share a
|
|
name with a slash command (e.g., a user skill called `model` should still
|
|
appear as `/skill:model`).
|
|
"""
|
|
|
|
|
|
def build_skill_commands(
|
|
skills: list[ExtendedSkillMetadata],
|
|
) -> list[CommandEntry]:
|
|
"""Build autocomplete entries for discovered skills.
|
|
|
|
Each skill becomes a `/skill:<name>` entry with its description
|
|
and the skill name as a hidden keyword for fuzzy matching.
|
|
|
|
Skills that already have a dedicated slash command in `COMMANDS`
|
|
(e.g., `remember` → `/remember`) are excluded to avoid duplicate
|
|
autocomplete entries.
|
|
|
|
Args:
|
|
skills: List of discovered skill metadata.
|
|
|
|
Returns:
|
|
List of `CommandEntry` instances.
|
|
"""
|
|
return [
|
|
CommandEntry(
|
|
name=f"/skill:{skill['name']}",
|
|
description=skill["description"],
|
|
hidden_keywords=skill["name"],
|
|
argument_hint="",
|
|
)
|
|
for skill in skills
|
|
if skill["name"] not in _STATIC_SKILL_ALIASES
|
|
]
|