diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..bdc2b865f1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git/ +.DS_Store +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.ty/ +.venv/ +node_modules/ +.coverage +*.mp4 + +examples/whatsapp-channel/ + +.env +.env.* +*.pem +*.key +*.crt +credentials.json diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000000..517d728301 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +# Branch-name check for pre-push: enforce the repo branch naming convention +# // +# on branches pushed to this repo. See the "Branch naming" section of AGENTS.md. +# +# This script runs as a pre-commit `pre-push` hook (see .pre-commit-config.yaml), +# so it is installed by the usual `pre-commit install --install-hooks` setup — +# no separate `core.hooksPath` wiring (which would shadow the other installed +# hooks). Under pre-commit, the pushed branch arrives as +# `PRE_COMMIT_REMOTE_BRANCH` (pre-commit does not forward the raw pre-push +# stdin). The script also accepts direct git pre-push invocation with the +# standard " " lines on stdin. +# +# KNOWN GAPS in the pre-commit route (the tradeoff for not using +# `core.hooksPath`). pre-commit's pre-push driver decides what to report before +# any hook runs, so the script cannot see around these: +# +# 1. Multi-ref pushes are only partially checked. `_pre_push_ns` +# (pre_commit/commands/hook_impl.py, as of pre-commit 4.x) skips deletions +# and refs the remote already has, then returns on the first ref that does +# have new commits — so `PRE_COMMIT_REMOTE_BRANCH` names exactly one +# branch, and which one depends on stdin order rather than the order you +# named them. `git push origin a b` and `git push --all` validate one of +# them, not necessarily the first. +# 2. A push with no new commits runs no hooks at all. If the pushed ref points +# only at commits the remote already has (e.g. branching off `main` and +# pushing immediately), `_pre_push_ns` finds nothing to push and pre-commit +# returns early, silently — no output at all — without invoking this hook. +# `always_run: true` does not change that; the hook-running code is never +# reached. +# +# Both are covered by .github/workflows/branch_name_check.yml, which sees the +# real head ref regardless. Under direct git invocation (`core.hooksPath` or a +# hand-installed .git/hooks/pre-push) neither gap applies — the stdin loop below +# validates every ref. +# +# This is a local convenience only — it can be skipped with `git push --no-verify` +# (or the standard `SKIP=branch-name git push` for the pre-commit integration). +# Server-side enforcement lives in .github/workflows/branch_name_check.yml. + +set -euo pipefail + +# Protected branches and automation branch prefixes that never carry a +# / prefix. The `alpha|beta|rc|dev` prefixes are the release +# branches mandated by .github/RELEASING.md (e.g. `alpha/deepagents-0-7-0a1`). +ALLOWED_RE='^(main|master|v[0-9]+\.[0-9]+.*)$' +ALLOWED_PREFIX_RE='^(release-please--|dependabot/|copilot/|alpha/|beta/|rc/|dev/)' + +# Scopes mirror the allowed scopes in .github/workflows/pr_lint.yml plus `docs`, +# which AGENTS.md lists as a valid branch scope. All three patterns above and +# below are duplicated in .github/workflows/branch_name_check.yml; the +# `branch-scopes-sync` pre-commit hook +# (.github/scripts/checks/check_branch_scopes_sync.py) fails the commit if the +# copies drift, so edit them together. +SCOPES_RE='(acp|ci|cli|code|dcode-gha|daytona|deepagents|deepagents-acp|deepagents-cli|deepagents-code|deepagents-talon|deps|deps-dev|docs|evals|examples|harbor|infra|langchain-daytona|langchain-modal|langchain-quickjs|langchain-runloop|langchain-vercel-sandbox|langsmith-sandbox|modal|quickjs|repo|runloop|sdk|talon|vercel)' + +# Kebab-case description: lowercase alphanumerics and hyphens, no leading or +# trailing hyphen. The final group is optional so one-character descriptions are +# valid. +DESC_RE='[a-z0-9]([a-z0-9-]*[a-z0-9])?' + +# Resolve the expected GitHub login. `github.user` is not set by default; the +# cleanest fallback is the GitHub login recorded by the GitHub CLI (`gh`), then +# the local part of the committer email (a common personal convention). +# `user_source` is reported in the failure message so a wrong guess is +# diagnosable rather than mysterious. +# +# Resolution is lazy and memoized: it runs on the first branch that actually +# needs a username, so pushing a protected, automation or release branch — none +# of which carry a username segment — never requires a resolvable login. A +# contributor with no `github.user`, no `gh` and a `users.noreply.github.com` +# commit email can still push `main` or `alpha/...`. +github_user="" +user_source="" +user_resolved=0 + +resolve_github_user() { + if [ "$user_resolved" -eq 1 ]; then + return 0 + fi + user_resolved=1 + + local config_rc=0 + github_user="$(git config --get github.user)" || config_rc=$? + if [ "$config_rc" -gt 1 ]; then + echo "error: 'git config --get github.user' failed (exit $config_rc); check your git config." >&2 + exit 1 + fi + if [ -n "$github_user" ]; then + user_source='git config github.user' + fi + + if [ -z "$github_user" ]; then + if command -v gh >/dev/null 2>&1; then + # Report `gh` failures instead of muting them: its own message (e.g. + # "run gh auth login") is more actionable than the email guess we + # fall back to. Capture stderr separately — `gh` writes its upgrade + # notice there on otherwise successful commands, and folding that + # into stdout would splice it into the resolved username. + local gh_err + gh_err="$(mktemp)" + if github_user="$(gh api user --jq .login 2>"$gh_err")"; then + user_source='gh api user' + else + echo "note: could not resolve your GitHub login via 'gh': $(head -n 1 "$gh_err")" >&2 + echo "note: falling back to the local part of your git user.email." >&2 + fi + rm -f "$gh_err" + else + # Symmetry with the failure branch above: an absent `gh` is just as + # much a reason the email guess below may be wrong. + echo "note: 'gh' is not installed; falling back to the local part of your git user.email." >&2 + fi + fi + + if [ -z "$github_user" ]; then + local email_rc=0 + local email + email="$(git config --get user.email)" || email_rc=$? + if [ "$email_rc" -gt 1 ]; then + echo "error: 'git config --get user.email' failed (exit $email_rc); check your git config." >&2 + exit 1 + fi + if [ -n "$email" ] && [ "$email" != "${email%@*}" ]; then + github_user="${email%@*}" + user_source="local part of user.email ($email)" + fi + fi + + if [ -z "$github_user" ]; then + cat >&2 <<'EOF' +error: could not determine your GitHub username to validate the branch name. +Set it with: git config github.user +EOF + exit 1 + fi + + # A resolved value that is not a valid GitHub login means a fallback guessed + # wrong (e.g. `12345+user` from a noreply address, or `first.last` from a + # corporate one). Fail with instructions rather than validating against it. + case "$github_user" in + *[!A-Za-z0-9-]* | -* | *- | '') + cat >&2 < +EOF + exit 1 + ;; + esac +} + +fail=0 + +check_branch() { + local branch="$1" + + if [[ "$branch" =~ $ALLOWED_RE ]] || [[ "$branch" =~ $ALLOWED_PREFIX_RE ]]; then + return 0 + fi + + resolve_github_user + + # Split into exactly three segments and compare the username literally. + # Interpolating it into a regex would treat any metacharacter in a + # mis-resolved login as a pattern, which accepts and rejects the wrong + # names in both directions. + local user_seg="${branch%%/*}" + local rest="${branch#*/}" + local scope_seg="${rest%%/*}" + local desc_seg="${rest#*/}" + + if [ "$user_seg" != "$branch" ] && + [ "$scope_seg" != "$rest" ] && + [[ "$desc_seg" != */* ]] && + [ "$user_seg" = "$github_user" ] && + [[ "$scope_seg" =~ ^${SCOPES_RE}$ ]] && + [[ "$desc_seg" =~ ^${DESC_RE}$ ]]; then + return 0 + fi + + cat >&2 <// + example: ${github_user}/cli/startup-cmd-flag + +Your resolved GitHub username is '$github_user', from $user_source +(override with: git config github.user ). +Scopes: $(echo "$SCOPES_RE" | tr -d '()' | tr '|' ' ') +(the scopes in pr_lint.yml, plus \`docs\`). + +Rename with: git branch -m +Bypass with: git push --no-verify (or SKIP=branch-name git push under pre-commit) +EOF + fail=1 +} + +# Number of ref updates this invocation was able to inspect. Zero means the +# check never ran, which must be an error rather than a silent pass. +refs_seen=0 + +if [ -n "${PRE_COMMIT_REMOTE_BRANCH:-}" ]; then + # pre-commit pre-push integration; one ref only (see KNOWN GAPS above). + refs_seen=1 + case "$PRE_COMMIT_REMOTE_BRANCH" in + refs/heads/*) check_branch "${PRE_COMMIT_REMOTE_BRANCH#refs/heads/}" ;; + *) echo "note: skipping branch-name check for non-branch ref '$PRE_COMMIT_REMOTE_BRANCH'." >&2 ;; + esac +elif [ ! -t 0 ]; then + # Direct git pre-push invocation: " + # " per line on stdin. The `|| [ -n "$remote_ref" ]` keeps a + # final line with no trailing newline, which `read` reports as EOF. + while read -r _local_ref local_sha remote_ref _remote_sha || [ -n "$remote_ref" ]; do + [ -n "$remote_ref" ] || continue + refs_seen=$((refs_seen + 1)) + + # A local sha of all zeros is a remote branch deletion: nothing to name. + [ "$local_sha" = "0000000000000000000000000000000000000000" ] && continue + + branch="${remote_ref#refs/heads/}" + [ "$branch" = "$remote_ref" ] && continue # not a branch push (e.g. a tag) + + check_branch "$branch" + done +fi + +if [ "$refs_seen" -eq 0 ]; then + cat >&2 <<'EOF' +error: branch-name check could not determine which branch is being pushed. + PRE_COMMIT_REMOTE_BRANCH is unset and no ref updates arrived on stdin. + +This check needs the pushed ref, which a bare +`pre-commit run --hook-stage pre-push` does not supply — it would otherwise +report a pass without having validated anything. + +To exercise the real pre-commit path, pass the ref explicitly: + pre-commit run --hook-stage pre-push --remote-name origin \ + --remote-url "$(git remote get-url origin)" \ + --remote-branch refs/heads/bad-name \ + --local-branch "$(git branch --show-current)" + +To exercise this script on its own: + echo "refs/heads/x $(git rev-parse HEAD) refs/heads/bad-name $(git rev-parse HEAD)" | .githooks/pre-push +EOF + exit 1 +fi + +exit "$fail" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 30e0ba283b..bdaa319ed4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,9 +3,10 @@ # Owners will be automatically requested for review when someone opens a pull request. # For more information: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -/.github/ @mdrxy @eyurtsev -/libs/sdk/ @hwchase17 @sydney-runkle @ccurme @eyurtsev @vtrivedy +/.github/ @mdrxy +/libs/sdk/ @hwchase17 @sydney-runkle @ccurme /libs/cli/ @mdrxy -/libs/evals/ @mdrxy @vtrivedy @maahir30 +/libs/evals/ @mdrxy +/libs/talon/ @jkennedyvz /libs/partners/ @eyurtsev -/libs/acp/ @jacoblee93 +/libs/partners/quickjs/ @hntrl diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 4b061c1c34..2c62fa1777 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -46,15 +46,16 @@ body: description: Which area of the repository does this issue relate to? Select at least one. options: - label: deepagents (SDK) - - label: cli + - label: dcode + - label: talon - label: acp - label: evals - label: harbor - - label: repl - label: daytona - label: modal - label: quickjs - label: runloop + - label: vercel - label: langsmith-sandbox - label: Other / not sure / general - type: textarea @@ -118,6 +119,8 @@ body: Python: 3.x.x deepagents: 0.x.y deepagents-cli: 0.x.y + deepagents-code: 0.x.y + deepagents-talon: 0.x.y - type: markdown attributes: value: | diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index f00ac860cc..29443af582 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -43,15 +43,16 @@ body: description: Which area of the repository does this request relate to? Select at least one. options: - label: deepagents (SDK) - - label: cli + - label: dcode + - label: talon - label: acp - label: evals - label: harbor - - label: repl - label: daytona - label: modal - label: quickjs - label: runloop + - label: vercel - label: langsmith-sandbox - label: Other / not sure / general - type: textarea diff --git a/.github/ISSUE_TEMPLATE/privileged.yml b/.github/ISSUE_TEMPLATE/privileged.yml index d3373648fd..1d84078f76 100644 --- a/.github/ISSUE_TEMPLATE/privileged.yml +++ b/.github/ISSUE_TEMPLATE/privileged.yml @@ -30,15 +30,16 @@ body: Please select area(s) that this issue is related to. options: - label: deepagents (SDK) - - label: cli + - label: dcode + - label: talon - label: acp - label: evals - label: harbor - - label: repl - label: daytona - label: modal - label: quickjs - label: runloop + - label: vercel - label: langsmith-sandbox - label: Other / not sure / general - type: markdown diff --git a/.github/LAYOUT.md b/.github/LAYOUT.md new file mode 100644 index 0000000000..fead2d3b75 --- /dev/null +++ b/.github/LAYOUT.md @@ -0,0 +1,76 @@ +# `.github` layout + +Quick map of CI/automation files in this folder. + +## Top level + +| Path | Purpose | +| --- | --- | +| `workflows/` | GitHub Actions workflows (entrypoints and reusable callers) | +| `actions/` | Local composite actions consumed by workflows | +| `scripts/` | Helper scripts invoked by workflows, plus their tests | +| `ISSUE_TEMPLATE/` | Issue forms | +| `PULL_REQUEST_TEMPLATE.md` | Default PR body template | +| `CODEOWNERS` | Review routing for paths in this tree | +| `dependabot.yml` | Dependabot update groups | +| `RELEASING.md` | Release-please / publish process | +| `SECRETS.md` | Non-`GITHUB_TOKEN` CI credential inventory (names and scopes only) | +| `images/` | Static assets referenced by workflows or docs | + +Package-level CI conventions and partner onboarding checklists live in root [`AGENTS.md`](../AGENTS.md). + +## Workflows (`workflows/`) + +- **Entry workflows** (no leading underscore) run on events such as `pull_request`, `push`, `schedule`, or `workflow_dispatch`. +- **Reusable workflows** are named `_*.yml` (for example `_lint.yml`, `_test.yml`, `_eval.yml`) and are called from entry workflows via `workflow_call`. +- Prefer extending an existing reusable workflow over pasting setup/checkout/`uv` boilerplate into a new entry file. + +Credential placement rules are in [`SECRETS.md`](./SECRETS.md). Release wiring is in [`RELEASING.md`](./RELEASING.md). + +## Local composite actions (`actions/`) + +Reusable steps shared by multiple workflows. Today this is mainly `actions/uv_setup` (Python + pinned `uv` with caching). Add a new composite action here only when two or more workflows need the same multi-step setup. + +## Helper scripts (`scripts/`) + +Production helpers are nested by domain: + +```text +scripts/ +├── checks/ # repo integrity / sync checkers +├── evals/ # eval/harbor matrix and aggregation +├── labeling/ # PR/issue labeling and triage automation +├── release/ # release-please guards, notes, pin checks +└── tests/ # tests for the helpers above (and some workflow contracts) +``` + +### Placement rules + +1. **Put new helpers in an existing domain folder** when they clearly belong there. +2. **Add a domain folder** only for a sustained new area (not a one-off script). Keep the name short and topic-style like the neighbors. +3. Prefer plain modules invoked with `python .github/scripts//" + if status == "success" + else "" + ) + return ( + '' + '' + f"{escaped_title}" + "" + '
' + f'
{mark}
' + f"

{escaped_heading}

{escaped}

" + "
" + f"{auto_close}" + "" + ) + + +class MCPReauthRequiredError(RuntimeError): + """Raised when an MCP server needs interactive re-authentication.""" + + def __init__(self, server_name: str) -> None: + """Build with `server_name` so the message tells the user what to fix.""" + self.server_name = server_name + super().__init__( + f"MCP server {server_name!r} needs re-authentication. " + f"Run `/mcp login {server_name}` in the TUI, or " + f"`dcode mcp login {server_name}` from the shell.", + ) + + +def _make_reauth_required_handlers( + server_name: str, +) -> tuple[RedirectHandler, CallbackHandler]: + """Return OAuth handlers that refuse to prompt and raise instead. + + Used in non-interactive server mode so that a missing or expired token + surfaces as `MCPReauthRequiredError` rather than hanging on `input()`. + """ + + async def redirect(_auth_url: str) -> None: # noqa: RUF029 + raise MCPReauthRequiredError(server_name) + + async def callback() -> tuple[str, str | None]: # noqa: RUF029 + raise MCPReauthRequiredError(server_name) + + return redirect, callback + + +def _make_paste_back_handlers( + *, + extra_auth_params: dict[str, str] | None = None, + ui: OAuthInteraction | None = None, +) -> tuple[RedirectHandler, CallbackHandler]: + """Create paste-back redirect and callback handlers for OAuth. + + Args: + extra_auth_params: Extra query params to append to the auth URL. + ui: Interaction surface for the auth URL display and the + pasted-back callback URL prompt. + + Returns: + A tuple of `(redirect_handler, callback_handler)`. + """ + extras = dict(extra_auth_params or {}) + interaction = ui if ui is not None else _default_ui() + + async def redirect(auth_url: str) -> None: + final_url = _append_query_params(auth_url, extras) if extras else auth_url + await interaction.show_authorize_url(final_url, opened_in_browser=False) + + async def callback() -> tuple[str, str | None]: + url = await interaction.request_callback_url() + return _parse_callback_url(url) + + return redirect, callback + + +def _parse_callback_url(url: str) -> tuple[str, str | None]: + """Parse a provider callback URL into `(code, state)`. + + Args: + url: Raw callback URL pasted by the user. + + Returns: + The `code` and optional `state` query parameters. + + Raises: + RuntimeError: If the URL contains `error=` or lacks `code`. + """ + params = parse_qs(urlparse(url).query) + if "error" in params: + err_code = params["error"][0] + err_desc = (params.get("error_description") or [""])[0] + detail = f": {err_desc}" if err_desc else "" + msg = f"Authorization denied by provider: {err_code}{detail}" + raise RuntimeError(msg) + if "code" not in params or not params["code"]: + msg = "Callback URL is missing the 'code' parameter." + raise RuntimeError(msg) + return params["code"][0], (params.get("state") or [None])[0] + + +def _default_ui() -> OAuthInteraction: + """Return the default `OAuthInteraction` implementation (CLI stdio).""" + from deepagents_code.mcp_oauth_ui import CliOAuthInteraction + + return CliOAuthInteraction() + + +def _make_loopback_handlers( + *, + callback_server: _LoopbackOAuthCallbackServer, + extra_auth_params: dict[str, str] | None = None, + ui: OAuthInteraction | None = None, +) -> tuple[RedirectHandler, CallbackHandler]: + """Create browser loopback redirect and callback handlers for OAuth. + + Args: + callback_server: Prepared local callback server for this login attempt. + The socket is bound when the returned redirect handler is first called. + extra_auth_params: Extra query params to append to the auth URL. + ui: Interaction surface for the browser-opened or fallback prompts. + + Returns: + A tuple of `(redirect_handler, callback_handler)`. + """ + extras = dict(extra_auth_params or {}) + interaction = ui if ui is not None else _default_ui() + last_authorize_url: str | None = None + _paste_redirect, paste_callback = _make_paste_back_handlers( + extra_auth_params=extra_auth_params, + ui=interaction, + ) + + async def redirect(auth_url: str) -> None: + import asyncio + import webbrowser + + nonlocal last_authorize_url + final_url = _append_query_params(auth_url, extras) if extras else auth_url + last_authorize_url = final_url + + # Resolve a browser explicitly before opening so headless / SSH + # environments fall through to paste-back without burning the + # 300s loopback timeout. `webbrowser.open` can return `True` in + # those environments even when nothing launches. + try: + await asyncio.to_thread(webbrowser.get) + has_browser = True + except webbrowser.Error: + has_browser = False + + if has_browser: + opened = await asyncio.to_thread(webbrowser.open, final_url) + else: + opened = False + if not opened: + callback_server.fail( + _LoopbackCallbackUnavailableError( + "No browser is available to complete the OAuth flow.", + ), + ) + await interaction.show_authorize_url(final_url, opened_in_browser=False) + return + try: + callback_server.start() + except OSError as exc: + logger.warning( + "Could not start loopback OAuth callback server on port %s: %s", + callback_server.port, + exc, + ) + msg = "Local OAuth callback server could not be started." + callback_server.fail(_LoopbackCallbackUnavailableError(msg)) + await interaction.show_notice( + "Could not start the local OAuth callback server.", + ) + await interaction.show_authorize_url(final_url, opened_in_browser=False) + return + await interaction.show_authorize_url(final_url, opened_in_browser=True) + + async def callback() -> tuple[str, str | None]: + try: + return await callback_server.wait() + except ( + _LoopbackCallbackTimeoutError, + _LoopbackCallbackUnavailableError, + ) as exc: + if last_authorize_url is not None: + await interaction.show_authorize_url( + last_authorize_url, + opened_in_browser=False, + ) + await interaction.show_notice( + f"{exc}\nPaste the full callback URL instead.", + ) + return await paste_callback() + finally: + callback_server.close() + + return redirect, callback + + +def _append_query_params(url: str, params: dict[str, str]) -> str: + """Return `url` with `params` replacing any same-named query keys.""" + from urllib.parse import urlencode, urlunparse + + parsed = urlparse(url) + existing = dict(parse_qs(parsed.query, keep_blank_values=True)) + for key, value in params.items(): + existing[key] = [value] + return urlunparse(parsed._replace(query=urlencode(existing, doseq=True))) + + +def _strip_duplicate_client_id_under_basic_auth(context: OAuthContext) -> None: + """Drop the redundant body `client_id` when token auth uses HTTP Basic. + + The MCP SDK copies `client_id` into the token-request body (on both the + authorization-code exchange and refresh paths) and, for + `token_endpoint_auth_method == "client_secret_basic"`, *also* sends it in the + `Authorization: Basic` header. RFC 6749 §2.3.1 carries the client identity in + the header for Basic auth, so the body copy is redundant; some authorization + servers (e.g. Pylon) reject the duplicate identity with an `OAuthTokenError`. + Wrapping `prepare_token_auth` strips the body `client_id` only when a Basic + header is present, leaving `client_secret_post`/`none` flows untouched. + """ + original = context.prepare_token_auth + + def prepare_token_auth( + data: dict[str, str], + headers: dict[str, str] | None = None, + ) -> tuple[dict[str, str], dict[str, str]]: + data, headers = original(data, headers) + # RFC 7617 makes the auth-scheme token case-insensitive, so match + # `basic` regardless of casing rather than coupling to the SDK's exact + # `Basic ` literal. + if headers.get("Authorization", "").lower().startswith("basic "): + data = {k: v for k, v in data.items() if k != "client_id"} + return data, headers + + context.prepare_token_auth = prepare_token_auth # ty: ignore[invalid-assignment] + + +class _ExpiryAwareOAuthClientProvider(OAuthClientProvider): + """`OAuthClientProvider` that restores `token_expiry_time` from storage. + + Upstream `_initialize` loads stored tokens but leaves + `context.token_expiry_time` at `None`, which makes `is_token_valid` + report any stored access token — even one that expired hours ago — + as valid. The SDK then sends a stale `Bearer`, gets a 401, and falls + into a full re-auth (browser) instead of the `refresh_token` grant. + + Restoring the persisted absolute expiry to the context after load + lets the SDK's refresh-when-invalid-and-refreshable branch fire on + the first request after a cold start. When the sidecar is absent + (older token files written before this field existed), assume the + token is expired so the refresh path still gets a chance before + falling back to 401. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._suppress_expected_reauth_logs = bool( + kwargs.pop("suppress_expected_reauth_logs", False) + ) + super().__init__(*args, **kwargs) + _strip_duplicate_client_id_under_basic_auth(self.context) + + async def _initialize(self) -> None: + # Overrides a leading-underscore SDK method; behavior depends on + # `super()._initialize()` populating `context.current_tokens` from + # storage. If an upstream rename or refactor breaks that contract, + # the test suite's TestExpiryAwareOAuthClientProvider cases will + # fail loudly rather than silently regress to the 401-on-restart + # bug this class exists to prevent. + await super()._initialize() + await self._apply_stored_expiry() + + async def _apply_stored_expiry(self) -> None: + """Seed `context.token_expiry_time` from the persisted sidecar. + + Upstream `_initialize` loads stored tokens but leaves the expiry unset, + so a token whose access portion expired long ago still reports as valid + and is sent stale. Restoring the absolute expiry recorded beside the + token lets `is_token_valid` return `False` in time for the cheaper + refresh grant to fire. Also caches persisted OAuth metadata so the + refresh uses the advertised token endpoint. Safe to call repeatedly, so + it doubles as the post-reload expiry refresh. + """ + if self.context.oauth_metadata is None: + get_oauth_metadata = getattr( + self.context.storage, + "get_oauth_metadata", + None, + ) + if get_oauth_metadata is not None: + self.context.oauth_metadata = await get_oauth_metadata() + get_tokens_with_expiry = getattr( + self.context.storage, + "get_tokens_with_expiry", + None, + ) + if get_tokens_with_expiry is not None: + tokens, expires_at = await get_tokens_with_expiry() + # Keep the token and expiry paired from the same file snapshot. A + # peer may rotate both while the upstream initializer is yielding. + self.context.current_tokens = tokens + else: + get_expires_at = getattr(self.context.storage, "get_expires_at", None) + if get_expires_at is None: + return + expires_at = await get_expires_at() + tokens = self.context.current_tokens + if expires_at is None: + # Use 1.0 (one second after the Unix epoch) rather than 0.0 so the + # SDK's `not self.token_expiry_time` falsy-zero check doesn't treat + # the sentinel as "no expiry known" and mark the token valid again. + if tokens is not None and tokens.refresh_token: + self.context.token_expiry_time = 1.0 + elif tokens is not None and tokens.access_token: + # Legacy file with no refresh_token: nothing we can do to + # pre-empt expiry. Surface a structural breadcrumb so the + # 401-then-browser-reauth flow isn't completely silent. + logger.info( + "Legacy MCP token file for %s has no refresh_token; " + "cannot pre-empt expiry. The next 401 will trigger " + "browser re-auth.", + self.context.server_url, + ) + return + if expires_at - time.time() < _REFRESH_SAFETY_MARGIN_SECONDS: + # Token already inside its safety margin (or past it) — likely a + # cold start after a long pause, or a misconfigured server issuing + # sub-margin lifetimes. Log only the duration, never any token + # material. + logger.debug( + "MCP token for %s is within %.0fs of expiry on load; " + "scheduling refresh on next request.", + self.context.server_url, + _REFRESH_SAFETY_MARGIN_SECONDS, + ) + self.context.token_expiry_time = expires_at - _REFRESH_SAFETY_MARGIN_SECONDS + + async def _reload_tokens_from_storage(self) -> None: + """Re-read persisted tokens so a peer's refresh is observed. + + Another dcode process (or a separate provider instance in this process) + may have rotated the refresh token on disk while this provider held a + now-stale copy in memory. Re-reading before deciding to refresh keeps + this provider from replaying an already-rotated refresh token, which + the LangSmith OAuth server treats as reuse and punishes by revoking the + whole identity+client token family. + """ + self.context.current_tokens = await self.context.storage.get_tokens() + client_info = await self.context.storage.get_client_info() + if client_info is not None: + self.context.client_info = client_info + await self._apply_stored_expiry() + + async def _acquire_refresh_lock(self, lock: FileLock) -> bool: + """Wait for the cross-process refresh lock off the event loop. + + `lock.acquire` blocks for up to `_REFRESH_LOCK_TIMEOUT_SECONDS` while a + peer finishes its refresh, so it runs in a worker thread to avoid + stalling the event loop for that long. + + Args: + lock: The `filelock.FileLock` serializing refreshes for this server + (backed by the sidecar `.lock` file, not the token file). + + Returns: + `True` when the lock was acquired; `False` when the wait timed out + or the lock could not be created, signalling the caller to avoid + using the possibly in-flight refresh token after reloading. + """ + acquire_task = asyncio.create_task( + asyncio.to_thread( + lock.acquire, + timeout=_REFRESH_LOCK_TIMEOUT_SECONDS, + ) + ) + cancellation = await _join_task_deferring_cancellation(acquire_task) + try: + acquire_task.result() + except Timeout: + if cancellation is not None: + raise cancellation from None + # A timeout means a peer may still be mid-refresh with this same + # token. Do not refresh unlocked: rotating-token servers can treat + # the second grant as reuse and revoke the whole token family. + logger.warning( + "Timed out after %.0fs waiting for the MCP token refresh lock " + "for %s; skipping refresh to avoid refresh-token reuse.", + _REFRESH_LOCK_TIMEOUT_SECONDS, + self.context.server_url, + ) + return False + except OSError as exc: + if cancellation is not None: + raise cancellation from None + # Creating/locking the sidecar can fail (read-only or missing + # tokens dir, permission denial on a hardened host). Avoid an + # unlocked refresh so we do not replay a rotating refresh token if a + # peer did manage to take the lock. + logger.warning( + "Could not acquire the MCP token refresh lock for %s (%s); " + "skipping refresh to avoid refresh-token reuse.", + self.context.server_url, + type(exc).__name__, + ) + return False + if cancellation is not None: + raise cancellation + return True + + @contextlib.asynccontextmanager + async def _refresh_lock_guard(self, lock_path: Path) -> AsyncIterator[bool]: + """Hold the cross-process refresh lock across the serialized refresh. + + Acquires the lock (waiting up to `_REFRESH_LOCK_TIMEOUT_SECONDS`; on + timeout it yields `False` so the caller can avoid the refresh grant). + Release is gated on `lock.is_locked` rather than the acquire result, so + a cancellation that lands *after* the worker thread took the lock still + frees it instead of orphaning it, while a timed-out/failed acquisition + skips the release. Acquisition and release are each joined before + cancellation escapes, so neither worker can acquire or retain the lock + after the guard has returned. + + Args: + lock_path: Sibling `.lock` path from `FileTokenStorage`. + + Yields: + Whether the refresh lock was acquired. + """ + # `thread_local=False` because acquire and release run in different + # `asyncio.to_thread` worker threads; the default would refuse the + # cross-thread release and leak the OS lock until process exit. + lock = FileLock(str(lock_path), thread_local=False) + pending_exception: BaseException | None = None + try: + yield await self._acquire_refresh_lock(lock) + except BaseException as exc: + # Preserve the guarded operation's exception while joining cleanup. + pending_exception = exc + raise + finally: + if lock.is_locked: + release_task = asyncio.create_task(asyncio.to_thread(lock.release)) + cancellation = await _join_task_deferring_cancellation(release_task) + try: + release_task.result() + except Exception as exc: + if pending_exception is None: + # No guarded error to preserve, so the release failure + # is the primary error to surface. It supersedes any + # deferred cancellation; log that loss so it is not + # silent. + if cancellation is not None: + logger.warning( + "MCP token refresh lock release for %s failed; " + "a deferred cancellation is superseded by the " + "release error.", + self.context.server_url, + ) + raise + # Preserve the guarded operation's exception — including a + # `CancelledError`, whose propagation structured + # cancellation depends on — and record the release failure + # as a note rather than masking the original with it. + pending_exception.add_note( + "MCP refresh lock release also failed with " + f"{type(exc).__name__}." + ) + logger.warning( + "Failed to release the MCP token refresh lock for %s " + "while propagating %s", + self.context.server_url, + type(pending_exception).__name__, + exc_info=True, + ) + else: + # Release succeeded. Re-raise a deferred cancellation unless + # a guarded error is already propagating, in which case the + # guarded error wins; log the superseded cancellation so the + # dropped edge is not silent (parity with the failure path). + if cancellation is not None: + if pending_exception is None: + raise cancellation + logger.warning( + "MCP token refresh lock for %s released cleanly, but " + "a deferred cancellation is superseded by the " + "in-flight %s.", + self.context.server_url, + type(pending_exception).__name__, + ) + + async def _persist_oauth_metadata(self) -> None: + """Persist discovered public OAuth metadata when storage supports it.""" + if self.context.oauth_metadata is None: + return + set_oauth_metadata = getattr(self.context.storage, "set_oauth_metadata", None) + if set_oauth_metadata is not None: + await set_oauth_metadata(self.context.oauth_metadata) + + async def _handle_token_response(self, response: httpx.Response) -> None: + """Persist tokens and any metadata discovered during full OAuth login.""" + await super()._handle_token_response(response) + await self._persist_oauth_metadata() + + async def _handle_locked_refresh_response(self, response: httpx.Response) -> bool: + """Handle a serialized refresh without bypassing SDK re-auth fallback. + + Args: + response: Refresh endpoint response returned through the auth generator. + + Returns: + `True` when refresh succeeded, otherwise `False` so the caller can + continue into the delegated SDK flow. + """ + try: + return bool(await self._handle_refresh_response(response)) + except Exception: + if response.status_code not in _EXPECTED_REAUTH_REFRESH_STATUS_CODES: + raise + logger.debug( + "Locked MCP token refresh for %s failed with %s; " + "deferring to the SDK re-auth flow.", + self.context.server_url, + response.status_code, + ) + self.context.clear_tokens() + self._initialized = False + return False + + async def async_auth_flow( + self, + request: httpx.Request, + ) -> AsyncGenerator[httpx.Request, httpx.Response]: + """Discover and cache OAuth metadata before the SDK refresh branch. + + Yields: + HTTP requests for OAuth metadata discovery and the delegated SDK auth flow. + """ + async with self.context.lock: + if not self._initialized: + await self._initialize() + self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION) + if ( + not self.context.is_token_valid() + and self.context.can_refresh_token() + and self.context.oauth_metadata is None + ): + # Pre-empt the SDK's 401-path discovery so its refresh branch + # finds populated `oauth_metadata` and uses the advertised token + # endpoint instead of guessing `/token`. The resource-metadata + # URL is `None`: no 401 yet, so no `WWW-Authenticate` to read. + try: + prm_urls = build_protected_resource_metadata_discovery_urls( + None, + self.context.server_url, + ) + for url in prm_urls: + # ASYNC119: yielding the request to receive its response is + # this auth generator's handshake protocol, not a value + # escaping a context manager. + response = yield create_oauth_metadata_request(url) # noqa: ASYNC119 + prm = await handle_protected_resource_response(response) + if prm is None: + logger.debug( + "Protected resource metadata discovery failed: %s", + url, + ) + continue + self.context.protected_resource_metadata = prm + self.context.auth_server_url = str(prm.authorization_servers[0]) + break + + asm_urls = build_oauth_authorization_server_metadata_discovery_urls( + self.context.auth_server_url, + self.context.server_url, + ) + for url in asm_urls: + # ASYNC119: yielding the request to receive its response is + # this auth generator's handshake protocol, not a value + # escaping a context manager. + response = yield create_oauth_metadata_request(url) # noqa: ASYNC119 + ok, metadata = await handle_auth_metadata_response(response) + if not ok: + break + if metadata is None: + logger.debug("OAuth metadata discovery failed: %s", url) + continue + self.context.oauth_metadata = metadata + await self._persist_oauth_metadata() + break + except httpx.HTTPError as exc: + # Log only the exception type, never its payload — discovery + # responses travel the same channel as bearer tokens. + logger.debug( + "Pre-emptive OAuth metadata discovery for %s raised %s; " + "deferring to the SDK auth flow.", + self.context.server_url, + type(exc).__name__, + ) + + if ( + not self.context.is_token_valid() + and self.context.can_refresh_token() + and isinstance(self.context.storage, FileTokenStorage) + ): + # Serialize the refresh across processes and provider instances. + # Without this, two holders of the same token file can both + # replay the same refresh token; the LangSmith OAuth server + # rotates refresh tokens and revokes the entire token family on + # reuse, which surfaces as requests hanging until a full + # re-auth. `self.context.lock` only guards this one provider, + # so a file lock is required for the cross-process case. + async with self._refresh_lock_guard( + self.context.storage.refresh_lock_path + ) as refresh_lock_acquired: + # A peer may have rotated the token while we waited for the + # lock; reload so a now-valid token skips the refresh. + await self._reload_tokens_from_storage() + if ( + not self.context.is_token_valid() + and self.context.can_refresh_token() + ): + if refresh_lock_acquired: + # ASYNC119: the refresh lock must stay held across this + # yield — the request/response round-trip is the + # critical section being serialized. Release is safe + # because httpx deterministically drives and + # `aclose()`s this generator (see the delegation note + # below), so the guard's `finally` runs rather than + # deferring cleanup to GC. + refresh_response = yield await self._refresh_token() # noqa: ASYNC119 + await self._handle_locked_refresh_response(refresh_response) + else: + # The delegated SDK flow has its own refresh branch; + # clear only in-memory tokens so this request falls + # through to re-auth instead of replaying the refresh + # token while another process may still be using it. + self.context.clear_tokens() + + # Delegate to the SDK flow by manually pumping the inner generator so + # the HTTP responses httpx feeds back via `auth_flow.asend(response)` + # are forwarded into it. A plain `async for` would advance the inner + # generator with `__anext__()` (i.e. `asend(None)`), discarding every + # response — the SDK's `response = yield request` and refresh-path + # `yield refresh_request` would then see `None` and raise + # `AttributeError: 'NoneType' object has no attribute 'status_code'`, + # surfacing as the `ExceptionGroup` users hit on MCP OAuth login. + # httpx primes the flow with `__anext__()`, then drives it with + # `asend`/`aclose` (never `athrow`), so forwarding sent values and + # closing the inner generator on `GeneratorExit` is sufficient — no + # `athrow` forwarding needed. + token: contextvars.Token[bool] | None = None + if self._suppress_expected_reauth_logs: + token = _SUPPRESS_EXPECTED_REAUTH_LOGS.set(True) + inner = super().async_auth_flow(request) + try: + # Prime with `anext()` (no response to send yet); thereafter every + # resume carries httpx's response back in via `asend`. + flow_request = await anext(inner) + while True: + response = yield flow_request + flow_request = await inner.asend(response) + except StopAsyncIteration: + return + finally: + await inner.aclose() + if token is not None: + _SUPPRESS_EXPECTED_REAUTH_LOGS.reset(token) + + +def build_oauth_provider( + *, + server_name: str, + server_url: str, + storage: TokenStorage, + extra_auth_params: dict[str, str] | None = None, + interactive: bool = True, + ui: OAuthInteraction | None = None, +) -> OAuthClientProvider: + """Construct an `OAuthClientProvider` for an MCP server. + + Args: + server_name: MCP server name used in re-auth messages. + server_url: Remote MCP server URL. + storage: Token storage implementation for this server. + extra_auth_params: Optional query params for the interactive auth URL. + interactive: Whether the provider may prompt on stdin. + ui: Interaction surface used for URL display and paste-back + input in interactive mode. + + Returns: + A configured `OAuthClientProvider`. + """ + from deepagents_code.mcp_providers import resolve_provider + + policy = resolve_provider(server_url) + redirect_uri: str | None = None + + if interactive: + if policy.supports_loopback_callback(): + fixed = policy.loopback_port() + if fixed is not None: + port = fixed + else: + # Reuse the port from a prior DCR registration when available, + # so the authorize request's redirect_uri matches what was + # registered against the persisted client_id. A fresh random + # port on every launch would otherwise invalidate the URI on + # the second run and force the server to reject the request. + stored = ( + storage.stored_loopback_port() + if isinstance(storage, FileTokenStorage) + else None + ) + # No reusable port means any persisted registration can't be + # paired with the random loopback port we're about to bind. Drop + # a stale registration so the handshake re-runs DCR with a + # matching redirect URI instead of failing with "invalid or + # missing redirect_uri". + if ( + stored is None + and isinstance(storage, FileTokenStorage) + and storage.discard_client_info_if_loopback_unusable() + ): + logger.info( + "Discarded a stale MCP client registration for %s " + "whose redirect URI can't serve loopback login; the " + "handshake will register a fresh client.", + server_name, + ) + port = stored if stored is not None else _choose_loopback_port() + callback_server = _LoopbackOAuthCallbackServer(port=port) + redirect_uri = callback_server.redirect_uri + redirect, callback = _make_loopback_handlers( + callback_server=callback_server, + extra_auth_params=extra_auth_params, + ui=ui, + ) + else: + redirect, callback = _make_paste_back_handlers( + extra_auth_params=extra_auth_params, + ui=ui, + ) + else: + redirect, callback = _make_reauth_required_handlers(server_name=server_name) + + metadata = ( + policy.client_metadata(redirect_uri=redirect_uri) + if redirect_uri is not None + else policy.client_metadata() + ) + + return _ExpiryAwareOAuthClientProvider( + server_url=server_url, + client_metadata=metadata, + storage=storage, + redirect_handler=redirect, + callback_handler=callback, + suppress_expected_reauth_logs=not interactive, + ) + + +async def _run_device_flow( + *, + device_code_url: str, + token_url: str, + client_id: str, + scope: str | None = None, + ui: OAuthInteraction | None = None, +) -> OAuthToken: + """Run OAuth 2.0 Device Authorization Grant and return the token. + + Args: + device_code_url: Provider endpoint that issues a device + user code. + token_url: Provider endpoint to poll for the access token. + client_id: Registered OAuth client ID. + scope: Optional space-delimited scope string. + ui: Interaction surface used to display the device code. + + Returns: + The issued OAuth access token payload. + + Raises: + RuntimeError: If the device flow fails, times out, or the provider + returns an unexpected HTTP status on the device-code request. + """ + import asyncio + + import httpx + + interaction = ui if ui is not None else _default_ui() + + init_data = {"client_id": client_id} + if scope is not None: + init_data["scope"] = scope + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + device_code_url, + data=init_data, + headers={"Accept": "application/json"}, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + msg = ( + f"Device code request failed: HTTP {response.status_code} " + f"from {device_code_url}." + ) + raise RuntimeError(msg) from exc + try: + device = _DeviceCodeResponse.model_validate(response.json()) + except (ValueError, ValidationError) as exc: + msg = ( + f"Device code response from {device_code_url} is missing " + f"required fields: {exc}" + ) + raise RuntimeError(msg) from exc + + await interaction.show_device_code( + verification_uri=device.verification_uri, + user_code=device.user_code, + expires_in=device.expires_in, + ) + + interval = max(device.interval, 1) + loop = asyncio.get_running_loop() + deadline = loop.time() + device.expires_in + while loop.time() < deadline: + await asyncio.sleep(interval) + token_response = await client.post( + token_url, + data={ + "client_id": client_id, + "device_code": device.device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + headers={"Accept": "application/json"}, + ) + # RFC 8628 §3.5 lets providers return `authorization_pending` / + # `slow_down` with either a 200 or 400 response. Check the body + # before raise_for_status so 400-returning providers work. + try: + body = token_response.json() + except ValueError as exc: + # Malformed JSON would otherwise cascade into a confusing + # OAuthToken.model_validate({}) error below; log the cause + # explicitly so debugging is possible. + logger.warning( + "Token endpoint %s returned non-JSON body: %s", + token_url, + exc, + ) + body = {} + err = body.get("error") + if err == "authorization_pending": + continue + if err == "slow_down": + interval += 5 + continue + if err: + msg = f"Device flow failed: {err}: {body.get('error_description', '')}" + raise RuntimeError(msg) + try: + token_response.raise_for_status() + except httpx.HTTPStatusError as exc: + msg = ( + f"Token request failed: HTTP {token_response.status_code} " + f"from {token_url}." + ) + raise RuntimeError(msg) from exc + try: + return OAuthToken.model_validate(body) + except ValidationError as exc: + msg = ( + f"Token response from {token_url} is not a valid " + f"OAuth token payload: {exc}" + ) + raise RuntimeError(msg) from exc + + msg = "Device flow timed out. Try logging in again." + raise RuntimeError(msg) + + +def format_login_failure(exc: BaseException) -> str: + """Return a token-safe single-line summary of an OAuth-login exception. + + OAuth handshakes commonly surface as `ExceptionGroup` (anyio task + groups) or as MCP-SDK errors whose `args`/`repr` may include an + `OAuthToken`. Never call `str()`/`repr()` on the raw exception for + display or logging — instead, prefer a known-safe nested + `MCPReauthRequiredError` message, fall back to the messages of our + own loopback-related exception types, and degrade to a class-name + chain for anything else. + + Args: + exc: Root exception caught from the login worker. + + Returns: + A user-displayable string that is safe to log and to render. + """ + reauth = find_reauth_required(exc) + if reauth is not None: + return str(reauth) + + from deepagents_code.mcp_tools import MCPConfigError + + if isinstance(exc, MCPConfigError): + # Config-interpolation errors are our own and are raised before the + # OAuth handshake, so they carry no token material and their + # field-scoped messages are safe (and useful) to render verbatim. + return str(exc) + + safe_types = ( + _LoopbackCallbackTimeoutError, + _LoopbackCallbackUnavailableError, + ) + if isinstance(exc, safe_types): + return f"{type(exc).__name__}: {exc}" + + parts: list[str] = [] + current: BaseException | None = exc + visited: set[int] = set() + while current is not None and id(current) not in visited: + visited.add(id(current)) + parts.append(type(current).__name__) + if isinstance(current, BaseExceptionGroup): + parts.append( + "[" + ", ".join(type(e).__name__ for e in current.exceptions[:5]) + "]" + ) + break + current = current.__cause__ or current.__context__ + return " -> ".join(parts) if parts else type(exc).__name__ + + +def find_reauth_required(exc: BaseException) -> MCPReauthRequiredError | None: + """Find an `MCPReauthRequiredError` anywhere inside `exc`'s tree. + + Walks `exceptions` (for `ExceptionGroup`), then `__cause__` and + `__context__`, tracking visited nodes to terminate on cyclic chains. + + Args: + exc: Root exception to inspect. + + Returns: + The nested `MCPReauthRequiredError`, or `None` if not present. + """ + visited: set[int] = set() + stack: list[BaseException] = [exc] + while stack: + current = stack.pop() + if id(current) in visited: + continue + visited.add(id(current)) + if isinstance(current, MCPReauthRequiredError): + return current + if isinstance(current, BaseExceptionGroup): + stack.extend(current.exceptions) + cause = current.__cause__ or current.__context__ + if cause is not None: + stack.append(cause) + return None + + +_BEARER_SCHEME_RE = re.compile(r"(?:^|,)\s*bearer\b", re.IGNORECASE) +"""Match a `Bearer` auth scheme at the start of a challenge or after a comma. + +A `WWW-Authenticate` line may list several schemes (RFC 7235); anchoring to +the start or a preceding comma finds `Bearer` even when it isn't listed first. +""" + +_RESOURCE_METADATA_RE = re.compile( + r'(?:^|[\s,])resource_metadata\s*=\s*"?([^",\s]+)', + re.IGNORECASE, +) +"""Capture the RFC 9728 `resource_metadata` URL from a Bearer challenge.""" + + +def _oauth_resource_challenge(headers: httpx.Headers) -> str | None: + """Return the RFC 9728 `resource_metadata` URL from a Bearer challenge. + + A single `WWW-Authenticate` header line may carry several comma-separated + challenges (RFC 7235), and a response may repeat the header. Scan every + value for a `Bearer` scheme — anywhere in the line, not only first — that + advertises a `resource_metadata` parameter. + + Args: + headers: Response headers to inspect. + + Returns: + The `resource_metadata` URL when a Bearer challenge carries one, + else `None`. + """ + for value in headers.get_list("www-authenticate"): + if _BEARER_SCHEME_RE.search(value) is None: + continue + match = _RESOURCE_METADATA_RE.search(value) + if match is not None: + return match.group(1) + return None + + +def find_oauth_challenge(exc: BaseException) -> str | None: + """Return the `resource_metadata` URL of a 401 OAuth challenge in `exc`. + + Per the MCP authorization spec (RFC 9728), a server requiring OAuth + answers an unauthenticated request with HTTP 401 plus a Bearer + `WWW-Authenticate` challenge pointing at its protected-resource metadata. + The MCP client surfaces that as an `httpx.HTTPStatusError`. Walks + `exceptions` (for `ExceptionGroup`), then `__cause__`/`__context__`, + tracking visited nodes to terminate on cyclic chains. + + Args: + exc: Root exception to inspect. + + Returns: + The `resource_metadata` URL when a 401 response carrying a Bearer + challenge is found, else `None`. + """ + visited: set[int] = set() + stack: list[BaseException] = [exc] + while stack: + current = stack.pop() + if id(current) in visited: + continue + visited.add(id(current)) + if isinstance(current, httpx.HTTPStatusError): + response = current.response + if ( + response is not None and response.status_code == 401 # noqa: PLR2004 # HTTP Unauthorized + ): + challenge = _oauth_resource_challenge(response.headers) + if challenge is not None: + return challenge + if isinstance(current, BaseExceptionGroup): + stack.extend(current.exceptions) + cause = current.__cause__ or current.__context__ + if cause is not None: + stack.append(cause) + return None + + +async def _drive_handshake(connections: dict) -> None: + """Open a one-shot MCP session for `connections` to trigger OAuth handshake.""" + from langchain_mcp_adapters.client import MultiServerMCPClient + + client = MultiServerMCPClient(connections=connections) + server_name = next(iter(connections)) + async with client.session(server_name): + pass + + +async def login( + *, + server_name: str, + server_config: McpServerSpec, + ui: OAuthInteraction, +) -> None: + """Drive OAuth login for `server_name`, persisting tokens on success. + + Args: + server_name: Name of the configured MCP server. + server_config: Parsed server config for that entry. + ui: Interaction surface for all user prompts and progress messages + during the flow. + + Raises: + ValueError: If `server_config` isn't an http/sse server. + MCPConfigError: If config env-var interpolation fails or a + supported field has the wrong type (a non-string value, or + args/env/headers with the wrong container type). + RuntimeError: If the device flow fails or times out, or the + OAuth handshake aborts. + """ # noqa: DOC502 - `RuntimeError` surfaces via the device flow / handshake + from langchain_mcp_adapters.sessions import ( + SSEConnection, + StreamableHttpConnection, + ) + + from deepagents_code.mcp_tools import MCPConfigError, _resolve_server_type + + # OAuth login is discovery-based (RFC 9728), so it works for any remote + # http/sse server — whether the config opted in with `auth: oauth` or the + # server was auto-detected as needing auth via a 401 challenge. Only the + # transport needs gating; stdio servers can't speak OAuth. + transport = _resolve_server_type(server_config) + if transport not in {"http", "sse"}: + msg = ( + f"Server '{server_name}' uses {transport!r} transport; " + "OAuth login is only valid for http/sse." + ) + raise ValueError(msg) + try: + resolved_config = resolve_mcp_server_env(server_name, server_config) + except (RuntimeError, TypeError) as exc: + # Re-raise as MCPConfigError (a ValueError) so callers' existing + # config-error handling catches it, and `format_login_failure` + # preserves the actionable, field-scoped message instead of + # collapsing it to a bare "RuntimeError"/"TypeError". + raise MCPConfigError(str(exc)) from exc + + from deepagents_code.mcp_providers import resolve_provider + + storage = FileTokenStorage(server_name, server_url=resolved_config["url"]) + policy = resolve_provider(resolved_config["url"]) + result = await policy.run_login( + server_name=server_name, + server_url=resolved_config["url"], + storage=storage, + ui=ui, + ) + + success_message = ( + f"Logged in to MCP server '{server_name}'. Tokens saved to {storage.path}." + ) + + if result.completed: + await ui.show_success(success_message) + return + + provider = build_oauth_provider( + server_name=server_name, + server_url=resolved_config["url"], + storage=storage, + extra_auth_params=result.extra_auth_params or None, + ui=ui, + ) + conn: StreamableHttpConnection | SSEConnection + if transport == "http": + conn = StreamableHttpConnection( + transport="streamable_http", + url=resolved_config["url"], + auth=provider, + ) + else: + conn = SSEConnection( + transport="sse", + url=resolved_config["url"], + auth=provider, + ) + + if "headers" in resolved_config: + conn["headers"] = resolved_config["headers"] + + await _drive_handshake({server_name: conn}) + await ui.show_success(success_message) diff --git a/libs/code/deepagents_code/mcp_config.py b/libs/code/deepagents_code/mcp_config.py new file mode 100644 index 0000000000..7dba5706c3 --- /dev/null +++ b/libs/code/deepagents_code/mcp_config.py @@ -0,0 +1,176 @@ +"""Validation and environment-variable expansion for MCP server config. + +Resolves `${VAR}` and `${VAR:-default}` references in the supported +configuration fields (`command`, `url`, `args`, `env`, `headers`) and +validates their types. A `${VAR:-default}` reference falls back to +`default` when `VAR` is unset *or* empty (POSIX `:-` semantics). +""" + +from __future__ import annotations + +import copy +import os +import re +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping + +_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^{}]*))?\}") +"""Matches a supported reference: `${VAR}` or `${VAR:-default}`. + +Group 1 is the variable name; group 2 (present only for the `:-` form) is +the default. A bare `$VAR` and a literal `$` are intentionally not matched. +""" + +_ENV_BRACE_RE = re.compile(r"\$\{") +"""Matches a `${` brace-open, used to catch malformed `${...}` references.""" + + +def _interpolate_env(value: str, *, field: str) -> str: + """Expand `${VAR}` / `${VAR:-default}` references in one config string. + + A bare `$VAR` (no braces) and a literal `$` pass through untouched; + only the braced forms expand. `${VAR:-default}` uses `default` when + `VAR` is unset or empty. A `${...}` that does not parse as one of the + supported forms (e.g. `${VAR-default}` or an unterminated `${VAR`) is + rejected rather than silently emitted, so a typo cannot inject a + garbage value into a URL, command, or header. + + Args: + value: Raw configuration string. + field: Fully qualified field path for error messages. + + Returns: + The interpolated string. + + Raises: + RuntimeError: If a required environment variable is unset, or the + string contains a malformed `${...}` reference. + """ + + def replace(match: re.Match[str]) -> str: + name = match.group(1) + default = match.group(2) + resolved = os.environ.get(name) + # A non-empty value always wins, for both `${VAR}` and `${VAR:-default}`. + if resolved: + return resolved + # `resolved` is now "" (set but empty) or None (unset). + if default is not None: + # `${VAR:-default}`: `:-` falls back for empty *and* unset (POSIX). + return default + if resolved is not None: + # `${VAR}` set to "": no default, so emit the empty value. + return resolved + # `${VAR}` unset with no default: the only hard error. + msg = ( + f"{field} references unset env var {name}. " + f"Set {name} in the environment or provide a default." + ) + raise RuntimeError(msg) + + # Reject any `${` that isn't the start of a well-formed reference. The + # check is against the raw `value` (not the substituted result) so a + # resolved value that happens to contain `${` never trips it. + ref_spans = [match.span() for match in _ENV_REF_RE.finditer(value)] + for brace in _ENV_BRACE_RE.finditer(value): + if not any(start <= brace.start() < end for start, end in ref_spans): + msg = ( + f"{field} contains a malformed '${{...}}' reference. " + "Use '${VAR}' or '${VAR:-default}'." + ) + raise RuntimeError(msg) + + return _ENV_REF_RE.sub(replace, value) + + +def _resolve_string(value: object, *, field: str) -> str: + """Validate and interpolate one string field. + + Args: + value: Raw field value. + field: Fully qualified field path for error messages. + + Returns: + The validated and interpolated string. + + Raises: + TypeError: If the field value is not a string. + """ + if not isinstance(value, str): + msg = f"{field} must be a string, got {type(value).__name__}" + raise TypeError(msg) + return _interpolate_env(value, field=field) + + +def _resolve_mapping_values( + values: Mapping[str, object], + *, + field: str, +) -> dict[str, str]: + """Validate and interpolate string values in a mapping field. + + Args: + values: Raw mapping values. + field: Fully qualified field path for error messages. + + Returns: + A new mapping with validated and interpolated values. + """ + return { + name: _resolve_string(value, field=f"{field}.{name}") + for name, value in values.items() + } + + +def resolve_mcp_server_env( + server_name: str, + server_config: Mapping[str, object], +) -> dict[str, Any]: + """Resolve `${VAR}` references in one MCP server's supported fields. + + Interpolates the `command`, `url`, `args`, `env`, and `headers` + fields (see `_interpolate_env` for the reference syntax); every other + field is copied through verbatim. The input is not mutated. + + Args: + server_name: Server name used in field-specific error messages. + server_config: Raw server configuration. + + Returns: + A resolved copy of the server configuration. + + Raises: + TypeError: If a supported field has the wrong type — a non-string + scalar value, or `args`/`env`/`headers` with the wrong container + type. + RuntimeError: If a required environment variable is unset. + """ # noqa: DOC502 - `RuntimeError` is raised by `_interpolate_env` + resolved: dict[str, Any] = copy.deepcopy(dict(server_config)) + prefix = f"mcpServers.{server_name}" + + for name in ("command", "url"): + if name in resolved: + resolved[name] = _resolve_string(resolved[name], field=f"{prefix}.{name}") + + if "args" in resolved: + args = resolved["args"] + if not isinstance(args, list): + msg = f"{prefix}.args must be a list, got {type(args).__name__}" + raise TypeError(msg) + resolved["args"] = [ + _resolve_string(value, field=f"{prefix}.args[{index}]") + for index, value in enumerate(args) + ] + + for name in ("env", "headers"): + if name not in resolved: + continue + values = resolved[name] + if not isinstance(values, dict): + msg = f"{prefix}.{name} must be a dictionary, got {type(values).__name__}" + raise TypeError(msg) + resolved[name] = _resolve_mapping_values(values, field=f"{prefix}.{name}") + + return resolved diff --git a/libs/code/deepagents_code/mcp_disabled.py b/libs/code/deepagents_code/mcp_disabled.py new file mode 100644 index 0000000000..3534f00900 --- /dev/null +++ b/libs/code/deepagents_code/mcp_disabled.py @@ -0,0 +1,212 @@ +"""Persistent store of MCP server names the user has disabled. + +Disabled servers are skipped at config merge time so their tools never +reach the agent and no connection is attempted. State lives under +`[mcp].disabled_servers` in `~/.deepagents/config.toml`, alongside the +user's other MCP configuration. + +The store keys on server *name* alone. Two configs that both declare a +`github` server will both be disabled by a single entry — intentional, +since the agent cannot distinguish overlapping names at runtime anyway +(later configs in the merge order win). +""" + +from __future__ import annotations + +import contextlib +import logging +import os +import tempfile +from pathlib import Path +from typing import Any + +from deepagents_code.model_config import DEFAULT_CONFIG_PATH as _DEFAULT_CONFIG_PATH + +logger = logging.getLogger(__name__) + +_SECTION = "mcp" +_KEY = "disabled_servers" +_LEGACY_SECTION = "mcp_disabled" +_LEGACY_KEY = "servers" + + +class _ConfigLoadError(Exception): + """Raised when the config exists but cannot be parsed or read. + + Distinct from "file does not exist" so callers can refuse to + overwrite a config they could not parse — otherwise a transient + read error or a hand-edit typo would silently truncate sibling + sections (e.g. model profiles) on the next write. + """ + + +def _load_config(config_path: Path) -> dict[str, Any]: + """Read the TOML config file. + + Args: + config_path: Path to the TOML config file. + + Returns: + Parsed TOML data, or an empty dict if the file does not exist. + + Raises: + _ConfigLoadError: If the file exists but cannot be read or parsed. + """ + import tomllib + + if not config_path.exists(): + return {} + try: + with config_path.open("rb") as f: + return tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError) as exc: + logger.warning( + "Could not read MCP disabled config at %s: %s", + config_path, + exc, + ) + msg = f"could not load {config_path}: {exc}" + raise _ConfigLoadError(msg) from exc + + +def _save_config(data: dict[str, Any], config_path: Path) -> bool: + """Atomic TOML write. + + Returns: + `True` on success, `False` on I/O failure. + """ + import tomli_w + + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + tomli_w.dump(data, f) + Path(tmp_path).replace(config_path) + except BaseException: + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except (OSError, ValueError): + logger.exception("Failed to save config to %s", config_path) + return False + return True + + +def _coerce_entries(entries: object) -> set[str] | None: + """Return valid server names from a TOML value, or `None` when unset.""" + if not isinstance(entries, list): + return None + return {name for name in entries if isinstance(name, str) and name} + + +def _disabled_entries(data: dict[str, Any]) -> set[str]: + """Return disabled names from the current config shape with legacy fallback.""" + section = data.get(_SECTION) + if isinstance(section, dict): + entries = _coerce_entries(section.get(_KEY)) + if entries is not None: + return entries + + legacy_section = data.get(_LEGACY_SECTION) + if isinstance(legacy_section, dict): + entries = _coerce_entries(legacy_section.get(_LEGACY_KEY)) + if entries is not None: + return entries + + return set() + + +def _remove_legacy_disabled_section(data: dict[str, Any]) -> None: + """Drop the old top-level section after writing the folded config shape.""" + legacy_section = data.get(_LEGACY_SECTION) + if not isinstance(legacy_section, dict): + data.pop(_LEGACY_SECTION, None) + return + legacy_section.pop(_LEGACY_KEY, None) + if legacy_section: + data[_LEGACY_SECTION] = legacy_section + else: + data.pop(_LEGACY_SECTION, None) + + +def get_disabled_servers(*, config_path: Path | None = None) -> set[str]: + """Return the set of server names the user has disabled. + + Args: + config_path: Override the default config location; intended for tests. + + Returns: + Set of server names. Empty when nothing is disabled or the config + cannot be read. + """ + if config_path is None: + config_path = _DEFAULT_CONFIG_PATH + try: + data = _load_config(config_path) + except _ConfigLoadError: + return set() + return _disabled_entries(data) + + +def is_server_disabled(server_name: str, *, config_path: Path | None = None) -> bool: + """Return `True` when `server_name` is in the disabled set. + + Args: + server_name: MCP server name from `mcpServers` config. + config_path: Override the default config location; intended for tests. + + Returns: + `True` when the server is recorded as disabled, `False` otherwise + (including when the config cannot be read). + """ + return server_name in get_disabled_servers(config_path=config_path) + + +def set_server_disabled( + server_name: str, + disabled: bool, + *, + config_path: Path | None = None, +) -> tuple[bool, str | None]: + """Add or remove `server_name` from the persistent disabled set. + + Refuses to write when the existing config cannot be parsed so a + corrupt or permission-denied file is not silently overwritten — + that would discard sibling sections such as model profiles. + + Args: + server_name: MCP server name from `mcpServers` config. + disabled: `True` to disable, `False` to re-enable. + config_path: Override the default config location; intended for tests. + + Returns: + Tuple of `(ok, error_detail)`. `ok` is `True` on success; on + failure `error_detail` is a short user-facing string suitable + for a toast. + """ + if config_path is None: + config_path = _DEFAULT_CONFIG_PATH + try: + data = _load_config(config_path) + except _ConfigLoadError as exc: + return False, str(exc) + current = _disabled_entries(data) + previous = set(current) + if disabled: + current.add(server_name) + else: + current.discard(server_name) + if current == previous and _LEGACY_SECTION not in data: + return True, None + + section = data.get(_SECTION) + if not isinstance(section, dict): + section = {} + section[_KEY] = sorted(current) + data[_SECTION] = section + _remove_legacy_disabled_section(data) + if _save_config(data, config_path): + return True, None + return False, f"could not write {config_path}" diff --git a/libs/code/deepagents_code/mcp_login_service.py b/libs/code/deepagents_code/mcp_login_service.py new file mode 100644 index 0000000000..a103f79af6 --- /dev/null +++ b/libs/code/deepagents_code/mcp_login_service.py @@ -0,0 +1,555 @@ +"""UI-agnostic helpers for resolving an MCP login target. + +The MCP login flow historically inlined config discovery, trust gating, +shape validation, and `print()`-based error reporting. The TUI cannot +consume those print statements, so this module extracts the same logic +into pure functions that return structured results (`ConfigResolution`, +`ServerSelection`) plus a typed `ConfigResolutionError`. Callers decide +how to render those results. + +No `print()` calls live in this module. No imports happen at module +top level beyond `dataclasses`/`typing`/`pathlib` so the CLI fast path +stays cheap; the actual config loaders are imported inside the +functions that need them. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from deepagents_code.mcp_auth import McpServerSpec + + +class ConfigErrorKind(StrEnum): + """Discriminator for `ConfigResolutionError` reasons. + + Only `NO_CONFIG_FOUND` maps to exit code 2 in `run_mcp_login`; all + other kinds map to exit code 1. The TUI surface translates them into + in-app status messages. + """ + + EXPLICIT_LOAD_FAILED = "explicit_load_failed" + """The `--mcp-config` path could not be parsed.""" + + NO_CONFIG_FOUND = "no_config_found" + """Auto-discovery returned zero candidate paths.""" + + NO_USABLE_CONFIG = "no_usable_config" + """Discovered paths existed but none could be loaded successfully.""" + + UNKNOWN_SERVER = "unknown_server" + """The selected server is not present in the resolved config.""" + + INVALID_SERVER_CONFIG = "invalid_server_config" + """The selected server's entry failed shape validation.""" + + +@dataclass(frozen=True) +class ConfigResolutionError: + """Structured error returned when a login target cannot be resolved.""" + + kind: ConfigErrorKind + """Reason category — callers translate this into UI text or exit codes.""" + + message: str + """Plain-text description suitable for direct display to the user.""" + + untrusted_project_paths: tuple[Path, ...] = () + """Project-level configs with server entries skipped by the trust gate. + + Populated when at least one discovered project config had server entries + skipped during auto-discovery (unapproved, disabled, or because the user's + trust policy could not be read), regardless of `kind`. Callers can surface a + "skipping untrusted project servers" hint alongside the primary error. + """ + + legacy_ignored: tuple[str, ...] = () + """Names found in a legacy `[mcp].enabled_project_servers` list, sorted. + + Mirrors `resolve_and_load_mcp_tools`: non-empty means the user relied on the + removed flat allowlist, so those servers silently stopped loading. Callers + should surface the migration hint so this non-interactive path explains the + change instead of the servers just vanishing. + """ + + policy_error: str | None = None + """Set when the user's trust policy (`config.toml`) could not be read. + + When non-`None`, project servers were dropped because the policy failed to + load — not because they were unapproved — so callers should surface this + reason instead of the misleading `untrusted_project_paths` notice. On this + error type `message` already embeds it; callers use the field only to + suppress the misleading untrusted notice. + """ + + legacy_env_ignored: bool = False + """`True` when the removed `DEEPAGENTS_CODE_ENABLED_PROJECT_MCP_SERVERS` env + var is set. Twin of `legacy_ignored` for the env surface; see + `format_legacy_env_ignored_notice`.""" + + malformed_approvals: int = 0 + """Count of persisted `[mcp].enabled_project_server_approvals` rows dropped as + malformed. Non-zero means a saved approval could not be read, so its server + silently stopped pre-approving; surfaced for parity with `legacy_ignored`. + See `format_malformed_approvals_notice`.""" + + +@dataclass(frozen=True) +class ConfigResolution: + """Successful resolution of a merged MCP config for login.""" + + config: dict[str, Any] + """The merged `mcpServers`-shaped config dict.""" + + used_paths: tuple[Path, ...] + """Paths whose contents were merged into `config`, in precedence order.""" + + untrusted_project_paths: tuple[Path, ...] = () + """Project-level configs with server entries skipped by the trust gate.""" + + legacy_ignored: tuple[str, ...] = () + """Names from a legacy `[mcp].enabled_project_servers` list, now ignored. + + See `ConfigResolutionError.legacy_ignored`; surfaced even on success because + the requested server may load while other legacy-listed servers do not. + """ + + policy_error: str | None = None + """Set when the user's trust policy could not be read (fail-closed). + + Non-`None` even on a successful resolution — user-level configs and any + `DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS` env names still load, but scoped + project approvals were discarded. Surfaced so the read failure is never + silently swallowed just because some other config remained usable. See + `ConfigResolutionError.policy_error`. + """ + + legacy_env_ignored: bool = False + """`True` when the removed `DEEPAGENTS_CODE_ENABLED_PROJECT_MCP_SERVERS` env + var is set. See `ConfigResolutionError.legacy_env_ignored`.""" + + malformed_approvals: int = 0 + """Count of persisted approval rows dropped as malformed. Surfaced even on + success because the requested server may load while a corrupt sibling + approval does not. See `ConfigResolutionError.malformed_approvals`.""" + + load_errors: tuple[tuple[Path, str], ...] = () + """Discovered config files that failed to parse or validate, `(path, error)`. + + Surfaced even on success: a broken project `.mcp.json` (or an approved + server that fails per-server validation) can be dropped while another + discovered config still loads. Reporting it here matches + `resolve_and_load_mcp_tools`, which emits the same failures as + `status="error"` rows rather than swallowing them. On `ConfigResolutionError` + the reason is already embedded in `message`, so the field lives only here. + See `format_load_errors_notice`.""" + + def __post_init__(self) -> None: + """Enforce the non-empty `used_paths` invariant. + + Raises: + ValueError: If `used_paths` is empty. + """ + if not self.used_paths: + msg = "ConfigResolution must have at least one used path" + raise ValueError(msg) + + @property + def search_label(self) -> str: + """Human-readable join of the paths backing this resolution.""" + return ", ".join(str(path) for path in self.used_paths) + + +@dataclass(frozen=True) +class ServerSelection: + """Resolved server config plus enough context for error messages.""" + + server_name: str + """Selected MCP server name (matches an `mcpServers` key).""" + + server_config: McpServerSpec + """Validated server config payload for `mcp_auth.login`.""" + + search_label: str = "" + """Where the config came from — used in not-found errors.""" + + def __post_init__(self) -> None: + """Enforce the non-empty `server_name` invariant. + + Raises: + ValueError: If `server_name` is empty. + """ + if not self.server_name: + msg = "ServerSelection.server_name must not be empty" + raise ValueError(msg) + + +def resolve_mcp_config( + config_path: str | None, + *, + trust_project_mcp: bool | None = None, +) -> ConfigResolution | ConfigResolutionError: + """Resolve an MCP config dict for login without printing anything. + + Args: + config_path: Explicit `--mcp-config` path, or `None` for auto-discovery. + trust_project_mcp: Whether project configs have whole-config trust for + the current session. Persisted approvals and denials still apply. + + Returns: + A `ConfigResolution` on success, or a `ConfigResolutionError` + describing why no usable config could be assembled. + """ + from deepagents_code.mcp_tools import ( + _drop_invalid_mcp_config_servers, + _load_mcp_config_top_level_with_error, + _merge_mcp_configs_with_sources, + _resolve_project_config_base, + classify_discovered_configs, + discover_mcp_configs, + filter_trusted_project_servers, + load_mcp_config, + load_mcp_config_with_error, + merge_mcp_configs, + project_root_for_mcp_config_path, + ) + + if config_path is not None: + try: + config = load_mcp_config(config_path) + except (OSError, TypeError, ValueError, RuntimeError) as exc: + return ConfigResolutionError( + kind=ConfigErrorKind.EXPLICIT_LOAD_FAILED, + message=f"Failed to load MCP config {config_path}: {exc}", + ) + return ConfigResolution( + config=config, + used_paths=(Path(config_path),), + ) + + found = discover_mcp_configs() + if not found: + return ConfigResolutionError( + kind=ConfigErrorKind.NO_CONFIG_FOUND, + message=( + "No MCP config file found in any auto-discovered location. " + "Pass --mcp-config , or run `dcode mcp login --help` " + "to see the search paths and config format." + ), + ) + + user_paths, project_paths = classify_discovered_configs(found) + configs: list[dict[str, Any]] = [] + used_paths: list[Path] = [] + untrusted: tuple[Path, ...] = () + # Parse failures of discovered files, surfaced when nothing usable remains + # so the user is told *why* (mirrors resolve_and_load_mcp_tools) instead of + # a bare "no usable config" that hides a JSON syntax error. + load_errors: list[tuple[Path, str]] = [] + policy_error: str | None = None + legacy_ignored: tuple[str, ...] = () + legacy_env_ignored = False + malformed_approvals = 0 + + for path in user_paths: + loaded, error = load_mcp_config_with_error(path) + if loaded is not None: + configs.append(loaded) + used_paths.append(path) + elif error is not None: + load_errors.append((path, error)) + + if project_paths: + from deepagents_code.model_config import load_mcp_server_trust_lists + + trust_lists = load_mcp_server_trust_lists() + legacy_ignored = tuple(sorted(trust_lists.legacy_ignored)) + legacy_env_ignored = trust_lists.legacy_env_ignored + malformed_approvals = trust_lists.malformed_approvals + project_base = _resolve_project_config_base(None) + untrusted_paths: list[Path] = [] + if trust_lists.load_failed: + # Whole-config trust and scoped TOML approvals fail closed. The + # trust-list loader has already discarded scoped approvals while + # retaining names explicitly enabled through the readable env var. + policy_error = trust_lists.read_error + config_trusted = trust_project_mcp is True and not trust_lists.load_failed + loaded_projects: list[tuple[Path, dict[str, Any]]] = [] + for path in project_paths: + loaded, error = _load_mcp_config_top_level_with_error(path) + if loaded is not None: + loaded_projects.append((path, loaded)) + elif error is not None: + load_errors.append((path, error)) + + if loaded_projects: + project_config, server_sources = _merge_mcp_configs_with_sources( + loaded_projects + ) + servers = project_config["mcpServers"] + kept: dict[str, Any] = {} + for name, server in servers.items(): + source = server_sources[name] + project_root = project_root_for_mcp_config_path( + source, fallback=project_base + ) + kept.update( + filter_trusted_project_servers( + {name: server}, + trust_lists, + project_root=project_root, + config_trusted=config_trusted, + ) + ) + + if kept: + filtered = {**project_config, "mcpServers": kept} + valid, errors = _drop_invalid_mcp_config_servers(filtered) + for name, error in errors.items(): + load_errors.append((server_sources[name], error)) + if valid["mcpServers"]: + configs.append(valid) + kept_sources = { + server_sources[name] for name in valid["mcpServers"] + } + used_paths.extend( + path for path in project_paths if path in kept_sources + ) + + dropped_sources = { + server_sources[name] for name in servers if name not in kept + } + untrusted_paths.extend( + path for path in project_paths if path in dropped_sources + ) + untrusted = tuple(untrusted_paths) + + if not configs: + if policy_error is not None: + message = _policy_error_message(policy_error) + elif load_errors: + detail = "; ".join(f"{path}: {error}" for path, error in load_errors) + message = f"No usable MCP config found (load errors: {detail})" + else: + found_paths = ", ".join(str(path) for path in found) + message = f"No usable MCP config found in: {found_paths}" + return ConfigResolutionError( + kind=ConfigErrorKind.NO_USABLE_CONFIG, + message=message, + untrusted_project_paths=untrusted, + legacy_ignored=legacy_ignored, + policy_error=policy_error, + legacy_env_ignored=legacy_env_ignored, + malformed_approvals=malformed_approvals, + ) + + return ConfigResolution( + config=merge_mcp_configs(configs), + used_paths=tuple(used_paths), + untrusted_project_paths=untrusted, + legacy_ignored=legacy_ignored, + policy_error=policy_error, + legacy_env_ignored=legacy_env_ignored, + malformed_approvals=malformed_approvals, + load_errors=tuple(load_errors), + ) + + +def select_server( + resolution: ConfigResolution, + server: str, +) -> ServerSelection | ConfigResolutionError: + """Pull `server` out of a resolved config and validate its shape. + + Args: + resolution: A successful `resolve_mcp_config` result. + server: Target server name as supplied by the user. + + Returns: + A `ServerSelection` on success, or a `ConfigResolutionError` + describing why the server entry is unusable. + """ + from deepagents_code.mcp_tools import _validate_server_config + + servers = resolution.config.get("mcpServers", {}) + if server not in servers: + return ConfigResolutionError( + kind=ConfigErrorKind.UNKNOWN_SERVER, + message=( + f"Server {server!r} not found in {resolution.search_label}. " + f"Known servers: {sorted(servers)}" + ), + ) + + try: + _validate_server_config(server, servers[server]) + except (TypeError, ValueError) as exc: + return ConfigResolutionError( + kind=ConfigErrorKind.INVALID_SERVER_CONFIG, + message=f"Invalid MCP server config for {server!r}: {exc}", + ) + + return ServerSelection( + server_name=server, + server_config=servers[server], + search_label=resolution.search_label, + ) + + +def _policy_error_message(policy_error: str) -> str: + """Return the user-facing message for an unreadable trust policy.""" + return ( + f"Refusing to trust project MCP servers: {policy_error}. Fix " + "~/.deepagents/config.toml, or pass --mcp-config to load " + "a file explicitly." + ) + + +def format_policy_error_notice(policy_error: str | None) -> str: + """Build the CLI-style hint for an unreadable user trust policy. + + Surfaced by `dcode mcp login` so a `config.toml` read failure is never + swallowed just because a user-level config or an env-enabled server still + loaded — and so the reason is not misattributed to an "untrusted project" + when the real fix is repairing `config.toml`. + + Args: + policy_error: The read-failure reason, or `None` when the policy loaded. + + Returns: + A single-line user-facing string. Empty when `policy_error` is `None`. + """ + if policy_error is None: + return "" + return _policy_error_message(policy_error) + + +def format_untrusted_project_notice(paths: tuple[Path, ...]) -> str: + """Build the CLI-style hint string for skipped project server entries. + + Args: + paths: Project configs with entries skipped during resolution. + + Returns: + A single-line user-facing string. Empty when `paths` is empty. + """ + if not paths: + return "" + skipped = ", ".join(str(path) for path in paths) + return ( + "Skipping untrusted project MCP server entries " + f"(not yet approved or disabled): {skipped}. " + "Approve them by running `dcode` in this project, or " + "pass --mcp-config to use the file explicitly." + ) + + +def format_legacy_ignored_notice(names: tuple[str, ...]) -> str: + """Build the CLI-style hint for servers dropped by the legacy-key removal. + + Mirrors the `resolve_and_load_mcp_tools` migration message so + non-interactive `dcode mcp login` explains why a previously allowlisted + server stopped loading instead of leaving it to vanish silently. + + Args: + names: Server names found in a legacy `[mcp].enabled_project_servers` + list, now ignored. + + Returns: + A single-line user-facing string. Empty when `names` is empty. + """ + if not names: + return "" + ignored = ", ".join(names) + return ( + "[mcp].enabled_project_servers is no longer used; re-approve by " + f"running `dcode` in this project to keep loading: {ignored}" + ) + + +def format_legacy_env_ignored_notice(legacy_env_ignored: bool) -> str: + """Build the CLI-style hint for the renamed, now-ignored env var. + + Mirrors `format_legacy_ignored_notice` for the env surface so a user who + still exports `DEEPAGENTS_CODE_ENABLED_PROJECT_MCP_SERVERS` learns it was + renamed instead of its servers silently ceasing to pre-approve. + + Args: + legacy_env_ignored: Whether the removed env var is set. + + Returns: + A single-line user-facing string. Empty when the flag is `False`. + """ + if not legacy_env_ignored: + return "" + return ( + "DEEPAGENTS_CODE_ENABLED_PROJECT_MCP_SERVERS is no longer used; it was " + "renamed to DEEPAGENTS_CODE_DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS" + ) + + +def format_malformed_approvals_notice(count: int) -> str: + """Build the CLI-style hint for dropped malformed saved approvals. + + Mirrors `format_legacy_ignored_notice` so non-interactive `dcode mcp login` + explains why a previously-approved server stopped loading (a corrupt saved + approval) instead of leaving it to vanish silently. + + Args: + count: Number of `[mcp].enabled_project_server_approvals` rows dropped as + malformed. + + Returns: + A single-line user-facing string. Empty when `count` is zero. + """ + if count <= 0: + return "" + entry_word = "entry" if count == 1 else "entries" + return ( + f"{count} [mcp].enabled_project_server_approvals {entry_word} could not " + "be read and were ignored; re-approve by running `dcode` in this project " + "to keep loading affected servers" + ) + + +def format_load_errors_notice(load_errors: tuple[tuple[Path, str], ...]) -> str: + """Build the CLI-style hint for discovered configs that failed to load. + + Surfaced by `dcode mcp login` on a partially successful resolution so a + broken `.mcp.json` is reported instead of silently dropped while another + config still loads — matching the runtime loader + (`resolve_and_load_mcp_tools`), which emits the same failures as error rows. + + Args: + load_errors: `(path, error)` pairs for configs that failed to parse or + validate. + + Returns: + A user-facing string, one line per failure. Empty when `load_errors` is + empty. + """ + if not load_errors: + return "" + return "\n".join( + f"Ignoring MCP config {path}: {error}" for path, error in load_errors + ) + + +__all__ = [ + "ConfigErrorKind", + "ConfigResolution", + "ConfigResolutionError", + "ServerSelection", + "format_legacy_env_ignored_notice", + "format_legacy_ignored_notice", + "format_load_errors_notice", + "format_malformed_approvals_notice", + "format_policy_error_notice", + "format_untrusted_project_notice", + "resolve_mcp_config", + "select_server", +] diff --git a/libs/code/deepagents_code/mcp_oauth_ui.py b/libs/code/deepagents_code/mcp_oauth_ui.py new file mode 100644 index 0000000000..55590e3cc8 --- /dev/null +++ b/libs/code/deepagents_code/mcp_oauth_ui.py @@ -0,0 +1,199 @@ +"""UI-agnostic interaction interface for MCP OAuth login. + +The OAuth login flow needs to ask the user a few things during the +handshake — open or display the authorize URL, accept a pasted callback +URL when the provider has no loopback redirect, show RFC 8628 device-code +instructions, and report success or failure. The CLI uses `print` and +`input`; a TUI surface needs in-app widgets instead. `OAuthInteraction` is +the small Protocol both implementations satisfy, and `CliOAuthInteraction` +is the existing CLI behavior preserved as one implementation of that +interface. + +Important: implementations must never embed access or refresh tokens +in user-facing messages. The interaction surface only ever sees +authorize URLs, callback URLs, device codes, and short status strings, +so leaks come from misuse, not from this interface's shape. +""" + +from __future__ import annotations + +from typing import Protocol + + +class OAuthInteraction(Protocol): + """User-facing OAuth interaction surface shared by CLI and TUI.""" + + async def show_authorize_url(self, url: str, *, opened_in_browser: bool) -> None: + """Tell the user about the authorize URL. + + Args: + url: Final authorize URL with provider-specific extras applied. + opened_in_browser: `True` when the caller already launched the + URL via `webbrowser.open`; `False` when the caller needs the + user to open it manually. + """ + ... + + async def request_callback_url(self) -> str: + """Wait for the user to paste back the full provider callback URL. + + Returns: + The raw pasted URL (the caller parses `code`/`state`/`error`). + + Raises: + RuntimeError: When the user interaction cannot complete (for + example, the input surface is unavailable or was dismissed). + """ + ... + + async def show_device_code( + self, + *, + verification_uri: str, + user_code: str, + expires_in: int, + ) -> None: + """Show RFC 8628 device-code instructions to the user. + + Args: + verification_uri: Provider URL the user visits in a browser. + user_code: Short code the user enters on `verification_uri`. + expires_in: Lifetime of the device code in seconds. + """ + ... + + async def show_success(self, message: str) -> None: + """Report a successful login step. + + Implementations must not embed token material in `message`; the + login code only passes structural facts ("logged in", token file + path) here. + + Args: + message: Plain-text status line. + """ + ... + + async def show_notice(self, message: str) -> None: + """Report a non-fatal progress notice (e.g. fallback path taken). + + Args: + message: Plain-text notice. + """ + ... + + async def show_error(self, message: str) -> None: + """Report a fatal (flow-ending) error. + + "Fatal" rather than "terminal" because this is a TUI codebase + where "terminal" reads ambiguously. + + Args: + message: Plain-text error description. + """ + ... + + +class CliOAuthInteraction: + """Default `OAuthInteraction` that drives the flow via stdin/stdout. + + Preserves the previous `dcode mcp login` behavior — paste-back input, + plain-text prompts, success messages printed to stdout. + """ + + async def show_authorize_url( # noqa: PLR6301 + self, + url: str, + *, + opened_in_browser: bool, + ) -> None: + """Print the full authorize instruction block to stdout. + + Uses browser-opened wording when `opened_in_browser` is `True`; + otherwise instructs the user to open the URL and paste back the callback. + """ + if opened_in_browser: + print( # noqa: T201 + "\nOpened your browser to approve MCP access. " + "If it did not open, visit this URL:\n" + f"\n {url}\n", + ) + else: + print( # noqa: T201 + "\nOpen this URL in a browser, approve access, then paste the full " + "callback URL back here:\n" + f"\n {url}\n", + ) + + async def request_callback_url(self) -> str: # noqa: PLR6301 + """Read a trimmed callback URL from stdin via a worker thread. + + Returns: + The trimmed callback URL string. + + Raises: + RuntimeError: If stdin is closed before the user replies. + """ + import asyncio + + try: + raw = await asyncio.to_thread(input, "Callback URL: ") + except EOFError as exc: + msg = ( + "No callback URL received (stdin closed). " + "Re-run `dcode mcp login ` and paste the URL." + ) + raise RuntimeError(msg) from exc + return raw.strip() + + async def show_device_code( # noqa: PLR6301 + self, + *, + verification_uri: str, + user_code: str, + expires_in: int, + ) -> None: + """Print RFC 8628 device-code instructions to stdout.""" + print( # noqa: T201 + f"\nVisit {verification_uri} and enter code: " + f"{user_code}\n(code expires in {expires_in}s)\n", + ) + + async def prompt_slack_team_id(self) -> str | None: # noqa: PLR6301 + """Ask for a Slack team ID via `input()` on a worker thread. + + Returns: + The entered Slack team ID, or `None` if the prompt was blank or + stdin was closed. + """ + import asyncio + + try: + raw = await asyncio.to_thread( + input, + "Slack team ID to install the app into " + "(e.g. T01234567 — leave blank to pick on Slack's page): ", + ) + except EOFError: + return None + return raw.strip() or None + + async def show_success(self, message: str) -> None: # noqa: PLR6301 + """Print success message to stdout.""" + print(message) # noqa: T201 + + async def show_notice(self, message: str) -> None: # noqa: PLR6301 + """Print progress notice to stdout.""" + print(message) # noqa: T201 + + async def show_error(self, message: str) -> None: # noqa: PLR6301 + """Print error message to stderr.""" + import sys + + print(message, file=sys.stderr) # noqa: T201 + + +__all__ = [ + "CliOAuthInteraction", + "OAuthInteraction", +] diff --git a/libs/code/deepagents_code/mcp_providers/__init__.py b/libs/code/deepagents_code/mcp_providers/__init__.py new file mode 100644 index 0000000000..4817c6698b --- /dev/null +++ b/libs/code/deepagents_code/mcp_providers/__init__.py @@ -0,0 +1,23 @@ +"""Provider-specific MCP OAuth dispatch. + +`resolve_provider(url)` returns the registered policy whose `matches` +predicate fires for `url`, with `GenericProvider` as the fallback. +""" + +from deepagents_code.mcp_providers._registry import resolve_provider +from deepagents_code.mcp_providers.base import ( + GenericProvider, + LoginResult, + OAuthProvider, +) +from deepagents_code.mcp_providers.github import GitHubProvider +from deepagents_code.mcp_providers.slack import SlackProvider + +__all__ = [ + "GenericProvider", + "GitHubProvider", + "LoginResult", + "OAuthProvider", + "SlackProvider", + "resolve_provider", +] diff --git a/libs/code/deepagents_code/mcp_providers/_registry.py b/libs/code/deepagents_code/mcp_providers/_registry.py new file mode 100644 index 0000000000..55f0e3ab30 --- /dev/null +++ b/libs/code/deepagents_code/mcp_providers/_registry.py @@ -0,0 +1,39 @@ +"""Ordered provider registry for MCP OAuth dispatch. + +`resolve_provider` walks `_REGISTRY` in order and returns the first +provider whose `matches(url)` is `True`. `GenericProvider` sits last so +spec-compliant servers always resolve to a usable policy. +""" + +from __future__ import annotations + +from deepagents_code.mcp_providers.base import GenericProvider, OAuthProvider +from deepagents_code.mcp_providers.github import GitHubProvider +from deepagents_code.mcp_providers.slack import SlackProvider + +_REGISTRY: tuple[OAuthProvider, ...] = ( + SlackProvider(), + GitHubProvider(), + GenericProvider(), +) +"""Ordered provider list; `GenericProvider` must stay last as the fallback.""" + + +def resolve_provider(server_url: str) -> OAuthProvider: + """Return the provider policy that owns `server_url`. + + Args: + server_url: Remote MCP endpoint URL. + + Returns: + The first matching `OAuthProvider`; falls back to `GenericProvider`. + + Raises: + RuntimeError: If no provider matches (unreachable in practice since + `GenericProvider.matches` always returns `True`). + """ + for provider in _REGISTRY: + if provider.matches(server_url): + return provider + msg = f"No MCP OAuth provider matched {server_url!r}" + raise RuntimeError(msg) diff --git a/libs/code/deepagents_code/mcp_providers/base.py b/libs/code/deepagents_code/mcp_providers/base.py new file mode 100644 index 0000000000..212f72407f --- /dev/null +++ b/libs/code/deepagents_code/mcp_providers/base.py @@ -0,0 +1,133 @@ +"""Policy interface for provider-specific MCP OAuth quirks. + +Each concrete provider module (e.g. `slack`, `github`) subclasses +`OAuthProvider` to encode its own URL match rule, client metadata, and +any pre-handshake login steps (preseeding client info, running a +device flow, prompting for workspace IDs). `mcp_auth` dispatches to +the first matching provider via `resolve_provider`, so adding a new +provider is one new module plus one registry entry — no edits to +`build_oauth_provider` or `login`. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from mcp.shared.auth import AnyUrl, OAuthClientMetadata + +if TYPE_CHECKING: + from deepagents_code.mcp_auth import FileTokenStorage + from deepagents_code.mcp_oauth_ui import OAuthInteraction + + +@dataclass(frozen=True) +class LoginResult: + """Outcome of a provider's pre-handshake `run_login` step.""" + + completed: bool = False + """`True` means tokens are persisted and `login` should skip the handshake.""" + + extra_auth_params: dict[str, str] = field(default_factory=dict) + """Extra query params to thread into the interactive auth URL.""" + + +class OAuthProvider(ABC): + """Base class for provider-specific OAuth dispatch. + + Subclasses override `matches` plus whichever of `client_metadata` + and `run_login` they customize. The default implementations cover + the spec-compliant Authorization Code + PKCE + Dynamic Client + Registration path. + """ + + @abstractmethod + def matches(self, server_url: str) -> bool: + """Return `True` when this provider owns `server_url`.""" + + def supports_loopback_callback(self) -> bool: # noqa: PLR6301 # subclass hook + """Return whether this provider can use a runtime loopback redirect URI. + + When `False`, `client_metadata()` ignores the `redirect_uri` argument + and uses the provider's own pre-registered static URI instead. + + Returns: + `True` when the provider accepts dynamically registered redirect URIs. + """ + return True + + def loopback_port(self) -> int | None: # noqa: PLR6301 # subclass hook + """Return a fixed loopback port, or `None` for a random ephemeral port. + + Override when the provider's app registration requires a specific + pre-registered port (e.g. Slack registers `http://localhost:3118/callback`). + Ignored when `supports_loopback_callback()` is `False`. + + Returns: + A fixed TCP port, or `None` to pick a random ephemeral port. + """ + return None + + def client_metadata( # noqa: PLR6301 # subclass hook + self, + *, + redirect_uri: str | None = None, + ) -> OAuthClientMetadata: + """Return the `OAuthClientMetadata` used to build the auth provider. + + Args: + redirect_uri: Optional runtime redirect URI for CLI loopback auth. + + Returns: + Metadata for the spec-compliant Authorization Code + PKCE + + Dynamic Client Registration flow. + """ + return OAuthClientMetadata( + client_name="deepagents-code", + redirect_uris=[AnyUrl(redirect_uri or "http://localhost/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + ) + + async def run_login( # noqa: PLR6301 # subclass hook + self, + *, + server_name: str, + server_url: str, + storage: FileTokenStorage, + ui: OAuthInteraction, + ) -> LoginResult: + """Perform any provider-specific pre-handshake work. + + Args: + server_name: MCP server name from `mcpServers`. + server_url: Remote MCP endpoint URL. + storage: File-backed token storage for this server identity. + ui: Interaction surface used for any provider-specific + prompts (e.g. device-code instructions, workspace IDs). + + Returns: + `LoginResult.completed=True` if the provider finished the + login itself (e.g. device flow). Otherwise the caller drives + the standard Authorization Code handshake and passes any + returned `extra_auth_params` to the redirect URL. + """ + del server_name, server_url, storage, ui + return LoginResult() + + +class GenericProvider(OAuthProvider): + """Fallback provider for spec-compliant MCP servers with no quirks.""" + + def matches(self, server_url: str) -> bool: # noqa: PLR6301 # subclass hook + """Match any URL — the registry places this provider last. + + Args: + server_url: Remote MCP endpoint URL (unused). + + Returns: + Always `True`. + """ + del server_url + return True diff --git a/libs/code/deepagents_code/mcp_providers/github.py b/libs/code/deepagents_code/mcp_providers/github.py new file mode 100644 index 0000000000..e949605ce3 --- /dev/null +++ b/libs/code/deepagents_code/mcp_providers/github.py @@ -0,0 +1,102 @@ +"""GitHub-hosted MCP OAuth provider. + +GitHub's remote MCP at `api.githubcopilot.com` authenticates via RFC +8628 Device Authorization Grant — the app runs the device flow, +persists the resulting token plus a stub client-info record, and skips +the standard Authorization Code handshake entirely. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +from mcp.shared.auth import AnyUrl, OAuthClientInformationFull + +from deepagents_code.mcp_auth import _run_device_flow +from deepagents_code.mcp_providers.base import LoginResult, OAuthProvider + +if TYPE_CHECKING: + from deepagents_code.mcp_auth import FileTokenStorage + from deepagents_code.mcp_oauth_ui import OAuthInteraction + + +_GITHUB_MCP_CLIENT_ID = "Iv23libxz8qOApH0WQL3" +"""Public OAuth client ID for the GitHub App backing GitHub's remote MCP.""" + +_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code" +"""GitHub Device Authorization Grant endpoint that issues the user/device code pair.""" + +_GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token" # noqa: S105 +"""GitHub OAuth token endpoint polled while the user completes the device flow.""" + + +def _is_github_mcp_url(url: str) -> bool: + """Return `True` when `url` points at GitHub's remote MCP endpoint.""" + return (urlparse(url).hostname or "") == "api.githubcopilot.com" + + +async def _preseed_github_auth( + storage: FileTokenStorage, *, ui: OAuthInteraction +) -> None: + """Run GitHub Device Flow and persist the token and stub client info. + + Args: + storage: File-backed token storage for this server identity. + ui: Interaction surface that renders the device-code prompt. + """ + token = await _run_device_flow( + device_code_url=_GITHUB_DEVICE_CODE_URL, + token_url=_GITHUB_TOKEN_URL, + client_id=_GITHUB_MCP_CLIENT_ID, + ui=ui, + ) + await storage.set_tokens_and_client_info( + token, + OAuthClientInformationFull( + client_id=_GITHUB_MCP_CLIENT_ID, + redirect_uris=[AnyUrl("http://localhost/callback")], + grant_types=["urn:ietf:params:oauth:grant-type:device_code"], + response_types=["code"], + token_endpoint_auth_method="none", # noqa: S106 + ), + ) + + +class GitHubProvider(OAuthProvider): + """GitHub-hosted MCP: RFC 8628 Device Authorization Grant.""" + + def matches(self, server_url: str) -> bool: # noqa: PLR6301 # subclass hook + """Match `api.githubcopilot.com`. + + Args: + server_url: Remote MCP endpoint URL. + + Returns: + `True` when `server_url`'s host is GitHub's MCP endpoint. + """ + return _is_github_mcp_url(server_url) + + async def run_login( # noqa: PLR6301 # subclass hook + self, + *, + server_name: str, + server_url: str, + storage: FileTokenStorage, + ui: OAuthInteraction, + ) -> LoginResult: + """Run the device flow and short-circuit the Authorization Code handshake. + + Args: + server_name: MCP server name (unused). + server_url: Remote MCP endpoint URL (unused). + storage: File-backed token storage for this server identity. + ui: Interaction surface that renders the device-code prompt. + + Returns: + `LoginResult(completed=True)` — tokens are already persisted so + the caller must skip the handshake step. + """ + del server_name, server_url + await _preseed_github_auth(storage, ui=ui) + return LoginResult(completed=True) diff --git a/libs/code/deepagents_code/mcp_providers/slack.py b/libs/code/deepagents_code/mcp_providers/slack.py new file mode 100644 index 0000000000..1d94b452a5 --- /dev/null +++ b/libs/code/deepagents_code/mcp_providers/slack.py @@ -0,0 +1,175 @@ +"""Slack-hosted MCP OAuth provider. + +Slack's hosted MCP endpoint uses the Authorization Code flow with a +hardcoded public client ID and a fixed pre-registered loopback redirect +URI (`http://localhost:3118/callback`). The local callback server listens +on that port so the browser redirect completes automatically. An optional +`team` query parameter selects the workspace to install the app into. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, runtime_checkable +from urllib.parse import urlparse + +from mcp.shared.auth import ( + AnyUrl, + OAuthClientInformationFull, + OAuthClientMetadata, +) + +from deepagents_code.mcp_providers.base import LoginResult, OAuthProvider + +if TYPE_CHECKING: + from deepagents_code.mcp_auth import FileTokenStorage + from deepagents_code.mcp_oauth_ui import OAuthInteraction + + +@runtime_checkable +class _SupportsSlackTeamPrompt(Protocol): + """Optional interaction-surface capability: prompt for a Slack team ID. + + `OAuthInteraction` deliberately omits this method — only the CLI + surface implements it. The TUI lets Slack's browser page handle + workspace selection. Marked `runtime_checkable` so `isinstance` + can replace `getattr`-style structural probes at the call site. + """ + + async def prompt_slack_team_id(self) -> str | None: ... + + +# Public OAuth client ID — safe to check in. No secret is associated; +# Slack treats this as a browser-style public client where the security +# boundary is the redirect URI rather than client secrecy. +_SLACK_MCP_CLIENT_ID = "4518649543379.10944517634130" +"""Public OAuth client ID registered with Slack for the hosted MCP endpoint.""" + +_SLACK_LOOPBACK_PORT = 3118 +"""Fixed TCP port the local callback server binds to for Slack OAuth. + +Slack validates the redirect URI against the app's registered allowlist, so +the port must be pre-registered in the Slack app dashboard. Only one Slack +login can proceed at a time per machine (port conflicts are surfaced as an +OSError from the loopback server's `start()` call). +""" + +_SLACK_REDIRECT_URI = f"http://localhost:{_SLACK_LOOPBACK_PORT}/callback" +"""Pre-registered loopback redirect URI for the Slack MCP OAuth app.""" + + +def _is_slack_mcp_url(url: str) -> bool: + """Return `True` when `url` points at a Slack-hosted MCP endpoint.""" + host = urlparse(url).hostname or "" + return host == "slack.com" or host.endswith(".slack.com") + + +async def _prompt_slack_team(ui: OAuthInteraction) -> str | None: + """Return a Slack team ID when the interaction surface supports prompting. + + `prompt_slack_team_id` is **not** a member of the `OAuthInteraction` + Protocol — only the CLI surface implements it. The TUI omits it so + Slack's browser page handles workspace selection instead. Detection + uses `isinstance` against the `runtime_checkable` + `_SupportsSlackTeamPrompt` capability protocol. + + Args: + ui: Interaction surface to use. + + Returns: + The entered Slack team ID, or `None` when the surface lacks the + optional prompt method or the user declined to specify one. + """ + if not isinstance(ui, _SupportsSlackTeamPrompt): + return None + return await ui.prompt_slack_team_id() + + +async def _preseed_slack_client_info(storage: FileTokenStorage) -> None: + """Write the hardcoded Slack `client_info` to `storage` if not current.""" + existing = await storage.get_client_info() + redirect_uris = existing.redirect_uris if existing is not None else None + current_redirect = str(redirect_uris[0]) if redirect_uris else None + if ( + existing is not None + and existing.client_id == _SLACK_MCP_CLIENT_ID + and current_redirect == _SLACK_REDIRECT_URI + ): + return + await storage.set_client_info( + OAuthClientInformationFull( + client_id=_SLACK_MCP_CLIENT_ID, + redirect_uris=[AnyUrl(_SLACK_REDIRECT_URI)], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method="none", # noqa: S106 + ) + ) + + +class SlackProvider(OAuthProvider): + """Slack-hosted MCP: loopback Authorization Code with a public client.""" + + def matches(self, server_url: str) -> bool: # noqa: PLR6301 # subclass hook + """Match `slack.com` and any `*.slack.com` subdomain. + + Args: + server_url: Remote MCP endpoint URL. + + Returns: + `True` when `server_url`'s host is Slack. + """ + return _is_slack_mcp_url(server_url) + + def loopback_port(self) -> int: # noqa: PLR6301 # subclass hook + """Return the fixed loopback port registered in the Slack OAuth app. + + Returns: + `_SLACK_LOOPBACK_PORT` (3118). + """ + return _SLACK_LOOPBACK_PORT + + def client_metadata( # noqa: PLR6301 # subclass hook + self, *, redirect_uri: str | None = None + ) -> OAuthClientMetadata: + """Return public-client metadata with Slack's pre-registered loopback URI. + + Args: + redirect_uri: Ignored; Slack requires its pre-registered loopback URI. + + Returns: + Metadata configured for Slack's public OAuth client (no token secret). + """ + del redirect_uri + return OAuthClientMetadata( + client_name="deepagents-code", + redirect_uris=[AnyUrl(_SLACK_REDIRECT_URI)], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method="none", # noqa: S106 + ) + + async def run_login( # noqa: PLR6301 # subclass hook + self, + *, + server_name: str, + server_url: str, + storage: FileTokenStorage, + ui: OAuthInteraction, + ) -> LoginResult: + """Preseed client info and optionally thread the team ID into auth URL. + + Args: + server_name: MCP server name (unused). + server_url: Remote MCP endpoint URL (unused). + storage: File-backed token storage for this server identity. + ui: Interaction surface used to prompt for the Slack team ID. + + Returns: + A `LoginResult` carrying the optional `team=` extra param + so the Slack authorize URL installs into the chosen workspace. + """ + del server_name, server_url + await _preseed_slack_client_info(storage) + team_id = await _prompt_slack_team(ui) + extras = {"team": team_id} if team_id else {} + return LoginResult(extra_auth_params=extras) diff --git a/libs/code/deepagents_code/mcp_tools.py b/libs/code/deepagents_code/mcp_tools.py new file mode 100644 index 0000000000..016b42b2c1 --- /dev/null +++ b/libs/code/deepagents_code/mcp_tools.py @@ -0,0 +1,2798 @@ +"""MCP (Model Context Protocol) tools loader. + +This module provides async functions to load and manage MCP servers using +`langchain-mcp-adapters`, supporting Claude Desktop style JSON configs. +It also supports automatic discovery of `.mcp.json` files from user-level +and project-level locations. +""" + +from __future__ import annotations + +import asyncio +import copy +import fnmatch +import functools +import json +import logging +import re +import shutil +from contextlib import AsyncExitStack +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar, cast, overload + +from deepagents_code import _env_vars +from deepagents_code.mcp_config import resolve_mcp_server_env + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Collection, Mapping, Sequence + + from langchain_core.tools import BaseTool + from langchain_mcp_adapters.client import Connection + from mcp import ClientSession + + from deepagents_code.model_config import McpServerTrustLists + from deepagents_code.project_utils import ProjectContext + +logger = logging.getLogger(__name__) + +_T = TypeVar("_T") + +# Maintainer note: `deepagents-talon` imports `MCPConfigError`, +# `MCPServerInfo`, and `get_mcp_tools` from this module, and its tests construct +# `MCPToolInfo`. Keep those symbols' names, signatures, and return/dataclass +# shapes stable unless `deepagents-talon` is migrated in the same change. + + +@dataclass(frozen=True, slots=True) +class MCPToolInfo: + """Metadata for a single MCP tool.""" + + name: str + """Tool name (may include server name prefix).""" + + description: str + """Human-readable description of what the tool does.""" + + input_schema: dict[str, Any] | None = None + """Raw MCP `inputSchema` dict (JSON Schema), or `None` when unavailable. + + Supplied directly from `mcp_tool.inputSchema` at tool-load time. The viewer + reads `properties` and `required` from this dict for parameter display; + `None` is rendered as "no parameters". + """ + + +MCPServerStatus = Literal[ + "ok", + "unauthenticated", + "awaiting_reconnect", + "error", + "disabled", +] +"""Load states a configured MCP server can end up in. + +`ok` means the server loaded successfully and has an authoritative tool list. + +`unauthenticated` means the server requires OAuth login before tools can load. + +`error` means the server failed to load after a connection or configuration +failure. + +`disabled` is set when the user has turned the server off via the TUI +(`/mcp` -> F2). No connection is attempted and no tools are loaded, but +the entry is still surfaced in the viewer so the user can re-enable it. + +`awaiting_reconnect` is a transient UI-only state used after OAuth login +has succeeded but before the LangGraph server has restarted and loaded +the newly available MCP tools. +""" + + +@dataclass(frozen=True, slots=True) +class MCPServerInfo: + """Metadata for a configured MCP server and its tools.""" + + name: str + """Server name from the MCP configuration.""" + + transport: str + """Transport identifier — `stdio`, `sse`, `http`, the synthetic + `config` value used for entries surfacing a bad config file, or + `unknown` for a disabled server whose original config could not be + classified.""" + + tools: tuple[MCPToolInfo, ...] = () + """Tools exposed by this server (empty when `status != "ok"`).""" + + status: MCPServerStatus = "ok" + """Load status. + + One of `ok`, `unauthenticated`, `awaiting_reconnect`, `error`, or + `disabled`. + """ + + error: str | None = None + """Human-readable reason when `status != "ok"`.""" + + pending_reconnect: bool = False + """`True` for a disabled entry that was just re-enabled in the TUI and is + awaiting a reconnect to load its tools. + + Lets `/tools` (`tool_catalog.split_mcp_server_info`) preserve the reconnect + guidance held in `error` instead of collapsing it to the generic "disabled + by user" label — an explicit flag rather than a fragile match on the + guidance text. Only meaningful while `status == "disabled"`. + """ + + def __post_init__(self) -> None: + """Enforce the status/error/tools consistency invariant. + + Raises: + ValueError: If any of: `status='ok'` with a non-`None` error; + non-`ok` status without an error message; non-`ok` status + carrying tools; or `pending_reconnect` set without + `status='disabled'`. + """ + if self.status == "ok": + if self.error is not None: + msg = ( + f"MCPServerInfo {self.name!r}: status='ok' cannot carry " + f"an error (got {self.error!r})" + ) + raise ValueError(msg) + else: + if self.error is None: + msg = ( + f"MCPServerInfo {self.name!r}: status={self.status!r} " + "requires an error message" + ) + raise ValueError(msg) + if self.tools: + msg = ( + f"MCPServerInfo {self.name!r}: status={self.status!r} " + "cannot carry tools" + ) + raise ValueError(msg) + if self.pending_reconnect and self.status != "disabled": + msg = ( + f"MCPServerInfo {self.name!r}: pending_reconnect requires " + f"status='disabled' (got {self.status!r})" + ) + raise ValueError(msg) + + def needs_attention(self) -> bool: + """Return whether this server is blocked on user login.""" + return self.status == "unauthenticated" + + +_SUPPORTED_REMOTE_TYPES = {"sse", "http"} +"""Supported transport types for remote MCP servers (SSE and HTTP).""" + +_TRANSPORT_ALIASES = {"streamable_http": "http", "streamable-http": "http"} +"""Aliases that normalize to canonical transport names. + +The MCP spec and `langchain_mcp_adapters` use `streamable_http` for what the +app calls `http`. Accept both so users copy-pasting from upstream docs don't +hit a validation error. +""" + + +_SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$") +"""Server names become token-file basenames and must remain path-safe.""" + + +class MCPConfigError(ValueError): + """An MCP configuration file is malformed or structurally invalid. + + Subclasses `ValueError` so existing `except ValueError` handlers + keep working; new code can catch this specifically to render a + user-actionable message (typically with a file path and hint). + """ + + +def _is_transient_session_error(exc: BaseException) -> bool: + """Return `True` when `exc` signals the MCP session transport is dead. + + The anyio import is guarded so an anyio rename or removal surfaces as + an `ImportError` at module import rather than silent mis-classification + at runtime. Standard-library socket/pipe/EOF errors are covered as a + fallback regardless of anyio's presence. + """ + try: + import anyio + except ImportError: # pragma: no cover - anyio is a transitive MCP dep + anyio_excs: tuple[type[BaseException], ...] = () + else: + anyio_excs = ( + anyio.ClosedResourceError, + anyio.BrokenResourceError, + anyio.EndOfStream, + ) + return isinstance( + exc, + ( + *anyio_excs, + BrokenPipeError, + ConnectionAbortedError, + ConnectionResetError, + EOFError, + asyncio.IncompleteReadError, + ), + ) + + +@dataclass(frozen=True, slots=True) +class _MCPSessionEntry: + """Cached MCP session and its close stack.""" + + session: ClientSession + exit_stack: AsyncExitStack + + +def _connection_signature(value: Any) -> Any: # noqa: ANN401 + """Return a stable comparison signature for MCP connection configs.""" + from mcp.client.auth import OAuthClientProvider + + if isinstance(value, dict): + return tuple( + sorted((key, _connection_signature(item)) for key, item in value.items()), + ) + if isinstance(value, list | tuple): + return tuple(_connection_signature(item) for item in value) + if isinstance(value, Path): + return str(value) + if isinstance(value, OAuthClientProvider): + context = value.context + storage_path = getattr(getattr(context, "storage", None), "path", None) + return ( + "oauth", + _connection_signature(context.server_url), + _connection_signature( + context.client_metadata.model_dump(mode="json", exclude_none=True), + ), + _connection_signature(storage_path), + _connection_signature(context.timeout), + _connection_signature(context.client_metadata_url), + _connection_signature(context.auth_server_url), + _connection_signature(context.protocol_version), + ) + + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + return _connection_signature(model_dump(mode="json", exclude_none=True)) + return value + + +def _connections_signature( + connections: dict[str, Connection], +) -> tuple[tuple[str, Any], ...]: + """Return a stable signature for a full MCP connections mapping.""" + return tuple( + sorted( + (name, _connection_signature(connection)) + for name, connection in connections.items() + ), + ) + + +class MCPSessionManager: + """Lazy, per-server cache of persistent MCP sessions. + + Discovery always happens through throwaway sessions. Live sessions are + only created on the first real tool call inside the runtime event loop + so sessions stay bound to the loop that owns their subprocess/transport + handles, and so stdio servers are not restarted on every invocation. + """ + + def __init__(self, *, connections: dict[str, Connection] | None = None) -> None: + """Initialize the session manager. + + Args: + connections: Optional initial server connection configs. + """ + self._connections: dict[str, Connection] = dict(connections or {}) + self._entries: dict[str, _MCPSessionEntry] = {} + self._locks: dict[str, asyncio.Lock] = {} + self._closed = False + + def configure(self, connections: dict[str, Connection]) -> None: + """Set or validate the connection configs used by this manager. + + When no sessions exist yet, `connections` overwrites the stored + configs unconditionally. Once any session has been created, the + new `connections` must produce the same signature as the stored + ones — otherwise this raises to prevent rebinding live sessions + to different transports or auth providers. + + Args: + connections: Connection configs keyed by server name. + + Raises: + RuntimeError: If the manager is closed or reconfigured + incompatibly after sessions already exist. + """ + if self._closed: + msg = "Cannot configure a closed MCP session manager" + raise RuntimeError(msg) + + if not self._entries: + self._connections = dict(connections) + return + + if _connections_signature(self._connections) != _connections_signature( + connections, + ): + msg = "Cannot reconfigure MCP session manager after sessions are active" + raise RuntimeError(msg) + self._connections = dict(connections) + + async def get_session(self, server_name: str) -> ClientSession: + """Return a cached session for `server_name`, creating it lazily.""" + entry = self._entries.get(server_name) + if entry is not None: + return entry.session + + lock = self._get_lock(server_name) + async with lock: + entry = self._entries.get(server_name) + if entry is not None: + return entry.session + + entry = await self._create_entry(server_name) + self._entries[server_name] = entry + return entry.session + + async def invalidate( + self, + server_name: str, + *, + expected_session: ClientSession | None = None, + ) -> None: + """Evict and close a cached session if it still matches `expected_session`. + + Args: + server_name: MCP server name. + expected_session: Optional identity check for race-safe eviction. + """ + lock = self._get_lock(server_name) + async with lock: + entry = self._entries.get(server_name) + if entry is None: + return + if expected_session is not None and entry.session is not expected_session: + return + self._entries.pop(server_name, None) + exit_stack = entry.exit_stack + + await exit_stack.aclose() + + async def cleanup(self) -> None: + """Close all cached sessions concurrently and reject future creation. + + Each server's `exit_stack.aclose()` runs with a 5 second timeout so + one slow stdio server cannot stall shutdown. Per-server failures + are logged — teardown is best-effort — but `CancelledError` is + re-raised so the enclosing `asyncio.gather` still cancels peers. + """ + if self._closed and not self._entries: + return + + self._closed = True + names = list(self._entries) + + async def _close(server_name: str) -> None: + try: + await asyncio.wait_for(self.invalidate(server_name), timeout=5.0) + except TimeoutError: + logger.warning( + "MCP session cleanup for %r timed out after 5s", + server_name, + ) + except (KeyboardInterrupt, SystemExit, asyncio.CancelledError): + raise + except Exception: + logger.warning( + "MCP session cleanup for %r failed", + server_name, + exc_info=True, + ) + + await asyncio.gather(*[_close(name) for name in names]) + + def _get_lock(self, server_name: str) -> asyncio.Lock: + """Return the per-server creation/eviction lock.""" + lock = self._locks.get(server_name) + if lock is None: + lock = asyncio.Lock() + self._locks[server_name] = lock + return lock + + async def _create_entry(self, server_name: str) -> _MCPSessionEntry: + """Create and initialize a new cached session entry. + + Args: + server_name: MCP server name. + + Returns: + A cached session entry containing the live session and close stack. + + Raises: + RuntimeError: If the manager has already been cleaned up. + ValueError: If `server_name` is not configured in the manager. + """ + if self._closed: + msg = "Cannot create an MCP session after cleanup" + raise RuntimeError(msg) + + try: + connection = self._connections[server_name] + except KeyError as exc: + msg = ( + f"Couldn't find an MCP server named '{server_name}', " + f"expected one of {sorted(self._connections)}" + ) + raise ValueError(msg) from exc + + from langchain_mcp_adapters.sessions import create_session + + exit_stack = AsyncExitStack() + try: + session = await exit_stack.enter_async_context(create_session(connection)) + await session.initialize() + except BaseException: + # Close the partially entered stack in *this* task before + # propagating. `create_session` enters an AnyIO task group whose + # cancel scope must be exited by the task that entered it; deferring + # teardown to async-generator finalization on another task raises + # "Attempted to exit cancel scope in a different task than it was + # entered in". Catch `BaseException` (not just `Exception`) so a + # `CancelledError` — e.g. from a crashed Streamable HTTP transport + # task group cancelling `session.initialize()` — also triggers the + # in-task teardown below instead of abandoning the session. The bare + # `raise` re-raises the original exception unchanged, so cancellation + # (and any other error) always propagates regardless; widening the + # catch only controls whether teardown runs, not whether the error + # propagates. + try: + await exit_stack.aclose() + except Exception: + # An ordinary cleanup failure must not mask the original error; + # the session is being discarded regardless. A `CancelledError` + # raised *by* `aclose()` is intentionally not caught here — it + # supersedes the original error, matching structured-cancellation + # semantics where an in-flight cancellation wins. + logger.warning( + "Failed to close a partially initialized MCP session for %r", + server_name, + exc_info=True, + ) + raise + + return _MCPSessionEntry(session=session, exit_stack=exit_stack) + + +def _resolve_server_type(server_config: Mapping[str, Any]) -> str: + """Determine the transport type for a server config. + + Accepts `type` or `transport` interchangeably. When neither is set, a + `url` field implies a remote server (defaulting to `http`) and the + absence of `url` implies stdio. This matches Claude Code's `.mcp.json` + convention where remote entries are commonly written as `{"url": "..."}` + alone. + + Args: + server_config: Server configuration dictionary. + + Returns: + Transport type string (`stdio`, `sse`, or `http`). + """ + transport = server_config.get("type") or server_config.get("transport") + if transport is not None: + return _TRANSPORT_ALIASES.get(transport, transport) + if "url" in server_config: + return "http" + return "stdio" + + +def _validate_server_config(server_name: str, server_config: dict[str, Any]) -> None: + """Validate a single server configuration. + + Performs only shape checks — `${VAR}` config interpolation is deferred + to activation time so one unset env var only fails its own server + rather than hiding every other MCP entry in the same file. + + Args: + server_name: Name of the server. + server_config: Server configuration dictionary. + + Raises: + TypeError: If config fields have wrong types. + ValueError: If required fields are missing or server type is unsupported. + """ + if not _SERVER_NAME_RE.fullmatch(server_name): + error_msg = ( + f"Invalid server name {server_name!r}: server names must contain " + "only alphanumerics, hyphens, and underscores." + ) + raise ValueError(error_msg) + + if not isinstance(server_config, dict): + error_msg = f"Server '{server_name}' config must be a dictionary" + raise TypeError(error_msg) + + server_type = _resolve_server_type(server_config) + + if server_type in _SUPPORTED_REMOTE_TYPES: + if "url" not in server_config: + error_msg = ( + f"Server '{server_name}' with type '{server_type}' " + "missing required 'url' field" + ) + raise ValueError(error_msg) + + if "command" in server_config: + error_msg = ( + f"Server '{server_name}' has type '{server_type}' (remote) " + "but also declares a 'command' field. Remove 'command' or " + 'set `"type": "stdio"`.' + ) + raise ValueError(error_msg) + + headers = server_config.get("headers") + if headers is not None and not isinstance(headers, dict): + error_msg = f"Server '{server_name}' 'headers' must be a dictionary" + raise TypeError(error_msg) + + if isinstance(headers, dict): + for name, value in headers.items(): + if not isinstance(value, str): + error_msg = ( + f"Server '{server_name}' header {name!r} must be " + f"a string, got {type(value).__name__}" + ) + raise TypeError(error_msg) + elif server_type == "stdio": + if "command" not in server_config: + error_msg = f"Server '{server_name}' missing required 'command' field" + raise ValueError(error_msg) + + if "url" in server_config: + error_msg = ( + f"Server '{server_name}' has type 'stdio' but also declares " + "a 'url' field. Remove 'url' or set " + '`"type": "http"` (or `"sse"`) for a remote server.' + ) + raise ValueError(error_msg) + + if "args" in server_config and not isinstance(server_config["args"], list): + error_msg = f"Server '{server_name}' 'args' must be a list" + raise TypeError(error_msg) + + if "env" in server_config and not isinstance(server_config["env"], dict): + error_msg = f"Server '{server_name}' 'env' must be a dictionary" + raise TypeError(error_msg) + else: + error_msg = ( + f"Server '{server_name}' has unsupported transport type '{server_type}'. " + "Supported types: stdio, sse, http" + ) + raise ValueError(error_msg) + + auth = server_config.get("auth") + if auth is not None: + if auth != "oauth": + msg = ( + f"Server '{server_name}' has unsupported auth value " + f"{auth!r}. Only 'oauth' is supported." + ) + raise ValueError(msg) + if server_type == "stdio": + msg = ( + f"Server '{server_name}' uses stdio transport; " + "'auth: oauth' is only valid for http/sse transports." + ) + raise ValueError(msg) + header_names = {name.lower() for name in (server_config.get("headers") or {})} + if "authorization" in header_names: + msg = ( + f"Server '{server_name}' cannot combine 'auth: oauth' " + "with an 'Authorization' header." + ) + raise ValueError(msg) + + _validate_tool_filter_fields(server_name, server_config) + + +def _validate_tool_filter_fields( + server_name: str, + server_config: dict[str, Any], +) -> None: + """Validate optional `allowedTools` / `disabledTools` fields. + + Both fields, when present, must be non-empty lists of strings. Setting + both on the same server is rejected to keep the filter semantics + unambiguous. An empty list is rejected because it would silently strip + every tool from the server (`allowedTools`) or be a no-op + (`disabledTools`) — both are almost certainly user errors; omit the field + instead. + + Args: + server_name: Name of the server (for error messages). + server_config: Server configuration dictionary. + + Raises: + TypeError: If a field is not a list of strings. + ValueError: If both fields are set, or either field is empty. + """ + has_allowed = "allowedTools" in server_config + has_disabled = "disabledTools" in server_config + if has_allowed and has_disabled: + error_msg = ( + f"Server '{server_name}' cannot set both 'allowedTools' and" + " 'disabledTools' — pick one." + ) + raise ValueError(error_msg) + + for field_name in ("allowedTools", "disabledTools"): + if field_name not in server_config: + continue + value = server_config[field_name] + if not isinstance(value, list) or not all( + isinstance(item, str) for item in value + ): + error_msg = ( + f"Server '{server_name}' '{field_name}' must be a list of strings" + ) + raise TypeError(error_msg) + if not value: + error_msg = ( + f"Server '{server_name}' '{field_name}' must be non-empty;" + " omit the field to disable filtering." + ) + raise ValueError(error_msg) + + +def _looks_like_comment(doc: str, lineno: int) -> bool: + """Return `True` if the offending line *begins* with `//` or `/*`. + + Only the failing line is checked, and only its leading characters (after + stripping indentation). A `url` value such as `"url": "https://..."` + begins with a quote, not `//`, so a URL scheme inside a quoted string + never triggers a false comment hint. + + Args: + doc: Full source text that failed to parse. + lineno: 1-based line number of the error; out-of-range values + return `False`. + + Returns: + `True` when the stripped failing line starts with `//` or `/*`. + """ + lines = doc.splitlines() + if lineno < 1 or lineno > len(lines): + return False + stripped = lines[lineno - 1].lstrip() + return stripped.startswith(("//", "/*")) + + +def _json_error_hint(exc: json.JSONDecodeError) -> str | None: + """Return an actionable hint for a common JSON mistake, or `None`. + + Checks are ordered most-specific-first (trailing comma, then comment, + then generic decoder-message keywords) so a more precise hint wins when + several could apply. + + Args: + exc: The decode error to classify. + + Returns: + A hint string for a recognized mistake, or `None` when no specific + guidance applies. + """ + msg = exc.msg.lower() + if "trailing comma" in msg: + return ( + "Hint: JSON does not allow trailing commas. Remove the comma " + "before the closing '}' or ']'." + ) + if _looks_like_comment(exc.doc, exc.lineno): + return "Hint: JSON does not allow comments (// or /* */). Remove them." + if "expecting property name" in msg: + return ( + "Hint: check for trailing commas, a missing key, or an unquoted " + "property name near this position." + ) + if "expecting value" in msg: + return ( + "Hint: check for a missing value, an extra comma, or unquoted text " + "near this position." + ) + if "delimiter" in msg: + return ( + "Hint: check for a missing comma, ':', or closing bracket near " + "this position." + ) + return None + + +def _trailing_comma_pos(doc: str, pos: int) -> int | None: + """Return the comma position for decoder errors at a trailing comma.""" + if pos < 0 or pos >= len(doc) or doc[pos] not in "}]": + return None + idx = pos - 1 + while idx >= 0 and doc[idx].isspace(): + idx -= 1 + if idx >= 0 and doc[idx] == ",": + return idx + return None + + +def _json_error_snippet( + doc: str, lineno: int, colno: int, *, pos: int | None = None +) -> str | None: + """Build a caret snippet pointing at a JSON error location. + + Args: + doc: Full source text that failed to parse. + lineno: 1-based line number of the error. + colno: 1-based column number of the error. + pos: 0-based absolute error offset, if available. + + Returns: + A two-line `` + caret string, or `None` when the line + is out of range or blank. + """ + if pos is not None: + trailing_pos = _trailing_comma_pos(doc, pos) + if trailing_pos is not None: + lineno = doc.count("\n", 0, trailing_pos) + 1 + line_start = doc.rfind("\n", 0, trailing_pos) + 1 + colno = trailing_pos - line_start + 1 + lines = doc.splitlines() + if lineno < 1 or lineno > len(lines): + return None + source = lines[lineno - 1].rstrip() + if not source: + return None + caret_col = max(0, min(colno - 1, len(source))) + return f" {source}\n {' ' * caret_col}^" + + +def _load_mcp_config_json(config_path: str) -> dict[str, Any]: + """Load MCP configuration JSON with parser diagnostics. + + Args: + config_path: Path to the MCP JSON configuration file. + + Returns: + Parsed configuration dictionary. + + Raises: + FileNotFoundError: If config file doesn't exist. + json.JSONDecodeError: If config file contains invalid JSON. + """ + path = Path(config_path) + + if not path.exists(): + error_msg = f"MCP config file not found: {config_path}" + raise FileNotFoundError(error_msg) + + try: + with path.open(encoding="utf-8") as file_obj: + return json.load(file_obj) + except json.JSONDecodeError as exc: + # Build a layered message: core reason, an actionable hint for common + # mistakes, then a caret snippet last so the auto-appended + # "line X column Y" suffix reads as the location of the caret. + parts = [f"Invalid JSON in MCP config file: {exc.msg}"] + hint = _json_error_hint(exc) + if hint is not None: + parts.append(hint) + snippet = _json_error_snippet(exc.doc, exc.lineno, exc.colno, pos=exc.pos) + if snippet is not None: + parts.append(snippet) + error_msg = "\n".join(parts) + raise json.JSONDecodeError(error_msg, exc.doc, exc.pos) from exc + + +def _validate_mcp_config_top_level(config: dict[str, Any]) -> None: + """Validate top-level MCP configuration fields. + + Args: + config: Parsed MCP config dictionary. + + Raises: + TypeError: If top-level fields have wrong types. + ValueError: If required top-level fields are missing. + """ + if "mcpServers" not in config: + error_msg = ( + "MCP config must contain 'mcpServers' field. " + 'Expected format: {"mcpServers": {"server-name": {...}}}' + ) + raise ValueError(error_msg) + + if not isinstance(config["mcpServers"], dict): + error_msg = "'mcpServers' field must be a dictionary" + raise TypeError(error_msg) + + if not config["mcpServers"]: + error_msg = "'mcpServers' field is empty - no servers configured" + raise ValueError(error_msg) + + +def _validate_mcp_config_servers(config: dict[str, Any]) -> None: + """Validate every server in an MCP configuration. + + Args: + config: Parsed MCP config dictionary. + """ + for server_name, server_config in config["mcpServers"].items(): + _validate_server_config(server_name, server_config) + + +def _drop_invalid_mcp_config_servers( + config: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, str]]: + """Remove invalid server entries without rejecting valid siblings. + + Callers use this only after config precedence has been resolved, so an + invalid winning definition is dropped instead of revealing a shadowed + lower-precedence server with the same name. + + Args: + config: Parsed MCP config with a top-level `mcpServers` mapping. + + Returns: + A tuple containing the config with only valid servers and a mapping of + dropped server names to validation errors. + """ + valid: dict[str, Any] = {} + errors: dict[str, str] = {} + for name, server in config["mcpServers"].items(): + try: + _validate_server_config(name, server) + except (ValueError, TypeError, RuntimeError) as exc: + errors[name] = str(exc) + else: + valid[name] = server + return {**config, "mcpServers": valid}, errors + + +def _load_mcp_config_top_level(config_path: Path) -> dict[str, Any]: + """Load an MCP config file and validate only its top-level shape. + + Args: + config_path: Config path to load. + + Returns: + Parsed configuration dictionary with a valid `mcpServers` mapping. + """ + config = _load_mcp_config_json(str(config_path)) + _validate_mcp_config_top_level(config) + return config + + +def load_mcp_config(config_path: str) -> dict[str, Any]: + """Load and validate MCP configuration from a JSON file. + + Supports multiple server types: + + - stdio: Process-based servers with `command`, `args`, `env` fields (default) + - sse: Server-Sent Events servers with `type: "sse"`, `url`, and optional `headers` + - http: HTTP-based servers with `type: "http"`, `url`, and optional `headers` + + Any server type may also set an optional tool filter: + + - `allowedTools`: list of tool names or patterns to keep (all others dropped) + - `disabledTools`: list of tool names or patterns to drop (all others kept) + + Entries are either literal tool names or `fnmatch`-style glob patterns + (entries containing `*`, `?`, or `[`). Each entry is matched against both + the bare MCP tool name and the server-prefixed form + (`f"{server_name}_{tool}"`), so either `read_*` or `fs_read_*` works. + Setting both fields on a single server is an error. + + Args: + config_path: Path to the MCP JSON configuration file. + + Returns: + Parsed configuration dictionary. + + Raises: + FileNotFoundError: If config file doesn't exist. + json.JSONDecodeError: If config file contains invalid JSON. + TypeError: If config fields have wrong types. + ValueError: If config is missing required fields. + """ # noqa: DOC502 - raised indirectly by `_load_mcp_config_json` / `_validate_server_config` (which does shape-only checks; `${VAR}` config interpolation is deferred to activation time, so no RuntimeError here) + config = _load_mcp_config_top_level(Path(config_path)) + _validate_mcp_config_servers(config) + + return config + + +def _resolve_project_config_base(project_context: ProjectContext | None) -> Path: + """Resolve the base directory for project-level MCP configuration lookup. + + Args: + project_context: Explicit project path context, if available. + + Returns: + Project root when one exists, otherwise the user working directory. + """ + if project_context is not None: + return project_context.project_root or project_context.user_cwd + + from deepagents_code.project_utils import find_project_root + + return find_project_root() or Path.cwd() + + +def project_root_for_mcp_config_path( + path: Path, *, fallback: Path | None = None +) -> Path: + """Infer the project root that owns a project-level MCP config path. + + Args: + path: Project-level `.mcp.json` path. + fallback: Root to use as the base for relative config paths. + + Returns: + The owning project root. + """ + parent = path.parent + if fallback is not None and not path.is_absolute(): + parent = fallback if str(parent) == "." else fallback / parent + if parent.name == ".deepagents": + return parent.parent + return parent + + +def filter_trusted_project_servers( + servers: Mapping[str, Any], + trust_lists: McpServerTrustLists, + *, + project_root: Path, + config_trusted: bool = False, +) -> dict[str, Any]: + """Return only the project servers that survive the user's trust policy. + + The single place the per-server trust rule lives, shared by the runtime + tool loader and the `mcp login` resolver so reject-precedence cannot drift + between them: a disabled name is dropped even from a `config_trusted` + config; otherwise a server is kept when the whole config is trusted or the + user's scoped approvals / env allowlist enable it (`is_enabled`). + + Args: + servers: `mcpServers`-shaped mapping of name to definition. + trust_lists: The user's allow/deny policy. + project_root: Resolved project root owning `servers`, for scoped + fingerprint matching. + config_trusted: Whether the config as a whole is trusted (e.g. + `--trust-project-mcp`). Defaults to `False`. + + Returns: + The kept subset of `servers`, in input order. + """ + kept: dict[str, Any] = {} + for name, server in servers.items(): + if name in trust_lists.disabled: + # Explicit reject always wins, even for a trusted config. + continue + if config_trusted or trust_lists.is_enabled( + name, project_root=project_root, server=server + ): + kept[name] = server + return kept + + +MCP_CONFIG_DISCOVERY_PATHS: tuple[tuple[str, str], ...] = ( + ("~/.deepagents/.mcp.json", "user-level"), + ("/.deepagents/.mcp.json", "project subdir"), + ("/.mcp.json", "project root"), +) +"""Display strings for the auto-discovered MCP config paths. + +Ordered from lowest to highest precedence. Each entry is `(path, label)` +suitable for rendering in help screens and error messages. The runtime +discovery in `discover_mcp_configs` builds the same paths from +`Path.home()` and `_resolve_project_config_base()`. +""" + + +def discover_mcp_configs( + *, + project_context: ProjectContext | None = None, +) -> list[Path]: + """Find MCP config files from standard locations. + + Checks the paths listed in `MCP_CONFIG_DISCOVERY_PATHS`, lowest to + highest precedence. + + Args: + project_context: Explicit project path context, if available. + + Returns: + Existing config file paths, ordered from lowest to highest precedence. + """ + user_dir = Path.home() / ".deepagents" + project_root = _resolve_project_config_base(project_context) + + candidates = [ + user_dir / ".mcp.json", + project_root / ".deepagents" / ".mcp.json", + project_root / ".mcp.json", + ] + + found: list[Path] = [] + for path in candidates: + try: + if path.is_file(): + found.append(path) + except OSError: + logger.warning("Could not check MCP config %s", path, exc_info=True) + return found + + +def classify_discovered_configs( + config_paths: list[Path], +) -> tuple[list[Path], list[Path]]: + """Split discovered config paths into user-level and project-level configs. + + Args: + config_paths: Candidate config paths from discovery. + + Returns: + Tuple of `(user_configs, project_configs)`. + """ + user_dir = Path.home() / ".deepagents" + user: list[Path] = [] + project: list[Path] = [] + for path in config_paths: + try: + if path.resolve().is_relative_to(user_dir.resolve()): + user.append(path) + else: + project.append(path) + except (OSError, ValueError): + project.append(path) + return user, project + + +def extract_stdio_server_commands( + config: dict[str, Any], +) -> list[tuple[str, str, list[str]]]: + """Extract stdio server entries from a parsed MCP config. + + Args: + config: Parsed MCP config dictionary. + + Returns: + List of `(server_name, command, args)` tuples for stdio servers. + """ + results: list[tuple[str, str, list[str]]] = [] + servers = config.get("mcpServers", {}) + if not isinstance(servers, dict): + return results + for name, server in servers.items(): + if not isinstance(server, dict): + continue + if _resolve_server_type(server) == "stdio": + results.append((name, server.get("command", ""), server.get("args", []))) + return results + + +class ProjectServerSummary(NamedTuple): + """A project MCP server row shown to the user and gated for trust. + + A `NamedTuple` (not a bare 3-tuple) so the three same-typed `str` slots get + field names — a `name`/`kind` swap can't type-check silently — while staying + tuple-compatible with existing unpacking and indexing. + """ + + name: str + """MCP server name.""" + + kind: str + """Transport kind from `_resolve_server_type`: `"stdio"`, `"http"`, or + `"sse"` for a well-formed entry. Typed `str`, not a `Literal`, because these + summaries are built from *unvalidated* configs (the trust prompt inspects + raw merged servers before validation), so a malformed `type`/`transport` + passes through verbatim (e.g. `{"type": "banana"}` yields `"banana"`).""" + + summary: str + """`" "` for stdio entries, the URL for remote entries.""" + + +def extract_project_server_summaries( + config: dict[str, Any], +) -> list[ProjectServerSummary]: + """Return a `ProjectServerSummary` for every server in a project config. + + Used by the trust prompt and the untrusted-config skip warning so that + both stdio servers (which spawn local commands) and remote servers + (which can SSRF or exfiltrate environment variables via interpolated + headers when an attacker controls `.mcp.json`) are gated identically. + + Args: + config: Parsed MCP config dictionary. + + Returns: + One `ProjectServerSummary` per server, in config order. + """ + results: list[ProjectServerSummary] = [] + servers = config.get("mcpServers", {}) + if not isinstance(servers, dict): + return results + for name, server in servers.items(): + if not isinstance(server, dict): + logger.debug( + "Skipping malformed MCP server entry %r: expected a table, got %s", + name, + type(server).__name__, + ) + continue + kind = _resolve_server_type(server) + if kind == "stdio": + args = server.get("args") or [] + summary = f"{server.get('command', '')} {' '.join(args)}".strip() + elif kind in _SUPPORTED_REMOTE_TYPES: + summary = str(server.get("url", "")) + else: + summary = "" + results.append(ProjectServerSummary(name, kind, summary)) + return results + + +def merge_mcp_configs(configs: list[dict[str, Any]]) -> dict[str, Any]: + """Merge multiple MCP config dicts by server name. + + Args: + configs: Config dictionaries in ascending precedence order. + + Returns: + A single config dict with later server definitions overriding earlier ones. + """ + merged: dict[str, Any] = {} + for config in configs: + servers = config.get("mcpServers") + if isinstance(servers, dict): + merged.update(servers) + return {"mcpServers": merged} + + +def _merge_mcp_configs_with_sources( + configs: list[tuple[Path, dict[str, Any]]], +) -> tuple[dict[str, Any], dict[str, Path]]: + """Merge MCP configs and retain the winning source for each server. + + Args: + configs: `(path, config)` pairs in ascending precedence order. + + Returns: + The merged config and a mapping from each server name to the path that + supplied its highest-precedence definition. + """ + servers: dict[str, Any] = {} + sources: dict[str, Path] = {} + for path, config in configs: + config_servers = config.get("mcpServers") + if isinstance(config_servers, dict): + servers.update(config_servers) + for name in cast("dict[str, Any]", config_servers): + sources[name] = path + return {"mcpServers": servers}, sources + + +def load_mcp_config_lenient( + config_path: Path, *, disabled_servers: Collection[str] = () +) -> dict[str, Any] | None: + """Load a single MCP config file, returning `None` on any error. + + Disabled servers are removed before per-server validation, so explicitly + denied entries can neither block loading nor surface to a caller inspecting + the config. The single-file counterpart to `load_merged_mcp_configs_lenient` + (which the trust prompt uses); this one has no production caller today and is + retained as the standalone lenient loader. + + Args: + config_path: Config path to load. + disabled_servers: Server names to remove before validation. + + Returns: + The parsed config, or `None` if loading or validation fails. + """ + config, _ = _load_mcp_config_top_level_with_error(config_path) + if config is None: + return None + + servers = config["mcpServers"] + filtered = { + **config, + "mcpServers": { + name: server + for name, server in servers.items() + if name not in disabled_servers + }, + } + try: + _validate_mcp_config_servers(filtered) + except (ValueError, TypeError, RuntimeError) as exc: + logger.warning("Skipping invalid MCP config %s: %s", config_path, exc) + return None + return filtered + + +def load_merged_mcp_configs_lenient( + config_paths: Collection[Path], *, disabled_servers: Collection[str] = () +) -> dict[str, Any] | None: + """Load and validate project configs after resolving precedence. + + The trust prompt must inspect the exact merged server definitions that a + whole-config approval can activate. Parsing each file with per-server + validation first can discard valid lower-precedence siblings when a bad + entry in that file is replaced by a valid higher-precedence definition. + + Args: + config_paths: Project config paths in ascending precedence order. + disabled_servers: Server names to remove before validation. + + Returns: + The merged, filtered config, or `None` when no config is usable. Invalid + winning server definitions are dropped without hiding valid siblings. + """ + configs: list[dict[str, Any]] = [] + for path in config_paths: + config, _ = _load_mcp_config_top_level_with_error(path) + if config is not None: + configs.append(config) + if not configs: + return None + + merged = merge_mcp_configs(configs) + servers = merged["mcpServers"] + filtered = { + **merged, + "mcpServers": { + name: server + for name, server in servers.items() + if name not in disabled_servers + }, + } + valid, errors = _drop_invalid_mcp_config_servers(filtered) + for name, error in errors.items(): + logger.warning("Skipping invalid merged MCP server %r: %s", name, error) + if errors and not valid["mcpServers"]: + return None + return valid + + +def load_mcp_config_with_error( + config_path: Path, +) -> tuple[dict[str, Any] | None, str | None]: + """Load an MCP config file, returning `(config, error)`. + + Missing files yield `(None, None)` — not an error. Malformed files + yield `(None, error_text)` so callers can surface the reason to users. + + Args: + config_path: Config path to load. + + Returns: + `(parsed_config, None)` on success, `(None, None)` when the file + doesn't exist, or `(None, error_message)` on load/validate failure. + """ + try: + return load_mcp_config(str(config_path)), None + except FileNotFoundError: + return None, None + except OSError as exc: + logger.warning("Skipping unreadable MCP config %s: %s", config_path, exc) + return None, f"Unreadable: {exc}" + except (json.JSONDecodeError, ValueError, TypeError, RuntimeError) as exc: + logger.warning("Skipping invalid MCP config %s: %s", config_path, exc) + return None, str(exc) + + +def _load_mcp_config_top_level_with_error( + config_path: Path, +) -> tuple[dict[str, Any] | None, str | None]: + """Load an MCP config file, validating only its top-level structure. + + Args: + config_path: Config path to load. + + Returns: + `(parsed_config, None)` on success, `(None, None)` when the file + doesn't exist, or `(None, error_message)` on load/top-level validate + failure. + """ + try: + return _load_mcp_config_top_level(config_path), None + except FileNotFoundError: + return None, None + except OSError as exc: + logger.warning("Skipping unreadable MCP config %s: %s", config_path, exc) + return None, f"Unreadable: {exc}" + except (json.JSONDecodeError, ValueError, TypeError) as exc: + logger.warning("Skipping invalid MCP config %s: %s", config_path, exc) + return None, str(exc) + + +def _check_stdio_server(server_name: str, server_config: dict[str, Any]) -> None: + """Verify that a stdio server's command exists on PATH. + + Args: + server_name: Server name for error messages. + server_config: Validated server config. + + Raises: + RuntimeError: If the command is missing or not found on PATH. + """ + command = server_config.get("command") + if command is None: + msg = f"MCP server '{server_name}': missing 'command' in config." + raise RuntimeError(msg) + if shutil.which(command) is None: + msg = ( + f"MCP server '{server_name}': configured command not found on PATH. " + "Install it or check your MCP config." + ) + raise RuntimeError(msg) + + +async def _check_remote_server(server_name: str, server_config: dict[str, Any]) -> None: + """Check network connectivity to a remote MCP server URL. + + Args: + server_name: Server name for error messages. + server_config: Validated remote server config. + + Raises: + RuntimeError: If the URL is missing, unreachable, or returns 5xx. + """ + import httpx + + url = server_config.get("url") + if url is None: + msg = f"MCP server '{server_name}': missing 'url' in config." + raise RuntimeError(msg) + try: + async with httpx.AsyncClient(timeout=2.0) as client: + response = await client.head(url) + except (httpx.HTTPError, httpx.InvalidURL, OSError) as exc: + # Name the failure *class* (e.g. `ConnectTimeout`, `InvalidURL`) so the + # failure mode stays diagnosable, but keep the URL redacted: `str(exc)` + # echoes the URL (which may carry `${VAR}`-injected credentials), while + # the class name never does. + msg = ( + f"MCP server '{server_name}': configured URL is unreachable " + f"({type(exc).__name__}). " + "Check that the URL is correct and the server is running." + ) + raise RuntimeError(msg) from exc + if response.status_code >= 500: # noqa: PLR2004 # HTTP server-error band + msg = ( + f"MCP server '{server_name}': configured URL returned HTTP " + f"{response.status_code}. Server may be down; retry later." + ) + raise RuntimeError(msg) + + +def _config_uses_env_interpolation(server_config: dict[str, Any]) -> bool: + """Return whether a supported config value contains an env reference. + + Exceptions raised after interpolation may include resolved connection + values in their messages or traceback. Treat every environment-derived + value as potentially sensitive so those failures can be reported without + exposing the resolved value. + + Args: + server_config: Raw, unresolved MCP server configuration. + + Returns: + Whether a supported value contains a `${...}` reference. + """ + scalar_values = [server_config.get("command"), server_config.get("url")] + sequence_values = server_config.get("args") + if isinstance(sequence_values, list): + scalar_values.extend(sequence_values) + for field in ("env", "headers"): + mapping = server_config.get(field) + if isinstance(mapping, dict): + scalar_values.extend(mapping.values()) + return any(isinstance(value, str) and "${" in value for value in scalar_values) + + +async def _discover_tools(session: ClientSession) -> list[Any]: + """Enumerate MCP tools from `session`, paginating until exhausted. + + Args: + session: Initialized MCP client session. + + Returns: + Discovered MCP tool definitions. + + Raises: + RuntimeError: If pagination never terminates within the hard safety bound. + """ + cursor: str | None = None + tools: list[Any] = [] + for _ in range(1000): + page = await session.list_tools(cursor=cursor) + if page.tools: + tools.extend(page.tools) + if not page.nextCursor: + return tools + cursor = page.nextCursor + msg = ( + "Reached max of 1000 iterations while listing MCP tools; " + "server may be returning a non-terminating cursor." + ) + raise RuntimeError(msg) + + +def _normalize_mcp_arguments( + arguments: dict[str, Any], + input_schema: Any, # noqa: ANN401 # raw JSON Schema dict from the MCP tool +) -> dict[str, Any]: + """Drop empty-string values for optional MCP tool params. + + Some MCP servers (e.g. Slack's `slack_search_public_and_private`) validate + optional ID-typed params with `value is not a channel ID` when the model + fills them in with `""` instead of omitting them. JSON-Schema-derived + Pydantic models happily accept `""` for `Optional[str]`, so the request + reaches the server and gets rejected with a generic `ToolException`. + + Treat `""` for non-required string fields as "omitted" so the MCP server + sees the same payload it would have for a field the model genuinely + skipped. Required fields are passed through unchanged so the server's + own missing-field error path still runs when applicable. + + Only `""` is normalized; `None` is left to the caller / server. Schemas + that declare `["string", "null"]` will see `""` dropped but `None` + forwarded — callers that want symmetric "no value" handling should + omit the kwarg explicitly. + + Dropped keys are logged at debug so unexpected MCP behavior is + diagnosable when a tool semantically distinguishes `""` from omitted. + + Args: + arguments: Keyword arguments collected by LangChain's tool runner. + input_schema: The MCP tool's `inputSchema` (raw JSON Schema dict). + + Returns: + A new dict suitable for `session.call_tool`. + """ + if not isinstance(input_schema, dict): + return arguments + required = set(input_schema.get("required") or ()) + properties = input_schema.get("properties") or {} + cleaned: dict[str, Any] = {} + for key, value in arguments.items(): + if value != "" or key in required: # noqa: PLC1901 # distinguishing "" from other falsy types (0, False, []) is the point + cleaned[key] = value + continue + prop = properties.get(key) + prop_type = prop.get("type") if isinstance(prop, dict) else None + is_string_typed = prop_type == "string" or ( + isinstance(prop_type, list) and "string" in prop_type + ) + # Three drop conditions converge here: + # - explicit string type (the original Slack-style failure mode); + # - missing `type` (oneOf/anyOf/$ref or untyped — treat as ambiguous + # and conservatively drop, since the server will reject `""` for + # any ID-shaped slot anyway); + # - key absent from `properties` entirely (model invented a field). + # Anything with an explicit non-string `type` is kept — `""` can't be + # a valid integer/bool/array so it was the model's mistake to send, + # and the server's own validation gives a clearer error than ours. + if isinstance(prop, dict) and not is_string_typed and prop_type is not None: + cleaned[key] = value + if cleaned.keys() != arguments.keys(): + dropped = sorted(set(arguments) - set(cleaned)) + logger.debug("MCP arg normalize: dropped empty-string keys %s", dropped) + return cleaned + + +def _build_cached_mcp_tool( + *, + mcp_tool: Any, # noqa: ANN401 + server_name: str, + session_manager: MCPSessionManager, + tool_name_prefix: bool, +) -> BaseTool: + """Build a `StructuredTool` backed by the cached session manager. + + Args: + mcp_tool: MCP tool metadata object. + server_name: Owning MCP server name. + session_manager: Runtime session cache used for tool calls. + tool_name_prefix: Whether to prefix the LangChain tool name with the + server name. + + Returns: + A LangChain `BaseTool` wrapper around the MCP tool. + """ + from langchain_core.tools import StructuredTool, ToolException + from langchain_mcp_adapters.tools import ( + _convert_call_tool_result, # noqa: PLC2701 + _handle_mcp_tool_error, # noqa: PLC2701 + ) + + original_tool_name = mcp_tool.name + lc_tool_name = ( + f"{server_name}_{original_tool_name}" + if tool_name_prefix and server_name + else original_tool_name + ) + + meta = getattr(mcp_tool, "meta", None) + base_meta = ( + mcp_tool.annotations.model_dump() if mcp_tool.annotations is not None else {} + ) + wrapped_meta = {"_meta": meta} if meta is not None else {} + metadata = { + **base_meta, + **wrapped_meta, + "_deepagents_code_mcp": True, + "_deepagents_code_mcp_server": server_name, + } + + def _handle_cached_mcp_tool_error(error: ToolException) -> Any: # noqa: ANN401 + try: + return _handle_mcp_tool_error(error) + except ToolException: + logger.warning( + "MCP tool %r failed with recoverable ToolException: %s", + lc_tool_name, + error, + exc_info=True, + ) + return str(error) or f"{lc_tool_name} failed with no error detail" + + async def coroutine( + # `runtime` is injected by LangChain's tool-calling plumbing. + # MCP tools don't use it but the kwarg must still be accepted. + runtime: Any = None, # noqa: ANN401, ARG001 + **arguments: Any, + ) -> Any: # noqa: ANN401 + from deepagents_code.mcp_auth import find_reauth_required + + arguments = _normalize_mcp_arguments(arguments, mcp_tool.inputSchema) + + session = await session_manager.get_session(server_name) + try: + result = await session.call_tool(original_tool_name, arguments) + # Re-raise control-flow/shutdown signals (CancelledError, + # KeyboardInterrupt, SystemExit) and ToolException unchanged. Wrapping a + # ToolException here would bury its actionable message (e.g. an MCP + # `isError` instruction like "use the X tool instead") under a generic + # retry wrapper; re-raising preserves it for the tool-local error + # handler and the model. + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit, ToolException): + raise + except Exception as exc: + reauth = find_reauth_required(exc) + if reauth is not None: + await session_manager.invalidate( + server_name, + expected_session=session, + ) + raise ToolException(str(reauth)) from exc + if not _is_transient_session_error(exc): + msg = ( + f"MCP tool {lc_tool_name!r} failed on server " + f"{server_name!r}: {type(exc).__name__}: {exc}" + ) + raise ToolException(msg) from exc + logger.info( + "MCP session for %r appears dead (%s: %s); " + "invalidating and retrying once", + server_name, + type(exc).__name__, + exc, + ) + await session_manager.invalidate( + server_name, + expected_session=session, + ) + + retry_session = await session_manager.get_session(server_name) + try: + result = await retry_session.call_tool(original_tool_name, arguments) + except ( + asyncio.CancelledError, + KeyboardInterrupt, + SystemExit, + ToolException, + ): + raise + except Exception as retry_exc: # noqa: BLE001 - wrapped into ToolException below so the agent sees it + try: + retry_reauth = find_reauth_required(retry_exc) + if retry_reauth is not None: + raise ToolException(str(retry_reauth)) from retry_exc + msg = ( + f"MCP tool {lc_tool_name!r} failed after one retry on " + f"server {server_name!r}: {type(retry_exc).__name__}: " + f"{retry_exc}" + ) + raise ToolException(msg) from retry_exc + finally: + # Invalidate the retry session last; log cleanup failure + # so resource leaks are observable. + try: + await session_manager.invalidate( + server_name, + expected_session=retry_session, + ) + except Exception: + logger.warning( + "Failed to invalidate retry session for %r after " + "tool failure", + server_name, + exc_info=True, + ) + + # On an MCP `isError=True` result the adapter's `_convert_call_tool_result` + # raises, and the `handle_tool_error` callback registered below converts + # the MCP content blocks into a `ToolMessage(status="error")`. Other + # expected `ToolException`s raised by this wrapper are formatted by that + # same tool-local handler. + return _convert_call_tool_result(result) + + return StructuredTool( + name=lc_tool_name, + description=mcp_tool.description or "", + args_schema=mcp_tool.inputSchema, + coroutine=coroutine, + response_format="content_and_artifact", + metadata=metadata, + handle_tool_error=cast("Any", _handle_cached_mcp_tool_error), + ) + + +_GLOB_METACHARS = frozenset("*?[") + + +def _entry_matches_tool(entry: str, tool_name: str, prefix: str) -> bool: + """Return True if a single filter entry matches a tool name. + + An entry containing `*`, `?`, or `[` is treated as an `fnmatch`-style glob; + otherwise it is matched literally. Each entry is tried against both the + bare MCP tool name and the server-prefixed form (`f"{prefix}{tool}"`), so + users can write either `read_*` or `fs_read_*`. + + Args: + entry: Filter list entry from `allowedTools` / `disabledTools`. + tool_name: Adapter-supplied tool name (already server-prefixed). + prefix: Server prefix (`f"{server_name}_"`). + + Returns: + True if the entry matches this tool under either match mode. + """ + is_glob = any(ch in _GLOB_METACHARS for ch in entry) + if is_glob: + if fnmatch.fnmatchcase(tool_name, entry): + return True + if tool_name.startswith(prefix): + return fnmatch.fnmatchcase(tool_name[len(prefix) :], entry) + return False + if tool_name == entry: + return True + return tool_name.startswith(prefix) and tool_name[len(prefix) :] == entry + + +@overload +def _apply_tool_filter( + tools: list[BaseTool], + server_name: str, + server_config: dict[str, Any], +) -> list[BaseTool]: ... + + +@overload +def _apply_tool_filter( + tools: Sequence[BaseTool], + server_name: str, + server_config: dict[str, Any], +) -> Sequence[BaseTool]: ... + + +def _apply_tool_filter( + tools: Sequence[BaseTool], + server_name: str, + server_config: dict[str, Any], +) -> Sequence[BaseTool]: + """Filter a server's loaded tools by its `allowedTools` / `disabledTools`. + + Entries may be literal tool names or `fnmatch`-style glob patterns + (entries containing `*`, `?`, or `[`). Each entry is tried against both + the bare MCP tool name and the server-prefixed name produced by + `tool_name_prefix=True` (`f"{server_name}_{tool}"`). Entries that match + no loaded tool are logged but not an error — the underlying MCP server + may expose different tools across versions, so a stale entry should not + fail startup. The same warning is emitted symmetrically for both fields + so a typo in `disabledTools` is visible (otherwise a tool the user + intended to disable would silently remain enabled). + + Args: + tools: Tools returned by `load_mcp_tools` for a single server. + server_name: Server name used by the adapter to build the prefix. + server_config: Server config dict (read for filter fields). + + Returns: + Filtered tool list preserving input order. + """ + allowed: list[str] | None = server_config.get("allowedTools") + disabled: list[str] | None = server_config.get("disabledTools") + entries: list[str] | None = allowed if allowed is not None else disabled + if entries is None: + return tools + + prefix = f"{server_name}_" + field_name = "allowedTools" if allowed is not None else "disabledTools" + + def _any_entry_matches(tool_name: str, entry_list: list[str]) -> bool: + return any(_entry_matches_tool(e, tool_name, prefix) for e in entry_list) + + missing = [ + e + for e in entries + if not any(_entry_matches_tool(e, t.name, prefix) for t in tools) + ] + if missing: + logger.warning( + "MCP server '%s' %s entries matched no tools: %s", + server_name, + field_name, + ", ".join(missing), + ) + + if allowed is not None: + return [t for t in tools if _any_entry_matches(t.name, entries)] + return [t for t in tools if not _any_entry_matches(t.name, entries)] + + +_MCP_LOAD_CONCURRENCY = 8 +"""Upper bound on MCP servers preflighted/discovered concurrently. + +Independent servers are probed in parallel so graph load no longer scales +linearly with server count, but the fan-out is capped so a large config cannot +spawn an unbounded number of simultaneous socket/subprocess handshakes (or +`asyncio.to_thread` `shutil.which` workers). +""" + + +def _warm_mcp_adapter_imports() -> None: + """Eagerly import MCP modules whose first import may block. + + Run via `asyncio.to_thread` before adapter/auth symbols are used, so any + blocking side effect of a first import happens off the server event loop + rather than where Blockbuster would reject it. Two known offenders: + + - `langchain_mcp_adapters` runs a package-resource scan on first import. + - `mcp_auth` imports `httpx`, which transitively imports `rich`; `rich` + calls `os.getcwd()` in its module body (verified against the pinned + versions — the exact culprit may shift as dependencies change, but the + general risk of import-time I/O in this subtree does not). + + Warming `mcp_auth` is best-effort: it is only *used* on per-server paths + (remote-server preflight and the per-tool call path), where an import + failure is captured and reported per server. A failure to warm it must not + abort loading for every server — notably stdio-only configs, which never + import `mcp_auth` otherwise — so it is swallowed here and left to re-raise + at the real use site. Runs only when at least one active MCP server exists. + """ + from langchain_mcp_adapters import ( + sessions as _sessions, # noqa: F401 + tools as _tools, # noqa: F401 + ) + + try: + from deepagents_code import mcp_auth as _mcp_auth # noqa: F401 + except Exception: # warmup is a best-effort optimization; never abort load + logger.warning( + "Failed to warm mcp_auth import off the event loop; " + "deferring to per-server use", + exc_info=True, + ) + + +async def _gather_bounded( + factories: Sequence[Callable[[], Awaitable[_T]]], + *, + limit: int, +) -> list[_T]: + """Await coroutine factories with bounded concurrency, preserving order. + + Results are returned in submission order (not completion order), so callers + can zip them back against their inputs to keep deterministic ordering. If a + factory raises (including a cancellation/shutdown signal), the remaining + tasks are cancelled and awaited before the exception propagates, so no + background work is left running. + + `asyncio.gather` propagates only the *first* task to finish with an + exception; when several tasks fail concurrently the rest are cancelled + during teardown and their exceptions would otherwise be discarded silently. + To keep concurrent failures debuggable, each dropped (non-cancellation) + sibling exception is logged at debug level before the first one propagates. + + Args: + factories: Zero-arg callables each returning an awaitable to run. + limit: Maximum number of awaitables in flight at once. Values below 1 + are clamped to 1. + + Returns: + The awaited results in the same order as `factories`. + """ + semaphore = asyncio.Semaphore(max(1, limit)) + + async def _run(factory: Callable[[], Awaitable[_T]]) -> _T: + async with semaphore: + return await factory() + + tasks = [asyncio.create_task(_run(factory)) for factory in factories] + try: + return await asyncio.gather(*tasks) + except BaseException: + for task in tasks: + task.cancel() + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + logger.debug( + "MCP concurrent load: a sibling task failed while another " + "failure was already propagating; logging the dropped " + "exception for debugging", + exc_info=result, + ) + raise + + +async def _load_tools_from_config( + config: dict[str, Any], + *, + stateless: bool = False, + session_manager: MCPSessionManager | None = None, +) -> tuple[list[BaseTool], MCPSessionManager | None, list[MCPServerInfo]]: + """Build MCP connections from a validated config and load tools. + + Discovery always opens throwaway sessions to capture tool metadata only. + Runtime tools either: + + - bind to a caller-managed `session_manager` (server mode), + - bind to a new local `session_manager` returned to the caller, or + - stay fully stateless and open a fresh session per tool call. + + Per-server config/auth/setup failures are captured in the returned + `server_infos` list rather than propagated — one bad server never + hides the others. + + Args: + config: Validated MCP configuration dict with `mcpServers` key. + stateless: When `True`, tools avoid returning an owned session manager. + session_manager: Optional externally owned runtime session manager. + + Returns: + Tuple of `(tools_list, session_manager, server_infos)`. + + Raises: + RuntimeError: If `session_manager` is reconfigured incompatibly with + sessions already active on it. + """ # noqa: DOC502 - `RuntimeError` surfaces via `MCPSessionManager.configure` + # Warm the adapter imports off the event loop *here* (rather than in the + # caller) so a config with no active MCP servers — which returns before + # ever reaching this function — never pays the adapter-import cost. + await asyncio.to_thread(_warm_mcp_adapter_imports) + from langchain_mcp_adapters.sessions import ( + SSEConnection, + StdioConnection, + StreamableHttpConnection, + create_session, + ) + from langchain_mcp_adapters.tools import convert_mcp_tool_to_langchain_tool + + server_items = list(config["mcpServers"].items()) + # Resolve each server's transport once, up front. `_resolve_server_type` is + # pure, so this is a readability/DRY win over recomputing it in preflight, + # discovery, and the final fold-in loop below. + transports = {name: _resolve_server_type(cfg) for name, cfg in server_items} + + async def _preflight_and_connect( + server_name: str, + server_config: dict[str, Any], + ) -> tuple[MCPServerStatus, str] | Connection: + """Preflight one server and build its connection config. + + Per-server preflight/config failures are captured here so one bad + server never aborts loading the others. + + Returns: + A `(status, error)` tuple when the server must be skipped, or a + ready `Connection` otherwise. + """ + server_type = transports[server_name] + # Capture this from the *raw* config, before resolution below rebinds + # `server_config` to the expanded copy. Once `${...}` refs are expanded, + # a downstream setup error may echo the resolved (secret-bearing) value, + # so those messages are redacted; plain configs keep full detail. + redact_failure_details = _config_uses_env_interpolation(server_config) + # Config env-var resolution is the only step that raises `TypeError` + # (non-string field). Keep it in its own `try` so an unexpected + # `TypeError` from the connectivity checks below — whose contract is + # `RuntimeError` only — surfaces as a real bug instead of being + # relabeled as a per-server config skip. + try: + server_config = resolve_mcp_server_env(server_name, server_config) + except (RuntimeError, TypeError) as exc: + logger.warning( + "MCP server '%s' skipped: config error: %s", + server_name, + exc, + ) + return ("error", str(exc)) + try: + if server_type in _SUPPORTED_REMOTE_TYPES: + await _check_remote_server(server_name, server_config) + elif server_type == "stdio": + # `shutil.which` makes blocking `os.access` calls; run it + # off the event loop so blockbuster doesn't reject it. + await asyncio.to_thread(_check_stdio_server, server_name, server_config) + except RuntimeError as exc: + logger.warning( + "MCP server '%s' skipped: pre-flight failed: %s", + server_name, + exc, + ) + return ("error", str(exc)) + + try: + if server_type in _SUPPORTED_REMOTE_TYPES: + if server_type == "http": + conn: Connection = StreamableHttpConnection( + transport="streamable_http", + url=server_config["url"], + ) + else: + conn = SSEConnection( + transport="sse", + url=server_config["url"], + ) + + if "headers" in server_config: + conn["headers"] = server_config["headers"] + + from deepagents_code.mcp_auth import ( + FileTokenStorage, + build_oauth_provider, + ) + + explicit_oauth = server_config.get("auth") == "oauth" + header_names = { + name.lower() for name in (server_config.get("headers") or {}) + } + has_authorization_header = "authorization" in header_names + storage = FileTokenStorage( + server_name, + server_url=server_config["url"], + ) + stored_tokens = await storage.get_tokens() + + if explicit_oauth and stored_tokens is None: + # Config opted into OAuth but no tokens are stored yet — + # require an upfront login before connecting. + auth_msg = f"MCP server {server_name!r} needs re-authentication." + logger.warning( + "MCP server '%s' skipped: not authenticated.", + server_name, + ) + return ("unauthenticated", auth_msg) + + if explicit_oauth or ( + stored_tokens is not None and not has_authorization_header + ): + # Attach the provider when the user opted in, or when a + # prior login (possibly triggered by 401 auto-detection) + # already stored tokens for this server. Static + # Authorization headers take precedence over stored OAuth. + conn["auth"] = build_oauth_provider( + server_name=server_name, + server_url=server_config["url"], + storage=storage, + interactive=False, + ) + + return conn + return StdioConnection( + command=server_config["command"], + args=server_config.get("args", []), + env=server_config.get("env") or None, + transport="stdio", + ) + except (OSError, RuntimeError, TypeError, ValueError) as exc: + if redact_failure_details: + error = ( + f"MCP server {server_name!r}: setup failed after " + "resolving environment variables." + ) + logger.warning( + "MCP server '%s' skipped: config/setup failed (%s; details " + "redacted because config uses environment interpolation)", + server_name, + exc.__class__.__name__, + ) + else: + error = str(exc) + logger.warning( + "MCP server '%s' skipped: config/setup failed", + server_name, + exc_info=exc, + ) + return ("error", error) + + # Preflight + connection build runs concurrently across servers (bounded). + # Results come back in submission order, so `skipped`/`connections` are + # assembled in config order and stay deterministic regardless of which + # server's probe finished first. + preflight_results = await _gather_bounded( + [ + functools.partial(_preflight_and_connect, name, cfg) + for name, cfg in server_items + ], + limit=_MCP_LOAD_CONCURRENCY, + ) + + skipped: dict[str, tuple[MCPServerStatus, str]] = {} + connections: dict[str, Connection] = {} + for (server_name, _server_config), result in zip( + server_items, preflight_results, strict=True + ): + if isinstance(result, tuple): + skipped[server_name] = result + else: + connections[server_name] = result + + runtime_manager: MCPSessionManager | None = session_manager + if runtime_manager is not None: + runtime_manager.configure(connections) + elif not stateless: + runtime_manager = MCPSessionManager(connections=connections) + + async def _discover_server( + server_name: str, + server_config: dict[str, Any], + transport: str, + ) -> tuple[list[BaseTool], MCPServerInfo]: + """Discover one server's tools and build its `MCPServerInfo`. + + Both discovery failures (classified as auth vs. generic error) and + post-discovery tool-construction failures are captured as a non-`ok` + `MCPServerInfo` with no tools, so a single failing server never aborts + the load for the others. Cancellation/shutdown signals are re-raised so + the bounded runner can tear the whole load down. + + Returns: + The server's LangChain tools plus its `MCPServerInfo` entry. + """ # noqa: DOC501 - CancelledError/KeyboardInterrupt/SystemExit are re-raised pass-throughs + redact_failure_details = _config_uses_env_interpolation(server_config) + + def _log_caught_exception( + level: int, + message: str, + caught: BaseException, + ) -> None: + """Log a caught exception without exposing resolved config values.""" + if redact_failure_details: + rendered_message = message % server_name + logger.log( + level, + "%s (%s; details redacted because config uses environment " + "interpolation)", + rendered_message, + caught.__class__.__name__, + ) + else: + logger.log(level, message, server_name, exc_info=caught) + + try: + async with create_session(connections[server_name]) as discover_session: + await discover_session.initialize() + mcp_tools = await _discover_tools(discover_session) + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception as exc: # noqa: BLE001 - isolate third-party discovery failures per server + from deepagents_code.mcp_auth import ( + find_oauth_challenge, + find_reauth_required, + format_login_failure, + ) + + status: MCPServerStatus + try: + reauth = find_reauth_required(exc) + challenge_url = ( + find_oauth_challenge(exc) + if transport in _SUPPORTED_REMOTE_TYPES + else None + ) + except Exception as classify_exc: # noqa: BLE001 - classification must not abort other servers + # Classifying the failure is best-effort. If a classifier + # itself raises, degrade this one server to a plain error + # rather than letting the exception abort tool loading for + # every remaining server. + reauth = None + challenge_url = None + _log_caught_exception( + logging.DEBUG, + "MCP server '%s': failed to classify discovery error", + classify_exc, + ) + + if reauth is not None: + # Tokens existed (we checked above) but the OAuth provider + # fell back to interactive reauth — the refresh attempt + # failed. Flag unauthenticated so the user is prompted to + # re-login. This is an expected, already-classified outcome, so + # the actionable WARNING says everything useful; the full + # traceback adds no diagnostic value, so keep the DEBUG log to a + # concise, token-safe breadcrumb. Use `format_login_failure` + # rather than `exc.__class__.__name__`: these failures usually + # arrive wrapped in an anyio `ExceptionGroup`, so the bare root + # class name would just read "ExceptionGroup"; the helper walks + # the group/cause chain to name the nested culprit instead. + status = "unauthenticated" + error = f"{reauth} (token refresh failed)" + logger.warning( + "MCP server '%s' skipped: %s", + server_name, + error, + ) + logger.debug( + "MCP server '%s' skipped: token refresh failed (%s)", + server_name, + format_login_failure(exc), + ) + elif challenge_url is not None: + # A remote server answered with a 401 OAuth challenge + # (RFC 9728) that wasn't already handled as a token refresh — + # typically a server not opted into OAuth in config. Surface it + # as unauthenticated so the user can log in, rather than as an + # opaque connection error. Like the reauth case, this is a + # recognized outcome: keep the DEBUG log to a concise, + # token-safe breadcrumb (via `format_login_failure`, which + # names the nested culprit inside the anyio `ExceptionGroup`) + # rather than dumping the full challenge traceback. + status = "unauthenticated" + error = ( + f"MCP server {server_name!r} requires authentication; " + f"run `dcode mcp login {server_name}`." + ) + logger.warning( + "MCP server '%s' skipped: %s", + server_name, + error, + ) + logger.debug( + "MCP server '%s' skipped: 401 OAuth challenge detected (%s)", + server_name, + format_login_failure(exc), + ) + else: + status = "error" + error = ( + ( + f"MCP server {server_name!r}: tool discovery failed " + "after resolving environment variables." + ) + if redact_failure_details + else str(exc) + ) + _log_caught_exception( + logging.WARNING, + "MCP server '%s' skipped: tool discovery failed", + exc, + ) + return [], MCPServerInfo( + name=server_name, + transport=transport, + status=status, + error=error, + ) + + # Tool construction and filtering run after the discovery session has + # closed and can still fail (schema conversion, custom tool filters). + # Isolate them too so a construction error degrades this one server to + # an error entry instead of aborting the whole concurrent load — the + # same guarantee the discovery `try` above provides. Cancellation and + # shutdown signals still propagate so the bounded runner can tear down. + try: + if runtime_manager is None: + server_tools: list[BaseTool] = [ + convert_mcp_tool_to_langchain_tool( + None, + mcp_tool, + connection=connections[server_name], + server_name=server_name, + tool_name_prefix=True, + ) + for mcp_tool in mcp_tools + ] + else: + server_tools = [ + _build_cached_mcp_tool( + mcp_tool=mcp_tool, + server_name=server_name, + session_manager=runtime_manager, + tool_name_prefix=True, + ) + for mcp_tool in mcp_tools + ] + + server_tools = _apply_tool_filter(server_tools, server_name, server_config) + + # Pair each tool's input_schema by its LangChain (server-prefixed) + # name — the same form `server_tools` carries — so the lookup needs + # no string surgery and stays correct if `tool_name_prefix` ever + # changes. Deep-copy the raw dict because `MCPToolInfo` is `frozen` + # but Python's `frozen=True` does not freeze nested mutables; a + # shared reference would let one holder mutate every other's view. + schemas: dict[str, dict[str, Any] | None] = {} + for mcp_tool in mcp_tools: + tool_name = getattr(mcp_tool, "name", "") + try: + raw_schema = getattr(mcp_tool, "inputSchema", None) + schema_copy = ( + copy.deepcopy(raw_schema) if raw_schema is not None else None + ) + except (AttributeError, TypeError, RecursionError) as exc: + logger.warning( + "MCP tool %r on server %r: inputSchema access raised " + "%s: %s; rendering with no parameters", + tool_name, + server_name, + exc.__class__.__name__, + exc, + ) + schema_copy = None + lc_name = f"{server_name}_{tool_name}" + schemas[lc_name] = schema_copy + + tool_infos: list[MCPToolInfo] = [] + for tool in server_tools: + schema = schemas.get(tool.name) + if schema is None and schemas: + logger.debug( + "MCP tool %r on server %r: no schema matched in lookup " + "(available keys: %s); rendering with no parameters", + tool.name, + server_name, + list(schemas.keys())[:5], + ) + tool_infos.append( + MCPToolInfo( + name=tool.name, + description=tool.description or "", + input_schema=schema, + ), + ) + except (asyncio.CancelledError, KeyboardInterrupt, SystemExit): + raise + except Exception as exc: # noqa: BLE001 - isolate third-party tool conversion failures per server + error = ( + ( + f"MCP server {server_name!r}: tool construction failed " + "after resolving environment variables." + ) + if redact_failure_details + else str(exc) + ) + _log_caught_exception( + logging.WARNING, + "MCP server '%s' skipped: tool construction failed", + exc, + ) + return [], MCPServerInfo( + name=server_name, + transport=transport, + status="error", + error=error, + ) + + return server_tools, MCPServerInfo( + name=server_name, + transport=transport, + tools=tuple(tool_infos), + ) + + # Discovery also runs concurrently (bounded) across the servers that + # survived preflight. Because `_gather_bounded` returns results in + # submission order and skipped servers are folded back in below by + # iterating `server_items` in config order, `server_infos` stays in config + # order and the returned tools stay sorted by tool name — regardless of + # which server's probe finished first. + discover_items = [ + (server_name, server_config, transports[server_name]) + for server_name, server_config in server_items + if server_name not in skipped + ] + discovery_results = await _gather_bounded( + [ + functools.partial(_discover_server, name, cfg, transport) + for name, cfg, transport in discover_items + ], + limit=_MCP_LOAD_CONCURRENCY, + ) + discovered: dict[str, tuple[list[BaseTool], MCPServerInfo]] = { + server_name: result + for (server_name, _cfg, _transport), result in zip( + discover_items, discovery_results, strict=True + ) + } + + all_tools: list[BaseTool] = [] + server_infos: list[MCPServerInfo] = [] + for server_name, _server_config in server_items: + if server_name in skipped: + status, error = skipped[server_name] + server_infos.append( + MCPServerInfo( + name=server_name, + transport=transports[server_name], + status=status, + error=error, + ), + ) + continue + server_tools, server_info = discovered[server_name] + all_tools.extend(server_tools) + server_infos.append(server_info) + + all_tools.sort(key=lambda tool: tool.name) + return all_tools, None if stateless else runtime_manager, server_infos + + +async def get_mcp_tools( + config_path: str, +) -> tuple[list[BaseTool], MCPSessionManager | None, list[MCPServerInfo]]: + """Load MCP tools from a configuration file. + + Args: + config_path: Path to an MCP config file. + + Returns: + Tuple of `(tools_list, runtime_session_manager, server_infos)`. + + Raises: + FileNotFoundError: If `config_path` doesn't exist. + json.JSONDecodeError: If the config file contains invalid JSON. + TypeError: If config fields have wrong types. + ValueError: If the config is missing required fields. + """ # noqa: DOC502 - surfaced via `load_mcp_config` + config = load_mcp_config(config_path) + return await _load_tools_from_config(config) + + +def _log_skipped_project_servers( + dropped: list[ProjectServerSummary], + *, + trust_project_mcp: bool | None, + config_trusted: bool, +) -> None: + """Log project MCP servers that were dropped, explaining why. + + Split out so the trust/drop loop stays readable. The message distinguishes an + explicit reject on an otherwise-trusted config from the untrusted-drop cases, + which themselves differ by whether trust was declined outright + (`--trust-project-mcp` off) or merely not yet granted. + + Args: + dropped: `ProjectServerSummary` rows for each skipped server. + trust_project_mcp: The caller's tri-state trust flag. + config_trusted: Whether the project config was otherwise trusted (so the + only reason to drop is an explicit user-level deny entry). + """ + skipped_list = "\n".join( + f"- {name} [{kind}]: {summary}" for name, kind, summary in dropped + ) + if config_trusted: + logger.warning( + "Skipped project MCP servers rejected by user config " + "(disabled_project_servers):\n%s", + skipped_list, + ) + elif trust_project_mcp is False: + logger.warning( + "Skipped untrusted project MCP servers:\n%s", + skipped_list, + ) + else: + logger.warning( + "Skipped untrusted project MCP servers " + "(config changed or not yet approved):\n%s", + skipped_list, + ) + + +def _mcp_trust_list_notices( + trust_lists: McpServerTrustLists, +) -> list[tuple[Path, str]]: + """Config-error entries surfacing a trust-list's read/migration problems. + + The loader runs in non-interactive paths where a bare `logger.warning` has + no handler, so these must-see notices are rendered as visible config errors + via `_bad_config_infos`. Returned (rather than appended in place) so a + single trust-list load can surface them once for both the plugin and + project config paths instead of duplicating them per path. + + Args: + trust_lists: The user's loaded allow/deny policy. + + Returns: + `(path, message)` tuples for each detected problem, empty when clean. + """ + from deepagents_code.model_config import DEFAULT_CONFIG_PATH + + notices: list[tuple[Path, str]] = [] + if trust_lists.read_error is not None: + # Surface the read failure as a visible config error (a bare + # logger.warning has no handler outside debug mode). + notices.append((DEFAULT_CONFIG_PATH, trust_lists.read_error)) + if trust_lists.legacy_ignored: + # The removed flat allowlist stops loading these silently; make it + # visible since the loader runs in non-interactive paths where the + # migration warning would otherwise be unseen. + ignored = ", ".join(sorted(trust_lists.legacy_ignored)) + notices.append( + ( + DEFAULT_CONFIG_PATH, + ( + "[mcp].enabled_project_servers is no longer used; " + "re-approve via the project MCP prompt to keep loading: " + f"{ignored}" + ), + ) + ) + if trust_lists.legacy_env_ignored: + # The env var was renamed; make the set-but-ignored old name visible + # so its servers don't silently stop pre-approving. + notices.append( + ( + Path(""), + ( + f"{_env_vars.LEGACY_ENABLED_PROJECT_MCP_SERVERS} is no " + "longer used; it was renamed to " + f"{_env_vars.DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS}" + ), + ) + ) + if trust_lists.malformed_approvals: + # A corrupt saved approval would otherwise just silently re-prompt; + # surface it here (the loader runs in non-interactive paths where a + # bare logger.warning is unseen), mirroring the legacy notices above. + count = trust_lists.malformed_approvals + entry_word = "entry" if count == 1 else "entries" + notices.append( + ( + DEFAULT_CONFIG_PATH, + ( + f"{count} [mcp].enabled_project_server_approvals {entry_word} " + "could not be read and were ignored; re-approve via the " + "project MCP prompt to keep loading affected servers" + ), + ) + ) + return notices + + +async def resolve_and_load_mcp_tools( + *, + explicit_config_path: str | None = None, + no_mcp: bool = False, + trust_project_mcp: bool | None = None, + project_context: ProjectContext | None = None, + additional_configs: tuple[dict[str, Any], ...] = (), + stateless: bool = False, + session_manager: MCPSessionManager | None = None, +) -> tuple[list[BaseTool], MCPSessionManager | None, list[MCPServerInfo]]: + """Resolve MCP config and load tools. + + Auto-discovers configs from standard locations and merges them. When + `explicit_config_path` is provided it is added as the highest-precedence + source and errors in that file are fatal. + + Args: + explicit_config_path: Extra config file to layer on top of + auto-discovered configs. + no_mcp: If `True`, disable all MCP loading. + trust_project_mcp: Controls project-level server trust. + + Applies to stdio and remote (http/sse) servers alike — remote entries + are gated too because an attacker-controlled `.mcp.json` can SSRF or + exfiltrate `${VAR}` headers during the discovery preflight. + + - `True`: grant whole-config trust (all servers load). + - `False` / `None`: no whole-config trust. `None` is treated + identically to `False` — the persistent trust store this once + consulted was removed, so project servers load only via the + user's scoped approvals / env allowlist described below. + + Regardless of this flag, the user-level allow/deny policy + (`[mcp].enabled_project_server_approvals`, + `[mcp].disabled_project_servers`, and env equivalents via + `load_mcp_server_trust_lists`) is applied: scoped approvals load + from an otherwise-untrusted config only when the project root and + server fingerprint match, and explicitly denied servers are dropped + even from a trusted one. + project_context: Explicit project path context for config discovery + and trust resolution. + additional_configs: Config layers injected by higher-level composition, + such as plugin-provided MCP servers. Installing a plugin is treated + as the user's trust decision for its bundled servers, so these load + without per-server approval — but the user-level deny policy still + applies (an explicitly disabled server stays disabled), and if that + policy cannot be read the servers fail closed rather than bypass a + saved rejection. A malformed layer (non-dict, or a non-mapping + `mcpServers`) is skipped and surfaced as a config error. + stateless: When `True`, do not return an owned runtime session manager. + session_manager: Optional externally owned runtime session manager. + + Returns: + Tuple of `(tools_list, session_manager, server_infos)`. + + Raises: + FileNotFoundError: If `explicit_config_path` was provided and points + at a missing file. + json.JSONDecodeError: If `explicit_config_path` contains invalid + JSON. + TypeError: If `explicit_config_path` contents have wrong field + types. + ValueError: If `explicit_config_path` is missing required fields + or declares an unsupported transport. + RuntimeError: If the merged MCP config is malformed. (`${VAR}` + config interpolation is deferred to activation inside + `_load_tools_from_config`, which captures such failures into the + returned `server_infos` rather than raising here.) + """ # noqa: DOC502 - FileNotFoundError / JSONDecodeError / TypeError / ValueError surface via `load_mcp_config` + if no_mcp: + return [], None, [] + + config_load_errors: list[tuple[Path, str]] = [] + + try: + config_paths = discover_mcp_configs(project_context=project_context) + except (OSError, RuntimeError) as exc: + logger.warning("MCP config auto-discovery failed", exc_info=True) + config_paths = [] + config_load_errors.append((Path(""), str(exc))) + + user_configs, project_configs = classify_discovered_configs(config_paths) + configs: list[dict[str, Any]] = [] + + for path in user_configs: + config, error = load_mcp_config_with_error(path) + if error is not None: + config_load_errors.append((path, error)) + if config is not None: + configs.append(config) + + # The user-level allow/deny policy (home config.toml + env) gates both + # plugin-provided and project `.mcp.json` servers. Load it once — and + # surface its read/migration notices once — so a plugin-only session and a + # project session behave identically and a read error is not reported + # twice. Sourced only from the user's own config (never the repo), so a + # committed `.mcp.json` cannot self-approve. Loaded lazily: skipped when + # there is neither a plugin layer nor a discovered project config to gate. + trust_lists: McpServerTrustLists | None = None + if additional_configs or project_configs: + from deepagents_code.model_config import load_mcp_server_trust_lists + + trust_lists = load_mcp_server_trust_lists() + config_load_errors.extend(_mcp_trust_list_notices(trust_lists)) + + # Installing a plugin is the user's trust decision for every bundled + # component, including MCP servers. Still apply the user-level deny policy + # so an explicitly disabled server stays disabled. If that policy cannot be + # read, fail closed rather than potentially bypass a saved rejection. The + # `trust_lists is not None` guard holds whenever `additional_configs` is + # non-empty (the load above ran); it only narrows the type. + if additional_configs and trust_lists is not None: + plugin_project_root = _resolve_project_config_base(project_context) + for plugin_config in additional_configs: + if not isinstance(plugin_config, dict): + continue + plugin_servers = plugin_config.get("mcpServers") + if plugin_servers is None or ( + isinstance(plugin_servers, dict) and not plugin_servers + ): + # No servers to contribute; nothing to trust-filter. + continue + if not isinstance(plugin_servers, dict): + # A present-but-malformed `mcpServers` (e.g. a list or string) + # is a plugin authoring mistake; surface it instead of dropping + # it silently, mirroring how project configs report bad shapes. + config_load_errors.append( + ( + Path(""), + ( + "plugin 'mcpServers' must be a mapping of name to " + "server definition, got " + f"{type(plugin_servers).__name__}" + ), + ) + ) + continue + plugin_kept = filter_trusted_project_servers( + plugin_servers, + trust_lists, + project_root=plugin_project_root, + config_trusted=not trust_lists.load_failed, + ) + plugin_dropped = [ + name for name in plugin_servers if name not in plugin_kept + ] + if plugin_dropped: + logger.warning( + "Skipped plugin MCP servers denied by an explicit disable or " + "an unreadable trust policy: %s", + ", ".join(sorted(plugin_dropped)), + ) + if plugin_kept: + configs.append({**plugin_config, "mcpServers": plugin_kept}) + + loaded_project_configs: list[tuple[Path, dict[str, Any]]] = [] + + for path in project_configs: + config, error = _load_mcp_config_top_level_with_error(path) + if error is not None: + config_load_errors.append((path, error)) + if config is not None: + loaded_project_configs.append((path, config)) + + if loaded_project_configs and trust_lists is not None: + # `trust_lists` was loaded above because `project_configs` is non-empty + # here; the `is not None` guard only narrows the type. Its read/migration + # notices were already surfaced once at the shared load site. + project_config, server_sources = _merge_mcp_configs_with_sources( + loaded_project_configs + ) + project_servers = extract_project_server_summaries(project_config) + + # Whole-config trust comes only from the flag (`--trust-project-mcp` + # or the interactive approval prompt's decision). Without it, servers + # load solely via the user's scoped approvals below. + config_trusted = trust_project_mcp is True + + if trust_lists.load_failed: + # Fail closed: the user's allow/deny policy could not be read, + # so do not honor whole-config trust. Env-enabled names still + # survive because the trust-list loader discards scoped + # approvals when it records a read error. + config_trusted = False + + # Resolve precedence before trust. If a higher-precedence file changes + # an approved server, rejecting that winning definition must not reveal + # the stale approved definition beneath it. Every server — even a + # malformed one — passes through the trust filter, so no entry can reach + # `configs` without a trust decision (defense in depth against a future + # validator that accepts a shape `extract_project_server_summaries` + # currently skips). + project_base = _resolve_project_config_base(project_context) + kept: dict[str, Any] = {} + for name, server in project_config["mcpServers"].items(): + source = server_sources[name] + project_root = project_root_for_mcp_config_path( + source, fallback=project_base + ) + kept.update( + filter_trusted_project_servers( + {name: server}, + trust_lists, + project_root=project_root, + config_trusted=config_trusted, + ) + ) + + if kept: + filtered = {**project_config, "mcpServers": kept} + valid, errors = _drop_invalid_mcp_config_servers(filtered) + for name, error in errors.items(): + logger.warning( + "Skipping invalid trusted project MCP server %r: %s", + name, + error, + ) + config_load_errors.append((server_sources[name], error)) + if valid["mcpServers"]: + configs.append(valid) + elif not project_servers: + # Nothing was trusted and no dict server produced a summary, so + # every entry is malformed. Re-validate the merged config (no second + # file read) to surface a precise per-server error instead of + # dropping the file silently. + try: + _validate_mcp_config_servers(project_config) + except (ValueError, TypeError, RuntimeError) as exc: + config_load_errors.append((loaded_project_configs[-1][0], str(exc))) + + # Servers dropped by the trust decision are logged only after + # precedence resolution, so shadowed definitions cannot be reported + # or loaded as if they were still active. + dropped = [summary for summary in project_servers if summary.name not in kept] + if dropped: + _log_skipped_project_servers( + dropped, + trust_project_mcp=trust_project_mcp, + config_trusted=config_trusted, + ) + + if explicit_config_path: + config_path = ( + str(project_context.resolve_user_path(explicit_config_path)) + if project_context is not None + else explicit_config_path + ) + configs.append(load_mcp_config(config_path)) + + def _bad_config_infos() -> list[MCPServerInfo]: + return [ + MCPServerInfo( + name=f"", + transport="config", + status="error", + error=f"{path}: {error}", + ) + for path, error in config_load_errors + ] + + if not configs: + return [], None, _bad_config_infos() + + merged = merge_mcp_configs(configs) + if not merged.get("mcpServers"): + return [], None, _bad_config_infos() + + from deepagents_code.mcp_disabled import get_disabled_servers + + disabled_names = get_disabled_servers() + disabled_infos: list[MCPServerInfo] = [] + if disabled_names: + active: dict[str, Any] = {} + for server_name, server_config in merged["mcpServers"].items(): + if server_name in disabled_names: + disabled_infos.append( + MCPServerInfo( + name=server_name, + transport=_resolve_server_type(server_config) + if isinstance(server_config, dict) + else "unknown", + status="disabled", + error="Disabled by user (F2 to re-enable).", + ), + ) + else: + active[server_name] = server_config + merged = {"mcpServers": active} + + if not merged.get("mcpServers"): + return [], None, disabled_infos + _bad_config_infos() + + try: + for server_name, server_config in merged["mcpServers"].items(): + _validate_server_config(server_name, server_config) + except (TypeError, ValueError, RuntimeError) as exc: + msg = f"Invalid MCP server configuration: {exc}" + raise RuntimeError(msg) from exc + + tools, manager, server_infos = await _load_tools_from_config( + merged, + stateless=stateless, + session_manager=session_manager, + ) + server_infos.extend(disabled_infos) + server_infos.extend(_bad_config_infos()) + return tools, manager, server_infos diff --git a/libs/code/deepagents_code/media_utils.py b/libs/code/deepagents_code/media_utils.py new file mode 100644 index 0000000000..e49b61cae2 --- /dev/null +++ b/libs/code/deepagents_code/media_utils.py @@ -0,0 +1,635 @@ +"""Utilities for handling image and video media from clipboard and files.""" + +import base64 +import io +import logging +import os +import pathlib +import re +import shutil + +# S404: subprocess needed for clipboard access via pngpaste/osascript +import subprocess # noqa: S404 +import sys +import tempfile +from collections import Counter +from collections.abc import Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from langchain_core.messages.content import VideoContentBlock + +logger = logging.getLogger(__name__) + +IMAGE_EXTENSIONS: frozenset[str] = frozenset( + { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".tiff", + ".tif", + ".webp", + ".ico", + } +) +"""Common image file extensions supported by PIL.""" + +VIDEO_EXTENSIONS: frozenset[str] = frozenset( + { + ".mp4", + ".mov", + ".avi", + ".webm", + ".m4v", + ".wmv", + } +) +"""Video file extensions with validated magic-byte support.""" + +MAX_MEDIA_BYTES: int = 20 * 1024 * 1024 +"""Maximum media file size (20 MB). Keeps base64 payload under ~27 MB.""" + + +def strip_media_placeholders( + text: str, + placeholders: Iterable[str], + *, + placeholder_spans: Iterable[tuple[int, int]] | None = None, +) -> str: + """Remove display-only media placeholders from user text. + + Placeholders like `[image 1]` are inserted into the terminal input purely for + display; the actual media travels as structured content blocks. They must not + leak into the canonical model-facing message or LangSmith trace as if the user + typed them. + + When available, tracked placeholder spans identify the exact display tokens to + strip so user-authored literal duplicates with the same token are preserved. + The token fallback removes one matching occurrence per tracked media item for + callers that only have placeholder text. + + Args: + text: Raw user text that may contain media placeholders. + placeholders: Exact placeholder tokens for the media actually attached to + this message (e.g. ``["[image 1]", "[video 1]"]``). + placeholder_spans: Exact `(start, end)` spans for tracked display tokens + in `text`, when known. + + Returns: + Text with the given media placeholders removed and surrounding whitespace + tidied. Newlines are preserved so multi-line prompts keep their structure. + Returns an empty string when only whitespace remains after removal, so + callers can treat a placeholder-only message as having no text block. + """ + tokens = [p for p in placeholders if p] + if not tokens: + return text + + valid_spans = _valid_placeholder_spans(text, tokens, placeholder_spans) + spans = [(start, end) for start, end, _token in valid_spans] + counts = Counter(tokens) + for _start, _end, token in valid_spans: + counts[token] -= 1 + for token, count in counts.items(): + if count <= 0: + continue + pattern = re.compile(r"[ \t]*" + re.escape(token)) + matches = list(pattern.finditer(text)) + if len(matches) > count: + # No (or too few) tracked spans, yet the token appears more times + # than we intend to strip: we can't tell the display token from a + # user-typed literal and fall back to removing the leading + # occurrence(s). That guess can strip a literal and leave the real + # display token behind, so leave a breadcrumb (never the text). + logger.debug( + "Ambiguous media placeholder strip: removing %d of %d " + "occurrences of %r with no tracked span to disambiguate", + count, + len(matches), + token, + ) + for index, match in enumerate(matches): + if index >= count: + break + spans.append(match.span()) + + # Only strip spaces/tabs (not newlines) so code indentation on lines after a + # removed placeholder is preserved. A full .strip() would collapse + # "[image 1]\n def foo():" to "def foo():", losing the leading indent. + cleaned = text + for start, end in sorted(spans, reverse=True): + cleaned = cleaned[:start] + cleaned[end:] + cleaned = cleaned.strip(" \t") + return cleaned if cleaned.strip() else "" + + +def _valid_placeholder_spans( + text: str, + tokens: list[str], + spans: Iterable[tuple[int, int]] | None, +) -> list[tuple[int, int, str]]: + """Return valid display placeholder spans expanded over adjacent padding. + + A span is kept only when it is in range and its slice equals one of the + bound tokens, so a stale span (e.g. left by an offset shift) is dropped + rather than used to delete arbitrary text; the caller then falls back to + token matching for that item. + + Args: + text: Text the spans index into. + tokens: Bound placeholder tokens for the attached media. + spans: Candidate `(start, end)` display-token spans, or `None`. + + Returns: + `(start, end, token)` triples with `start` expanded left over any + adjacent spaces/tabs, for spans that validate against `text`. + """ + if spans is None: + return [] + + token_set = set(tokens) + valid: list[tuple[int, int, str]] = [] + for start, end in spans: + if not (0 <= start < end <= len(text)): + continue + token = text[start:end] + if token not in token_set: + continue + expanded_start = start + while expanded_start > 0 and text[expanded_start - 1] in " \t": + expanded_start -= 1 + valid.append((expanded_start, end, token)) + return valid + + +def _get_executable(name: str) -> str | None: + """Get full path to an executable using shutil.which(). + + Args: + name: Name of the executable to find + + Returns: + Full path to executable, or None if not found. + """ + return shutil.which(name) + + +@dataclass +class ImageData: + """Represents a pasted image with its base64 encoding.""" + + base64_data: str + format: str # "png", "jpeg", etc. + placeholder: str # Display text like "[image 1]" + placeholder_span: tuple[int, int] | None = None + + def to_message_content(self) -> dict: + """Convert to LangChain message content format. + + Returns: + Dict with type and image_url for multimodal messages. + """ + return { + "type": "image_url", + "image_url": {"url": f"data:image/{self.format};base64,{self.base64_data}"}, + } + + +@dataclass +class VideoData: + """Represents a pasted video with its base64 encoding.""" + + base64_data: str + format: str # "mp4", "quicktime", etc. + placeholder: str # Display text like "[video 1]" + placeholder_span: tuple[int, int] | None = None + + def to_message_content(self) -> "VideoContentBlock": + """Convert to LangChain `VideoContentBlock` format. + + Returns: + `VideoContentBlock` with base64 data and mime_type. + """ + from langchain_core.messages.content import create_video_block + + return create_video_block( + base64=self.base64_data, + mime_type=f"video/{self.format}", + ) + + +def get_clipboard_image() -> ImageData | None: + """Attempt to read an image from the system clipboard. + + Supports macOS via `pngpaste` or `osascript`. + + Returns: + ImageData if an image is found, None otherwise. + """ + if sys.platform == "darwin": + return _get_macos_clipboard_image() + logger.warning( + "Clipboard image paste is not supported on %s. " + "Only macOS is currently supported. " + "You can still attach images by dragging and dropping file paths.", + sys.platform, + ) + return None + + +def get_image_from_path(path: pathlib.Path) -> ImageData | None: + """Read and encode an image file from disk. + + Args: + path: Path to the image file. + + Returns: + `ImageData` when the file is a valid image, otherwise `None`. + """ + from PIL import Image, UnidentifiedImageError + + try: + file_size = path.stat().st_size + if file_size == 0: + logger.debug("Image file is empty: %s", path) + return None + if file_size > MAX_MEDIA_BYTES: + logger.warning( + "Image file %s is too large (%d MB, max %d MB)", + path, + file_size // (1024 * 1024), + MAX_MEDIA_BYTES // (1024 * 1024), + ) + return None + + image_bytes = path.read_bytes() + if not image_bytes: + return None + + with Image.open(io.BytesIO(image_bytes)) as image: + image_format = (image.format or "").lower() + + if image_format == "jpg": + image_format = "jpeg" + if not image_format: + suffix = path.suffix.lower().removeprefix(".") + image_format = "jpeg" if suffix == "jpg" else suffix + if not image_format: + image_format = "png" + + return ImageData( + base64_data=encode_to_base64(image_bytes), + format=image_format, + placeholder="[image]", + ) + except (UnidentifiedImageError, OSError) as e: + logger.debug("Failed to load image from %s: %s", path, e, exc_info=True) + return None + + +def _detect_video_format(data: bytes) -> str | None: + """Detect video MIME subtype from magic bytes. + + Args: + data: Raw file bytes (at least 12 bytes for reliable detection). + + Returns: + MIME subtype (e.g. "mp4", "webm") or `None` if unrecognized. + """ + min_avi_len = 12 + if data[4:8] == b"ftyp": + # ftyp box: major brand at bytes 8-12 distinguishes MOV vs MP4 + brand = data[8:12] + if brand == b"qt ": + return "quicktime" + return "mp4" + if data[:4] == b"RIFF" and len(data) >= min_avi_len and data[8:12] == b"AVI ": + return "avi" + if data[:4] == b"\x30\x26\xb2\x75": # ASF/WMV + return "x-ms-wmv" + if data[:4] == b"\x1a\x45\xdf\xa3": # WebM/Matroska (EBML header) + return "webm" + return None + + +def get_video_from_path(path: pathlib.Path) -> VideoData | None: + """Read and encode a video file from disk. + + Args: + path: Path to the video file. + + Returns: + `VideoData` when the file is a valid video, otherwise `None`. + """ + suffix = path.suffix.lower() + if suffix not in VIDEO_EXTENSIONS: + return None + + try: + file_size = path.stat().st_size + if file_size == 0: + logger.debug("Video file is empty: %s", path) + return None + if file_size > MAX_MEDIA_BYTES: + logger.warning( + "Video file %s is too large (%d MB, max %d MB)", + path, + file_size // (1024 * 1024), + MAX_MEDIA_BYTES // (1024 * 1024), + ) + return None + + video_bytes = path.read_bytes() + + # Validate it's a real video file by checking magic bytes + # MP4 starts with ftyp, MOV also uses ftyp, AVI starts with RIFF + min_video_len = 8 + if len(video_bytes) < min_video_len: + logger.debug("Video file too small (%d bytes): %s", len(video_bytes), path) + return None + + # Detect format from magic bytes (not extension) so renamed files + # get the correct MIME type. + detected_format = _detect_video_format(video_bytes) + if detected_format is None: + logger.warning( + "Video file %s has unrecognized signature for extension '%s'; " + "skipping. If this is a valid video, the format may not be " + "supported yet.", + path, + suffix, + ) + return None + + return VideoData( + base64_data=encode_to_base64(video_bytes), + format=detected_format, + placeholder="[video]", + ) + except OSError as e: + logger.warning("Failed to load video from %s: %s", path, e, exc_info=True) + return None + + +def is_media_path(path: pathlib.Path) -> bool: + """Return whether a path's extension is a known image or video extension. + + This is a cheap, extension-only check (no file read or decode). Use it to + classify a dropped file path as media without loading it, e.g. to reject a + dragged image in a text-only input. + + It is a heuristic, not a support check, and disagrees with the loaders in + both directions: `get_image_from_path` accepts anything Pillow can decode + regardless of extension, while `get_video_from_path` additionally requires a + recognized magic-byte signature. Call `get_media_from_path` when the answer + must match what can actually be attached. + + Args: + path: Path whose suffix is inspected. + + Returns: + `True` when the suffix is a known image or video extension. + """ + suffix = path.suffix.lower() + return suffix in IMAGE_EXTENSIONS or suffix in VIDEO_EXTENSIONS + + +def get_media_from_path(path: pathlib.Path) -> ImageData | VideoData | None: + """Try to load a file as an image first, then as a video. + + Args: + path: Path to the media file. + + Returns: + `ImageData` or `VideoData` if the file is valid media, otherwise `None`. + """ + result: ImageData | VideoData | None = get_image_from_path(path) + if result is not None: + return result + return get_video_from_path(path) + + +def _get_macos_clipboard_image() -> ImageData | None: + """Get clipboard image on macOS using pngpaste or osascript. + + First tries pngpaste (faster if installed), then falls back to osascript. + + Returns: + ImageData if an image is found, None otherwise. + """ + from PIL import Image, UnidentifiedImageError + + # Try pngpaste first (fast if installed) + pngpaste_path = _get_executable("pngpaste") + if pngpaste_path: + try: + # S603: pngpaste_path is validated via shutil.which(), args are hardcoded + result = subprocess.run( # noqa: S603 + [pngpaste_path, "-"], + capture_output=True, + check=False, + timeout=2, + ) + if result.returncode == 0 and result.stdout: + # Successfully got PNG data - validate it's a real image + try: + Image.open(io.BytesIO(result.stdout)) + base64_data = base64.b64encode(result.stdout).decode("utf-8") + return ImageData( + base64_data=base64_data, + format="png", # 'pngpaste -' always outputs PNG + placeholder="[image]", + ) + except ( + # UnidentifiedImageError: corrupted or non-image data + UnidentifiedImageError, + OSError, # OSError: I/O errors during image processing + ) as e: + logger.debug( + "Invalid image data from pngpaste: %s", e, exc_info=True + ) + except FileNotFoundError: + # pngpaste not installed - expected on systems without it + logger.debug("pngpaste not found, falling back to osascript") + except subprocess.TimeoutExpired: + logger.debug("pngpaste timed out after 2 seconds") + + # Fallback to osascript with temp file (built-in but slower) + return _get_clipboard_via_osascript() + + +def _get_clipboard_via_osascript() -> ImageData | None: + """Get clipboard image via osascript using a temp file. + + osascript outputs data in a special format that can't be captured as raw binary, + so we write to a temp file instead. + + Returns: + ImageData if an image is found, None otherwise. + """ + from PIL import Image, UnidentifiedImageError + + # Get osascript path - it's a macOS builtin so should always exist + osascript_path = _get_executable("osascript") + if not osascript_path: + return None + + # Create a temp file for the image + fd, temp_path = tempfile.mkstemp(suffix=".png") + os.close(fd) + + try: + # First check if clipboard has PNG data + # S603: osascript_path is validated via shutil.which(), args are hardcoded + check_result = subprocess.run( # noqa: S603 + [osascript_path, "-e", "clipboard info"], + capture_output=True, + check=False, + timeout=2, + text=True, + ) + + if check_result.returncode != 0: + return None + + # Check for PNG or TIFF in clipboard info + clipboard_info = check_result.stdout.lower() + if "pngf" not in clipboard_info and "tiff" not in clipboard_info: + return None + + # Try to get PNG first, fall back to TIFF + if "pngf" in clipboard_info: + get_script = f""" + set pngData to the clipboard as «class PNGf» + set theFile to open for access POSIX file "{temp_path}" with write permission + write pngData to theFile + close access theFile + return "success" + """ # noqa: E501 + else: + get_script = f""" + set tiffData to the clipboard as TIFF picture + set theFile to open for access POSIX file "{temp_path}" with write permission + write tiffData to theFile + close access theFile + return "success" + """ # noqa: E501 + + # S603: osascript_path validated via shutil.which(), script is internal + result = subprocess.run( # noqa: S603 + [osascript_path, "-e", get_script], + capture_output=True, + check=False, + timeout=3, + text=True, + ) + + if result.returncode != 0 or "success" not in result.stdout: + return None + + # Check if file was created and has content + if ( + not pathlib.Path(temp_path).exists() + or pathlib.Path(temp_path).stat().st_size == 0 + ): + return None + + # Read and validate the image + image_data = pathlib.Path(temp_path).read_bytes() + + try: + image = Image.open(io.BytesIO(image_data)) + # Convert to PNG if it's not already (e.g., if we got TIFF) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + buffer.seek(0) + base64_data = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return ImageData( + base64_data=base64_data, + format="png", + placeholder="[image]", + ) + except ( + # UnidentifiedImageError: corrupted or non-image data + UnidentifiedImageError, + OSError, # OSError: I/O errors during image processing + ) as e: + logger.debug( + "Failed to process clipboard image via osascript: %s", e, exc_info=True + ) + return None + + except subprocess.TimeoutExpired: + logger.debug("osascript timed out while accessing clipboard") + return None + except OSError as e: + logger.debug("OSError accessing clipboard via osascript: %s", e) + return None + finally: + # Clean up temp file + try: + pathlib.Path(temp_path).unlink() + except OSError as e: + logger.debug("Failed to clean up temp file %s: %s", temp_path, e) + + +def encode_to_base64(data: bytes) -> str: + """Encode raw bytes to a base64 string. + + Args: + data: Raw bytes to encode. + + Returns: + Base64-encoded string. + """ + return base64.b64encode(data).decode("utf-8") + + +def create_multimodal_content( + text: str, images: list[ImageData], videos: list[VideoData] | None = None +) -> list[Any]: + """Create multimodal message content with text, images, and videos. + + Args: + text: Text content of the message + images: List of ImageData objects + videos: Optional list of VideoData objects + + Returns: + List of content blocks in LangChain message format. + """ + content_blocks = [] + + # Add text block. Strip only the display-only placeholders bound to the media + # actually attached here (e.g. "[image 1]") so the canonical/model-facing text + # never contains fake user-authored placeholder text. When a span is known, + # text that merely resembles the schema is preserved exactly; without a span + # `strip_media_placeholders` falls back to removing one occurrence per item, + # which can catch a look-alike literal. The media itself is carried by the + # structured blocks below. + # + # `placeholders` and `spans` are passed as parallel-but-unzipped lists on + # purpose: `strip_media_placeholders` recovers each span's token from the + # text slice, and each tracked media item has a unique token, so it never + # needs index alignment between the two. + media = [*images, *(videos or [])] + placeholders = [item.placeholder for item in media] + spans = [ + item.placeholder_span for item in media if item.placeholder_span is not None + ] + clean_text = strip_media_placeholders(text, placeholders, placeholder_spans=spans) + if clean_text: + content_blocks.append({"type": "text", "text": clean_text}) + + # Add image blocks + content_blocks.extend(image.to_message_content() for image in images) + + # Add video blocks + if videos: + content_blocks.extend(video.to_message_content() for video in videos) + + return content_blocks diff --git a/libs/code/deepagents_code/memory_guard.py b/libs/code/deepagents_code/memory_guard.py new file mode 100644 index 0000000000..49f59904bf --- /dev/null +++ b/libs/code/deepagents_code/memory_guard.py @@ -0,0 +1,474 @@ +"""Protect machine-managed memory blocks from agent edits. + +The onboarding flow writes the user's preferred name into the user `AGENTS.md` +inside a marker-delimited block (see `onboarding.ONBOARDING_NAME_MEMORY_START` / +`ONBOARDING_NAME_MEMORY_END`). `MemoryMiddleware` strips HTML comments before +injecting memory, so the model never sees those markers and has no way to know +the region is off-limits. Since the same prompt tells the model to `edit_file` +that file to persist learnings, nothing stops it from rewriting the managed +block. + +This middleware intercepts `write_file`/`edit_file` calls targeting the guarded +file(s), and `delete` calls that would remove them. When a write or edit would +change or remove the managed block, the model's other edits are kept (though +surrounding whitespace may be normalized, and a fully removed block is +re-appended rather than restored in place) while the managed block is restored, +and an error is returned so the model learns the region is machine-managed. A +`delete` call that would remove an existing managed block is rejected before the +tool runs; a `delete` of a guarded file that exists but cannot be read is also +rejected, failing closed rather than removing a file we cannot inspect. When the +block was altered but the restore could not be completed, an error is still +returned so the failure is never silent. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from difflib import SequenceMatcher +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from langchain.agents.middleware.types import AgentMiddleware +from langchain_core.messages import ToolMessage + +from deepagents_code.onboarding import ( + _upsert_onboarding_name_memory, + extract_onboarding_name_block, + strip_onboarding_name_markers, +) + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Iterable + + from langgraph.prebuilt.tool_node import ToolCallRequest + from langgraph.types import Command + +logger = logging.getLogger(__name__) + +_GUARDED_TOOLS: frozenset[str] = frozenset({"write_file", "edit_file", "delete"}) +"""Tool names whose calls can mutate a guarded file and must be inspected.""" + +_REJECTION_MESSAGE = ( + "The region between the `deepagents:onboarding-name:start` and " + "`deepagents:onboarding-name:end` markers in {path} is machine-managed and " + "must not be edited. Your other changes to the file were kept, but the " + "managed block was restored to its previous content. Do not modify content " + "between those markers." +) +"""Error returned when a managed-block edit was reverted (`{path}` formatted in).""" + +_RESTORE_FAILED_MESSAGE = ( + "The region between the `deepagents:onboarding-name:start` and " + "`deepagents:onboarding-name:end` markers in {path} is machine-managed and " + "must not be edited. Your edit changed it and the previous content could " + "not be restored, so the managed block may now be corrupted. Do not modify " + "content between those markers, and do not rely on this edit having " + "succeeded." +) +"""Error returned when a managed-block edit could not be reverted.""" + +_DELETE_REJECTION_MESSAGE = ( + "The guarded memory file {path} contains a machine-managed region between " + "the `deepagents:onboarding-name:start` and `deepagents:onboarding-name:end` " + "markers and must not be deleted. Do not delete this file or a parent " + "directory that contains it." +) +"""Error returned when a delete would remove a managed memory block.""" + + +class _RestoreOutcome(Enum): + """Result of attempting to restore a managed block after a tool call.""" + + UNCHANGED = "unchanged" + """The managed block was not altered; nothing to restore.""" + + RESTORED = "restored" + """The managed block was altered and successfully restored.""" + + FAILED = "failed" + """The managed block was altered but could not be restored.""" + + +class ManagedMemoryGuardMiddleware(AgentMiddleware): + """Revert agent edits to the managed onboarding-name memory block. + + Guards the managed onboarding-name block in a fixed set of memory files. A + `write_file`/`edit_file` that leaves the managed block untouched passes + through; one that alters or drops it has the block restored (other edits + kept) and returns an error. A `delete` targeting a guarded file (or a parent + directory that contains one) is rejected outright before the tool runs when + the file holds a managed block or exists but cannot be read. If the restore + itself fails, an error is still returned so the failure is never silent. + """ + + def __init__(self, guarded_paths: Iterable[str | Path]) -> None: + """Initialize the guard with the memory files to protect. + + Args: + guarded_paths: Paths whose managed onboarding-name block must be + protected from agent edits. Resolved to absolute form for + matching; unresolvable entries are skipped. + """ + super().__init__() + requested = list(guarded_paths) + resolved: set[Path] = set() + for raw in requested: + try: + resolved.add(Path(raw).expanduser().resolve()) + except (OSError, RuntimeError, ValueError): + logger.warning( + "Could not resolve guarded memory path %r", raw, exc_info=True + ) + self._guarded: frozenset[Path] = frozenset(resolved) + if requested and not self._guarded: + # Every configured path failed to resolve, so this guard now + # protects nothing. That nullifies an integrity control, so surface + # it loudly rather than letting protection silently disappear. + logger.error( + "ManagedMemoryGuardMiddleware resolved no guarded paths from %r; " + "managed memory-block protection is disabled", + requested, + ) + + def _guarded_path(self, request: ToolCallRequest) -> Path | None: + """Return the resolved guarded path targeted by the call, if any. + + Returns: + The matching guarded `Path`, or `None` when the call is unrelated. + """ + tool_name = request.tool_call["name"] + if tool_name not in _GUARDED_TOOLS: + return None + args = request.tool_call.get("args") or {} + file_path = args.get("file_path") + if not isinstance(file_path, str) or not file_path: + return None + try: + resolved = Path(file_path).expanduser().resolve() + except (OSError, RuntimeError, ValueError): + # A guarded-tool call whose path won't resolve could be an attempt + # to slip past the set-membership match, so leave a trail. + logger.warning( + "Could not resolve target path %r for %s", + file_path, + tool_name, + exc_info=True, + ) + return None + if tool_name == "delete": + # `is_relative_to` is True when the guarded file is the delete + # target itself or lives under a directory being deleted. + for guarded in self._guarded: + if guarded.is_relative_to(resolved): + return guarded + return None + return resolved if resolved in self._guarded else None + + @staticmethod + def _read(path: Path) -> str | None: + """Read `path` as UTF-8, returning `None` on failure. + + Returns: + File content, or `None` when the file is missing or unreadable. + """ + try: + fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + with os.fdopen(fd, "r", encoding="utf-8") as f: + return f.read() + except FileNotFoundError: + # Expected when the guarded file has not been created yet. + return None + except (OSError, UnicodeDecodeError): + # An existing-but-unreadable guarded file would otherwise silently + # disable protection for this call, so make it visible. + logger.warning("Could not read guarded memory file %s", path, exc_info=True) + return None + + @staticmethod + def _write(path: Path, content: str) -> None: + """Write `content` to `path` without following symlinks.""" + flags = os.O_WRONLY | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + fd = os.open(path, flags) + with os.fdopen(fd, "w", encoding="utf-8", newline="") as f: + f.write(content) + + @staticmethod + def _line_range_for_block(before: str, before_block: str) -> tuple[int, int] | None: + """Return the line range occupied by `before_block` in `before`. + + Returns: + A `(start, end)` line range, or `None` when the block is absent. + """ + block_start = before.find(before_block) + if block_start == -1: + return None + block_end = block_start + len(before_block) + start_line: int | None = None + end_line: int | None = None + offset = 0 + for line_number, line in enumerate(before.splitlines(keepends=True)): + line_end = offset + len(line) + if start_line is None and offset <= block_start < line_end: + start_line = line_number + if offset < block_end <= line_end: + end_line = line_number + 1 + break + offset = line_end + if start_line is None or end_line is None: + return None + return start_line, end_line + + @staticmethod + def _without_managed_block_edits( + before: str, after: str, before_block: str + ) -> str | None: + """Remove post-edit lines that originated from the managed block. + + Returns: + `after` with lines mapped from `before_block` removed, or `None` when + the old block cannot be located in `before`. + """ + block_range = ManagedMemoryGuardMiddleware._line_range_for_block( + before, before_block + ) + if block_range is None: + return None + block_start, block_end = block_range + # Use line-level matching so a damaged marker cannot leave the old + # managed memory body behind as regular user-editable memory. + before_lines = before.splitlines(keepends=True) + after_lines = after.splitlines(keepends=True) + ranges: list[tuple[int, int]] = [] + matcher = SequenceMatcher(None, before_lines, after_lines, autojunk=False) + for ( + tag, + before_start, + before_end, + after_start, + after_end, + ) in matcher.get_opcodes(): + overlaps = before_start < block_end and block_start < before_end + if tag == "insert": + if block_start < before_start < block_end: + ranges.append((after_start, after_end)) + continue + if not overlaps or tag == "delete": + continue + if tag == "equal": + start = max(before_start, block_start) + end = min(before_end, block_end) + ranges.append( + ( + after_start + start - before_start, + after_start + end - before_start, + ) + ) + else: + ranges.append((after_start, after_end)) + + if not ranges: + return after + parts: list[str] = [] + cursor = 0 + for start, end in sorted(ranges): + range_start = start + range_end = end + if range_start < cursor: + range_end = max(range_end, cursor) + range_start = cursor + parts.extend(after_lines[cursor:range_start]) + cursor = range_end + parts.extend(after_lines[cursor:]) + return "".join(parts) + + def _restore(self, path: Path, before: str, before_block: str) -> _RestoreOutcome: + """Re-apply `before_block` into `path`, preserving other edits. + + The restored content is verified before it is written, so a malformed + re-insertion (for example from a partially deleted block) is reported as + a failure instead of being persisted. + + Returns: + `UNCHANGED` when the block was untouched, `RESTORED` when it was + altered and successfully restored, or `FAILED` when it was + altered but could not be restored. + """ + after = self._read(path) + if after is None: + # The file vanished or became unreadable after the edit, so the + # block cannot be restored. Treat as a failure rather than passing + # the clobbering edit through as a success. + logger.warning( + "Guarded memory file %s is unreadable after edit; " + "cannot restore managed block", + path, + ) + return _RestoreOutcome.FAILED + block_after = extract_onboarding_name_block(after) + if block_after == before_block: + return _RestoreOutcome.UNCHANGED + if block_after is not None: + source = after + else: + source = self._without_managed_block_edits(before, after, before_block) + if source is None: + logger.error( + "Could not locate previous managed block in %s; leaving the " + "edited file untouched", + path, + ) + return _RestoreOutcome.FAILED + source = strip_onboarding_name_markers(source) + restored = _upsert_onboarding_name_memory(source, before_block) + if extract_onboarding_name_block(restored) != before_block: + logger.error( + "Restored content for %s did not reproduce the managed block; " + "leaving the edited file untouched", + path, + ) + return _RestoreOutcome.FAILED + try: + self._write(path, restored) + except (OSError, UnicodeEncodeError): + logger.warning( + "Could not restore managed memory block at %s", path, exc_info=True + ) + return _RestoreOutcome.FAILED + return _RestoreOutcome.RESTORED + + @staticmethod + def _error( + request: ToolCallRequest, path: Path, *, restore_failed: bool + ) -> ToolMessage: + """Build the error result returned after a managed-block edit. + + Returns: + An error-status `ToolMessage` explaining the managed region. + """ + template = _RESTORE_FAILED_MESSAGE if restore_failed else _REJECTION_MESSAGE + return ToolMessage( + content=template.format(path=path), + name=request.tool_call["name"], + tool_call_id=request.tool_call["id"], + status="error", + ) + + @staticmethod + def _reject_delete(path: Path, before: str | None) -> bool: + """Return whether a delete targeting a guarded path must be rejected. + + The caller has already matched `path` as a guarded file (or a file + inside a directory being deleted). The delete is rejected when: + + - the guarded file currently holds a managed block, or + - the guarded file exists but its content could not be read. + + The second case fails closed on purpose: `_read` returns `None` for + both a missing file and an existing-but-unreadable one (a permission + error, or a symlink swap caught by `O_NOFOLLOW`). A missing guarded + file has nothing to protect and is safe to remove, but an existing + file we cannot inspect must not be deleted irreversibly on the + assumption that it lacks a managed block. + + Returns: + `True` when the delete must be blocked, `False` when it may proceed. + """ + if before is not None: + return extract_onboarding_name_block(before) is not None + return path.exists() + + @staticmethod + def _delete_error(request: ToolCallRequest, path: Path) -> ToolMessage: + """Build the error result returned when a delete would remove memory. + + Returns: + An error-status `ToolMessage` explaining the protected file. + """ + return ToolMessage( + content=_DELETE_REJECTION_MESSAGE.format(path=path), + name=request.tool_call["name"], + tool_call_id=request.tool_call["id"], + status="error", + ) + + def _result_after_restore( + self, + request: ToolCallRequest, + path: Path, + before: str, + before_block: str, + result: ToolMessage | Command[Any], + ) -> ToolMessage | Command[Any]: + """Restore the managed block and pick the result to return. + + Returns: + The original `result` when the block was untouched, otherwise an + error `ToolMessage` describing the restore. + """ + outcome = self._restore(path, before, before_block) + if outcome is _RestoreOutcome.UNCHANGED: + return result + return self._error( + request, path, restore_failed=outcome is _RestoreOutcome.FAILED + ) + + def wrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]], + ) -> ToolMessage | Command[Any]: + """Restore the managed block when a sync edit would change it. + + Returns: + The tool result, or an error `ToolMessage` when the managed block + was altered. + """ + path = self._guarded_path(request) + if path is None: + return handler(request) + before = self._read(path) + if request.tool_call["name"] == "delete": + if self._reject_delete(path, before): + return self._delete_error(request, path) + return handler(request) + before_block = ( + extract_onboarding_name_block(before) if before is not None else None + ) + if before is None or before_block is None: + return handler(request) + result = handler(request) + return self._result_after_restore(request, path, before, before_block, result) + + async def awrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]], + ) -> ToolMessage | Command[Any]: + """Restore the managed block when an async edit would change it. + + Returns: + The tool result, or an error `ToolMessage` when the managed block + was altered. + """ + path = await asyncio.to_thread(self._guarded_path, request) + if path is None: + return await handler(request) + before = await asyncio.to_thread(self._read, path) + if request.tool_call["name"] == "delete": + if await asyncio.to_thread(self._reject_delete, path, before): + return self._delete_error(request, path) + return await handler(request) + before_block = ( + extract_onboarding_name_block(before) if before is not None else None + ) + if before is None or before_block is None: + return await handler(request) + result = await handler(request) + return await asyncio.to_thread( + self._result_after_restore, request, path, before, before_block, result + ) diff --git a/libs/code/deepagents_code/model_config.py b/libs/code/deepagents_code/model_config.py new file mode 100644 index 0000000000..d4ef40f535 --- /dev/null +++ b/libs/code/deepagents_code/model_config.py @@ -0,0 +1,5262 @@ +"""Model configuration management. + +Handles loading and saving model configuration from TOML files, providing a +structured way to define available models and providers. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import importlib.util +import json +import logging +import os +import sys +import tempfile +import threading +import tomllib +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, TypedDict, cast +from urllib.parse import urlparse + +import tomli_w + +from deepagents_code import _env_vars, auth_store +from deepagents_code._git import find_git_common_dir + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + + from deepagents_code.json_types import JsonValue + +logger = logging.getLogger(__name__) + +_ENV_PREFIX = "DEEPAGENTS_CODE_" +_resolved_env_var_log_lock = threading.Lock() +_resolved_env_var_log_names: set[str] = set() + + +def reset_env_resolution_log() -> None: + """Allow successful prefixed environment resolutions to be logged again.""" + with _resolved_env_var_log_lock: + _resolved_env_var_log_names.clear() + + +def resolved_env_var_name(canonical: str) -> str: + """Return whichever env var name actually carries the resolved value. + + Mirrors `resolve_env_var`'s precedence: when the prefixed variant is + present in `os.environ` (even empty), it wins; otherwise the canonical + name is returned. Useful for UI labels that need to reflect what the + app is actually reading rather than the canonical name. + + Args: + canonical: The canonical environment variable name. + + Returns: + The resolving env var name (prefixed or canonical). + """ + if not canonical.startswith(_ENV_PREFIX): + prefixed = f"{_ENV_PREFIX}{canonical}" + if prefixed in os.environ: + return prefixed + return canonical + + +def resolve_env_var(name: str) -> str | None: + """Look up an env var with `DEEPAGENTS_CODE_` prefix override. + + Checks `DEEPAGENTS_CODE_{name}` first, then falls back to `{name}`. + + If the prefixed variable is *present* in the environment (even as an empty + string), the canonical variable is never consulted. This lets users + set `DEEPAGENTS_CODE_X=""` to shadow a canonically-set key -- the function + will return `None` (since empty strings are normalized to `None`), + effectively suppressing the canonical value. + + If `name` already carries the prefix, the double-prefixed lookup is skipped + to avoid nonsensical `DEEPAGENTS_CODE_DEEPAGENTS_CODE_*` reads + (e.g., when the name comes from a user's `config.toml`). + + Args: + name: The canonical environment variable name (e.g. + `ANTHROPIC_API_KEY`). + + Returns: + The resolved value, or `None` when absent or empty. + """ + if not name.startswith(_ENV_PREFIX): + prefixed = f"{_ENV_PREFIX}{name}" + if prefixed in os.environ: + val = os.environ[prefixed] + if not val and os.environ.get(name): + logger.debug( + "%s is set but empty, blocking non-empty %s. " + "Unset %s to use the canonical variable.", + prefixed, + name, + prefixed, + ) + if val and logger.isEnabledFor(logging.DEBUG): + # `resolve_env_var` is called frequently; log each successful + # prefixed resolution only once per generation to avoid spam. + with _resolved_env_var_log_lock: + should_log = name not in _resolved_env_var_log_names + _resolved_env_var_log_names.add(name) + if should_log: + logger.debug("Resolved %s from %s", name, prefixed) + return val or None + return os.environ.get(name) or None + + +PROVIDERS_DOCS_URL = ( + "https://docs.langchain.com/oss/python/deepagents/code/providers#provider-reference" +) +"""Public docs page for configuring model providers. + +Referenced by `UnknownProviderError` and the `/auth` manager so the same +URL is used everywhere a user is sent to read about provider setup. +""" + + +class ModelConfigError(Exception): + """Raised when model configuration or creation fails.""" + + +class NoCredentialsConfiguredError(ModelConfigError): + """Raised when no credentials are configured for any default-resolvable provider. + + Distinct from `MissingCredentialsError` (which targets a specific provider + the user has selected): this fires from `_get_default_model_spec()` when + auto-detection finds no usable credentials at all. Callers (the deferred- + start path in the TUI and CLI) `isinstance`-check this type to recover by + launching the TUI with model creation deferred, rather than string-matching + the formatted message. + """ + + +class UnknownProviderError(ModelConfigError): + """Raised when neither the app nor `init_chat_model` can infer a provider. + + Carries the offending model spec as an attribute and exposes + `PROVIDERS_DOCS_URL` as a class-level constant so callers can render + a clickable link without string-scanning the formatted message. This + mirrors how `MissingCredentialsError` exposes `provider` / `env_var` + for targeted recovery hints. + """ + + docs_url: ClassVar[str] = PROVIDERS_DOCS_URL + """Provider-reference docs URL. Class-level so callers don't pass it.""" + + def __init__(self, *, model_spec: str) -> None: + """Initialize the error. + + Args: + model_spec: The bare model name the user supplied (e.g. + `'mystery-model'`). When the input had a `provider:model` + form, parsing succeeds and this exception does not fire. + + Raises: + ValueError: If `model_spec` is empty. + """ + if not model_spec: + msg = "model_spec must be non-empty" + raise ValueError(msg) + message = ( + f"Unable to infer a model provider for {model_spec!r}. " + f"Specify one explicitly (e.g. 'anthropic:{model_spec}') " + f"or see the provider reference at {self.docs_url}." + ) + super().__init__(message) + self.model_spec = model_spec + + +class MissingCredentialsError(ModelConfigError): + """Raised when a provider is selected but its API key env var is unset. + + Subclasses `ModelConfigError` so existing `except ModelConfigError` blocks + keep working. Carries the `provider` name and the canonical `env_var` so + callers can render targeted recovery hints (e.g., "set OPENAI_API_KEY" or + "run `/model :`") without string-matching on the + formatted exception message and without re-deriving the env-var name. + """ + + def __init__( + self, message: str, *, provider: str, env_var: str | None = None + ) -> None: + """Initialize the error. + + Args: + message: Human-readable message describing the missing credential. + provider: The provider whose credentials are missing + (e.g., `'openai'`). + env_var: The canonical env var name expected to hold the + credential (e.g., `'OPENAI_API_KEY'`). `None` when the + provider has no registered env-var mapping. + """ + super().__init__(message) + self.provider = provider + self.env_var = env_var + + +class MissingProviderPackageError(ModelConfigError): + """Raised when a provider is selected but its LangChain package is not installed. + + Subclasses `ModelConfigError` so existing `except ModelConfigError` blocks + keep working. Carries the `provider` name and the `package` to install so + callers can render targeted recovery hints (e.g., suggest `/install fireworks` + or the `/model` slash command) without string-matching on the formatted + exception message. + """ + + def __init__(self, message: str, *, provider: str, package: str) -> None: + """Initialize the error. + + Args: + message: Human-readable message describing the missing package. + provider: The provider whose package is missing (e.g., `'fireworks'`). + package: The pip-installable package name (e.g., + `'langchain-fireworks'`). + """ + super().__init__(message) + self.provider = provider + self.package = package + + +class ProviderAuthState(StrEnum): + """Credential readiness state for a model provider.""" + + CONFIGURED = "configured" + """An explicit credential source is configured and non-empty.""" + + MISSING = "missing" + """An explicit credential source is required but missing.""" + + NOT_REQUIRED = "not_required" + """This provider configuration does not require API-key credentials.""" + + IMPLICIT = "implicit" + """The provider supports ambient auth outside CLI env-var checks.""" + + MANAGED = "managed" + """A custom provider class is expected to manage auth itself.""" + + UNKNOWN = "unknown" + """The app cannot determine whether provider auth is ready.""" + + +class ProviderAuthSource(StrEnum): + """Origin of a `CONFIGURED` credential, used to discriminate display.""" + + STORED = "stored" + """Persisted in a local credential store under `~/.deepagents/.state`. + + Usually the `/auth` API-key map (`auth.json`), but also covers the + file-backed ChatGPT OAuth token used by the codex provider + (`chatgpt-auth.json`). + """ + + ENV = "env" + """Resolved from an environment variable.""" + + +@dataclass(frozen=True) +class ProviderAuthStatus: + """Credential readiness information for a provider. + + Args: + state: Provider auth state. + provider: Provider name. + env_var: Env var name associated with the state, when applicable. + source: For `CONFIGURED` states, where the credential value came + from. `None` for non-configured states or when the source is + not meaningful (e.g., implicit/managed auth). + detail: Short user-facing context for selectors and logs. + """ + + state: ProviderAuthState + provider: str + env_var: str | None = None + source: ProviderAuthSource | None = None + detail: str | None = None + + def __post_init__(self) -> None: + """Enforce the source-vs-state invariant. + + Raises: + ValueError: If `source` is set but `state` is not `CONFIGURED`, + or if `state` is `CONFIGURED` but no `source` is recorded. + """ + is_configured = self.state is ProviderAuthState.CONFIGURED + has_source = self.source is not None + if is_configured != has_source: + msg = ( + f"ProviderAuthStatus invariant violated: " + f"state={self.state!r} requires " + f"{'a source' if is_configured else 'source=None'}, " + f"got source={self.source!r}" + ) + raise ValueError(msg) + + @property + def blocks_start(self) -> bool: + """Whether this status should block model creation or switching.""" + return self.state is ProviderAuthState.MISSING + + def as_legacy_bool(self) -> bool | None: + """Return the historic `has_provider_credentials` tri-state value.""" + if self.state is ProviderAuthState.MISSING: + return False + if self.state is ProviderAuthState.UNKNOWN: + return None + return True + + def missing_detail(self) -> str: + """Return a user-facing reason for a missing-credential status.""" + if self.env_var: + return f"{self.env_var} is not set or is empty" + if self.detail: + return self.detail + return ( + f"provider '{self.provider}' is not recognized. " + "Add it to ~/.deepagents/config.toml with an api_key_env field" + ) + + +@dataclass(frozen=True) +class ModelSpec: + """A model specification in `provider:model` format. + + Examples: + >>> spec = ModelSpec.parse("anthropic:claude-sonnet-4-5") + >>> spec.provider + 'anthropic' + >>> spec.model + 'claude-sonnet-4-5' + >>> str(spec) + 'anthropic:claude-sonnet-4-5' + """ + + provider: str + """The provider name (e.g., `'anthropic'`, `'openai'`).""" + + model: str + """The model identifier (e.g., `'claude-sonnet-4-5'`, `'gpt-5.5'`).""" + + def __post_init__(self) -> None: + """Validate the model spec after initialization. + + Raises: + ValueError: If provider or model is empty. + """ + if not self.provider: + msg = "Provider cannot be empty" + raise ValueError(msg) + if not self.model: + msg = "Model cannot be empty" + raise ValueError(msg) + + @classmethod + def parse(cls, spec: str) -> ModelSpec: + """Parse a model specification string. + + Args: + spec: Model specification in `'provider:model'` format. + + Returns: + Parsed ModelSpec instance. + + Raises: + ValueError: If the spec is not in valid `'provider:model'` format. + """ + if ":" not in spec: + msg = ( + f"Invalid model spec '{spec}': must be in provider:model format " + "(e.g., 'anthropic:claude-sonnet-4-5')" + ) + raise ValueError(msg) + provider, model = spec.split(":", 1) + return cls(provider=provider, model=model) + + @classmethod + def try_parse(cls, spec: str) -> ModelSpec | None: + """Non-raising variant of `parse`. + + Args: + spec: Model specification in `provider:model` format. + + Returns: + Parsed `ModelSpec`, or `None` when *spec* is not valid. + """ + try: + return cls.parse(spec) + except ValueError: + return None + + def __str__(self) -> str: + """Return the model spec as a string in `provider:model` format.""" + return f"{self.provider}:{self.model}" + + +class ModelProfileEntry(TypedDict): + """Profile data for a model with override tracking.""" + + profile: dict[str, Any] + """Merged profile dict (upstream defaults + config.toml overrides). + + Keys vary by provider (e.g., `max_input_tokens`, `tool_calling`). + """ + + overridden_keys: frozenset[str] + """Keys in `profile` whose values came from config.toml rather than the + upstream provider package.""" + + +class ProviderConfig(TypedDict, total=False): + """Configuration for a model provider. + + The optional `class_path` field allows bypassing `init_chat_model` entirely + and instantiating an arbitrary `BaseChatModel` subclass via importlib. + + !!! warning + + Setting `class_path` executes arbitrary Python code from the user's + config file. This has the same trust model as `pyproject.toml` build + scripts — the user controls their own machine. + """ + + enabled: bool + """Whether this provider appears in the model switcher. + + Defaults to `True`. Set to `False` to hide a package-discovered provider + and all its models from the `/model` selector. Useful when a LangChain + provider package is installed as a transitive dependency but should not + be user-visible. + """ + + models: list[str] + """List of model identifiers available from this provider.""" + + api_key_env: str + """Name of the environment variable that holds the API key. + + This is the env var *name* (e.g., `"OPENAI_API_KEY"`), not the secret + itself. The app resolves it at startup to verify credentials before model + creation. + """ + + display_name: str + """Human-readable provider name shown in auth UI. + + Useful for arbitrary providers whose config key is optimized for machine use + (e.g., `my_gateway`) but whose UI label should include spaces or brand + capitalization. + """ + + short_name: str + """Compact brand label for space-constrained UI (e.g. the `/model` Recent + tag), where the full `display_name` — which may carry a parenthetical + qualifier like `"OpenAI Codex (ChatGPT login)"` — is too long. Optional; + when unset, callers fall back to `display_name`. + """ + + api_key_url: str + """Provider page where users can create or manage API keys. + + Used by `/auth` as an acquisition link before the API-key input. The value is + a URL, not a credential. Must use an `http` or `https` scheme to render as a + clickable link; values with other schemes are ignored with a warning. + """ + + base_url: str + """Custom base URL.""" + + base_url_env: str + """Name of the environment variable that holds this provider's base URL. + + Parallel to `api_key_env`: lets a provider that is not one of the built-in + `PROVIDER_BASE_URL_ENV` entries participate in endpoint resolution and in + the key/endpoint pairing applied by `apply_stored_credentials` (so a stored + `/auth` override clears an inherited gateway URL). The static `base_url` + field still wins over this when both are set. + """ + + # Level 2: arbitrary BaseChatModel classes + + class_path: str + """Fully-qualified Python class in `module.path:ClassName` format. + + When set, `create_model` imports this class and instantiates it directly + instead of calling `init_chat_model`. + """ + + params: dict[str, Any] + """Extra keyword arguments forwarded to the model constructor. + + Flat keys (e.g., `temperature = 0`) are provider-wide defaults applied to + every model from this provider. Model-keyed sub-tables (e.g., + `[params."qwen3:4b"]`) override individual values for that model only; + the merge is shallow (model wins on conflict). + + Do not set `api_key` here — the early credential check runs before + `params` are read, so the app will reject the model before it sees the key. + Use `api_key_env` to point at an environment variable instead. + """ + + profile: dict[str, Any] + """Overrides merged into the model's runtime profile dict. + + Flat keys (e.g., `max_input_tokens = 4096`) are provider-wide defaults. + Model-keyed sub-tables (e.g., `[profile."claude-sonnet-4-5"]`) override + individual values for that model only; the merge is shallow. + """ + + +DEFAULT_CONFIG_DIR = Path.home() / ".deepagents" +"""Directory for user-level Deep Agents configuration (`~/.deepagents`).""" + +DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml" +"""Path to the user's model configuration file (`~/.deepagents/config.toml`).""" + +DEFAULT_STATE_DIR = DEFAULT_CONFIG_DIR / ".state" +"""Directory for app-managed internal state (`~/.deepagents/.state`). + +Holds files the app writes for its own bookkeeping — OAuth tokens, the +sessions database, version-check caches, input history. Kept separate from +top-level user-facing config and agent directories so listing/iterating +`~/.deepagents` doesn't conflate state with agents. +""" + + +def default_cache_dir() -> Path: + """Return the OS-appropriate cache directory for Deep Agents Code. + + Uses `~/Library/Caches` on macOS, `LOCALAPPDATA` on Windows (falling back + to `~/AppData/Local`), and `XDG_CACHE_HOME` elsewhere when it is an + absolute path (falling back to `~/.cache`). The XDG spec treats relative + `XDG_CACHE_HOME` values as invalid, so they are ignored rather than + resolved against the launch directory. + + Platform-native locations are the convention for a long-lived app (this is + what `platformdirs` codifies and what `uv` itself does — its own cache is + `~/Library/Caches/uv` on macOS). The install script deliberately does not + follow this: as a portable one-shot POSIX bootstrap it uses XDG-style + `${XDG_CACHE_HOME:-~/.cache}` on every platform (like the rustup and uv + installers), so on macOS its `/deepagents-code/install.log` lands + under a different root than the update logs. That divergence is + intentional; do not "fix" one side to match the other without a concrete + need (e.g., a diagnostic command that collects both logs). + + Returns: + Base cache directory, before the `deepagents-code` subdirectory. + """ + if sys.platform == "win32": + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + return Path(local_app_data) + return Path.home() / "AppData" / "Local" + if sys.platform == "darwin": + return Path.home() / "Library" / "Caches" + xdg_cache_home = os.environ.get("XDG_CACHE_HOME") + if xdg_cache_home and Path(xdg_cache_home).is_absolute(): + return Path(xdg_cache_home) + return Path.home() / ".cache" + + +RECENT_MODELS_FILENAME = "recent_models.json" +"""Filename under `DEFAULT_STATE_DIR` for the MRU list shown in `/model`.""" + +RECENT_MODELS_LIMIT = 5 +"""Maximum number of `provider:model` specs retained in the recent list. + +Sized to fit comfortably above the provider-grouped list in `/model` without +pushing the rest of the catalog off-screen on a typical terminal. +""" + +LANGSMITH_GATEWAY_PROVIDERS: frozenset[str] = frozenset( + {"anthropic", "baseten", "fireworks", "google_genai", "openai"} +) +"""Providers whose LangChain integrations support LangSmith LLM Gateway env vars.""" + +LANGSMITH_GATEWAY_ENV = "LANGSMITH_GATEWAY" +LANGSMITH_GATEWAY_API_KEY_ENV = "LANGSMITH_GATEWAY_API_KEY" +_LANGSMITH_GATEWAY_FALSE_VALUES = frozenset({"false", "0", "no"}) + + +PROVIDER_API_KEY_ENV: dict[str, str] = { + "anthropic": "ANTHROPIC_API_KEY", + "azure_openai": "AZURE_OPENAI_API_KEY", + "baseten": "BASETEN_API_KEY", + "cohere": "COHERE_API_KEY", + "deepseek": "DEEPSEEK_API_KEY", + "fireworks": "FIREWORKS_API_KEY", + "google_genai": "GOOGLE_API_KEY", + "google_vertexai": "GOOGLE_CLOUD_PROJECT", + "groq": "GROQ_API_KEY", + "huggingface": "HUGGINGFACEHUB_API_TOKEN", + "ibm": "WATSONX_APIKEY", + "litellm": "LITELLM_API_KEY", + "meta": "MODEL_API_KEY", + "mistralai": "MISTRAL_API_KEY", + "nvidia": "NVIDIA_API_KEY", + "openai": "OPENAI_API_KEY", + "openrouter": "OPENROUTER_API_KEY", + "perplexity": "PPLX_API_KEY", + "together": "TOGETHER_API_KEY", + "xai": "XAI_API_KEY", +} +"""Well-known providers mapped to the env var that holds their API key. + +Used by `has_provider_credentials` to verify credentials *before* model +creation, so the UI can show a warning icon and a specific error message +(e.g., "ANTHROPIC_API_KEY not set") instead of letting the provider fail at call +time. + +Providers not listed here fall through to the config-file check or the langchain +registry fallback. +""" + +LANGSMITH_SERVICE = "langsmith" +"""Service name for LangSmith tracing in `SERVICE_API_KEY_ENV`. + +Storing a key for this service via `/auth` also enables tracing at startup +(see `config._apply_stored_langsmith_tracing`) and can carry a custom project +name, so it gets special handling beyond a plain key copy. +""" + +TAVILY_SERVICE = "tavily" +"""Service name for Tavily web search in `SERVICE_API_KEY_ENV`. + +Storing a key for this service via `/auth` gates the spawn-time `web_search` +tool (see `server_graph._build_tools`), so a key added to a running server +takes effect only after a respawn — the app offers that restart, and this +constant is the single name its `/auth` handling compares against. +""" + +SERVICE_API_KEY_ENV: dict[str, str] = { + LANGSMITH_SERVICE: "LANGSMITH_API_KEY", + TAVILY_SERVICE: "TAVILY_API_KEY", +} +"""Non-model services configurable via `/auth`, mapped to their API-key env var. + +These are not LLM providers — they back features such as web search (Tavily) or +agent tracing (LangSmith) — but their credentials follow the same store-on-disk +model as model providers, so they appear in the `/auth` manager and can be +entered directly in the TUI instead of being exported as environment variables +before launch. +""" + +CODEX_PROVIDER = "openai_codex" +"""Provider name for `_ChatOpenAICodex` models authenticated via ChatGPT OAuth. + +Distinct from `"openai"` (which uses an `OPENAI_API_KEY`) because the auth +source, model class, and request endpoint all differ. See +`deepagents_code.integrations.openai_codex` for the OAuth flow. +""" + +CODEX_MODELS: frozenset[str] = frozenset( + { + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex", + "gpt-5.2", + } +) +"""Curated allowlist of models the Codex (ChatGPT OAuth) backend serves. + +The provider mirrors `openai` profiles, but only models in this set are +exposed under `openai_codex`. The Codex backend serves a narrower lineup than +the full `openai` API, so mirroring every openai model would surface specs the +backend rejects at call time. +""" + + +RETRY_PARAM_BY_PROVIDER: dict[str, str] = { + "anthropic": "max_retries", + "azure_openai": "max_retries", + "baseten": "max_retries", + "bedrock": "max_retries", + "deepseek": "max_retries", + "fireworks": "max_retries", + "google_genai": "max_retries", + "google_vertexai": "max_retries", + "groq": "max_retries", + "litellm": "max_retries", + "meta": "max_retries", + "mistralai": "max_retries", + "openai": "max_retries", + "openrouter": "max_retries", + "perplexity": "max_retries", + "together": "max_retries", + "xai": "max_retries", +} +"""Maps a provider to the constructor kwarg that sets its retry count. + +The value is the kwarg name to pass to the provider's chat model constructor. +It is uniformly `max_retries` for every provider listed today, but this is a +`dict` rather than a `set` of providers because retry-kwarg names diverge across +the ecosystem -- some integrations expose a differently named kwarg -- and the +value column lets a future provider register its own name without restructuring +callers. + +Membership is verified against each provider's chat model constructor (e.g. +`ChatGoogleGenerativeAI` exposes `max_retries`, not `retries`), not inferred +from naming. Providers absent from this map either lack an integer retry-count +kwarg or are not yet wired as a credential-resolvable provider in this module; +a `[retries]` config for them is ignored with a warning by `_resolve_retry_kwargs`. +""" + +PROVIDER_BASE_URL_ENV: dict[str, tuple[str, ...]] = { + # Each tuple lists every base-URL env var the provider's LangChain + # integration and underlying SDK may read, canonical name first. Names were + # verified against the integration and SDK source, not inferred: + # anthropic langchain_anthropic reads ANTHROPIC_API_URL; the anthropic + # SDK reads ANTHROPIC_BASE_URL. + # azure_openai AzureChatOpenAI and the openai SDK both read + # AZURE_OPENAI_ENDPOINT. + # baseten ChatBaseten reads BASETEN_BASE_URL, then falls back to + # BASETEN_API_BASE. + # cohere langchain_cohere passes base_url=None, so the cohere SDK's + # CO_API_URL is what takes effect. + # deepseek ChatDeepSeek reads DEEPSEEK_API_BASE (alias base_url). + # fireworks ChatFireworks reads FIREWORKS_API_BASE; when unset the + # fireworks SDK reads FIREWORKS_BASE_URL. + # google_genai the google-genai SDK reads GOOGLE_GEMINI_BASE_URL (the lone + # name langchain_google_genai threads through HttpOptions). + # groq ChatGroq reads GROQ_API_BASE; when unset the groq SDK reads + # GROQ_BASE_URL. + # huggingface the integration and huggingface_hub both read + # HF_INFERENCE_ENDPOINT. + # ibm ChatWatsonx reads WATSONX_URL. + # meta ChatMetaModel reads MODEL_API_BASE. + # mistralai ChatMistralAI reads MISTRAL_BASE_URL. + # nvidia ChatNVIDIA reads NVIDIA_BASE_URL. + # openai langchain_openai reads OPENAI_API_BASE; the openai SDK + # reads OPENAI_BASE_URL. + # openrouter ChatOpenRouter reads OPENROUTER_API_BASE (alias base_url). + # perplexity the integration passes no base_url, so the perplexity SDK's + # PERPLEXITY_BASE_URL is what takes effect. + # together ChatTogether reads TOGETHER_API_BASE (alias base_url). + # xai ChatXAI reads XAI_API_BASE (alias base_url). + # + # OpenAI-compatible providers (deepseek, openrouter, together, xai, baseten) + # sit on the openai SDK, whose only base-URL env var is the shared + # OPENAI_BASE_URL. That name is intentionally NOT listed under those + # providers: writing or clearing it under another provider's name would + # clobber the user's real OpenAI endpoint. Each is listed above under its own + # dedicated name(s) instead. In practice the integration always passes + # base_url explicitly, so the shared fallback never fires. + # + # Omitted (no dedicated, provider-specific endpoint env var): litellm + # (api_base arg, per-provider env), google_vertexai (endpoint derived from the + # region). A `/auth` endpoint for these still resolves through the + # stored-credential step of `get_base_url` and reaches the model as the + # `base_url` kwarg. + "anthropic": ("ANTHROPIC_BASE_URL", "ANTHROPIC_API_URL"), + "azure_openai": ("AZURE_OPENAI_ENDPOINT",), + "baseten": ("BASETEN_BASE_URL", "BASETEN_API_BASE"), + "cohere": ("CO_API_URL",), + "deepseek": ("DEEPSEEK_API_BASE",), + "fireworks": ("FIREWORKS_BASE_URL", "FIREWORKS_API_BASE"), + "google_genai": ("GOOGLE_GEMINI_BASE_URL",), + "groq": ("GROQ_BASE_URL", "GROQ_API_BASE"), + "huggingface": ("HF_INFERENCE_ENDPOINT",), + "ibm": ("WATSONX_URL",), + "meta": ("MODEL_API_BASE",), + "mistralai": ("MISTRAL_BASE_URL",), + "nvidia": ("NVIDIA_BASE_URL",), + "openai": ("OPENAI_BASE_URL", "OPENAI_API_BASE"), + "openrouter": ("OPENROUTER_API_BASE",), + "perplexity": ("PERPLEXITY_BASE_URL",), + "together": ("TOGETHER_API_BASE",), + "xai": ("XAI_API_BASE",), +} +"""Every base-URL env var a provider's SDK may read. + +Element `[0]` is the *canonical* name — the one we write a stored endpoint to. +`get_base_url` reads each name in tuple order through `resolve_env_var`, so every +base URL gets the same `DEEPAGENTS_CODE_*` > plain-var precedence as API keys. +The remaining names are alternates the SDK might also honor; +`apply_stored_credentials` clears them when applying or resetting an endpoint, so +a stale value (e.g. an inherited gateway URL) can't leak through. Clearing every +name is what lets the write path treat the canonical as authoritative regardless +of which name the SDK prefers. + +The key and its endpoint are a coherent pair: a gateway key only works against +the gateway URL, a provider-native key only against the provider's own endpoint, +so both must resolve from the same source. +""" + + +def _canonical_base_url_env(provider: str) -> str | None: + """Return the canonical (written) base-URL env var name for a provider. + + The canonical name is element `[0]` of the provider's `PROVIDER_BASE_URL_ENV` + tuple. Returns `None` for providers outside the built-in set. + + Args: + provider: Provider name. + + Returns: + Canonical env var name, or `None` if the provider has no built-in entry. + """ + names = PROVIDER_BASE_URL_ENV.get(provider) + return names[0] if names else None + + +IMPLICIT_AUTH_PROVIDERS: frozenset[str] = frozenset({"google_vertexai"}) +"""Providers that support ambient auth outside app env-var checks. + +These providers can authenticate without the env var listed in +`PROVIDER_API_KEY_ENV`, so a missing env var should not be treated as a hard +credential failure. Used by `create_model` to skip the early credential check +and by `get_provider_auth_status` for user-facing auth labels. +""" + +NO_AUTH_REQUIRED_PROVIDERS: frozenset[str] = frozenset({"ollama"}) +"""Providers whose default local configuration does not require API keys.""" + +OPTIONAL_AUTH_ENV: dict[str, str] = {"ollama": "OLLAMA_API_KEY"} +"""Optional env vars that enable authenticated provider modes when present.""" + +PROVIDER_HOST_ENV: dict[str, str] = {"ollama": "OLLAMA_HOST"} +"""Provider-specific env vars that can point a local provider at a remote host.""" + +PROVIDER_CUSTOM_HEADERS_ENV: dict[str, str] = {"anthropic": "ANTHROPIC_CUSTOM_HEADERS"} +"""Provider SDK env vars that inject custom request headers (e.g. gateway auth).""" + +OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434" +"""Default endpoint assumed when no `base_url` or `OLLAMA_HOST` is configured.""" + +OLLAMA_DISCOVERY_TIMEOUT_SECONDS = 1.0 +"""Socket timeout for Ollama discovery probes. + +Kept short so a dead daemon does not stall switcher loading. Discovery runs +off the UI loop in a worker thread and may call `/api/tags` and `/api/show`, +so this caps the worst-case wait visible to the user. +""" + + +# Module-level caches — cleared by `clear_caches()`. +_available_models_cache: dict[str, list[str]] | None = None +_builtin_providers_cache: dict[str, Any] | None = None +_default_config_cache: ModelConfig | None = None +_provider_profiles_cache: dict[str, dict[str, Any]] = {} +_provider_profiles_lock = threading.Lock() +_config_write_lock = threading.RLock() +"""Process-wide lock serializing read-modify-write transactions on `config.toml`. + +Any helper that reads the file, mutates a section, and atomically replaces it +must hold this lock for the whole transaction. The atomic rename alone only +prevents torn writes; without a lock covering read-through-replace, two +overlapping writers (e.g. concurrent effort-selection workers) can each read the +same snapshot and the last `replace()` silently drops the other's change. + +Because the hazard is on the whole-file replace (not per-section), *every* writer +of `config.toml` must share this one lock — a second lock guarding the same file +would not mutually exclude, so a `[effort]` write could still clobber a `[ui]` +write. All such helpers here hold it, and `app.py`'s theme/UI writers import and +hold this same object rather than defining their own. + +It is reentrant so a caller can hold it across several of these helpers without +self-deadlock. Cross-process races are out of scope (mirrors the existing +helpers).""" +_ollama_installed_models_cache: dict[str, list[str]] = {} +_ollama_unreachable_endpoints: set[str] = set() +"""Local endpoints (trailing slash stripped) whose daemon refused the TCP +presence preflight. + +Lets `_get_ollama_installed_models` negatively-cache the empty result for a +daemon that is definitively absent (connection refused) so it probes and logs +"not detected" once per reload. A *reachable* daemon that merely has no models +pulled yet -- and a daemon whose preflight is only ambiguous (a connect +timeout, which defers to the HTTP probe) -- is still re-probed (its empty +result is not cached), so a later `ollama pull` is discovered without +`/reload`. Cleared by `clear_caches()`.""" +_ollama_model_profiles_cache: dict[tuple[str, str], dict[str, Any]] = {} +_profiles_cache: Mapping[str, ModelProfileEntry] | None = None +_profiles_override_cache: tuple[int, Mapping[str, ModelProfileEntry]] | None = None + + +def clear_caches() -> None: + """Reset module-level caches so the next call recomputes from scratch. + + Intended for tests and for the `/reload` command. + """ + global _available_models_cache, _builtin_providers_cache, _default_config_cache, _profiles_cache, _profiles_override_cache # noqa: PLW0603, E501 # Module-level caches require global statement + _available_models_cache = None + _builtin_providers_cache = None + _default_config_cache = None + _provider_profiles_cache.clear() + _ollama_installed_models_cache.clear() + _ollama_unreachable_endpoints.clear() + _ollama_model_profiles_cache.clear() + _profiles_cache = None + _profiles_override_cache = None + invalidate_thread_config_cache() + + +def _get_builtin_providers() -> dict[str, Any]: + """Return langchain's built-in provider registry. + + Tries the newer `_BUILTIN_PROVIDERS` name first, then falls back to + the legacy `_SUPPORTED_PROVIDERS` for older langchain versions. + + Results are cached after the first call; use `clear_caches()` to reset. + + Returns: + The provider registry dict from `langchain.chat_models.base`. + """ + global _builtin_providers_cache # noqa: PLW0603 # Module-level cache requires global statement + if _builtin_providers_cache is not None: + return _builtin_providers_cache + + # Deferred: langchain.chat_models pulls in heavy provider registry, + # only needed when resolving provider names for model config. + from langchain.chat_models import base + + registry: dict[str, Any] | None = getattr(base, "_BUILTIN_PROVIDERS", None) + if registry is None: + registry = getattr(base, "_SUPPORTED_PROVIDERS", None) + _builtin_providers_cache = registry if registry is not None else {} + return _builtin_providers_cache + + +def _get_provider_profile_modules() -> list[tuple[str, str]]: + """Build a `(provider, profile_module)` list from langchain's provider registry. + + Reads the built-in provider registry from `langchain.chat_models.base` + to discover every provider that `init_chat_model` knows about, then derives + the `.data._profiles` module path for each. + + Returns: + List of `(provider_name, profile_module_path)` tuples. + """ + providers = _get_builtin_providers() + + result: list[tuple[str, str]] = [] + seen: set[tuple[str, str]] = set() + + for provider_name, (module_path, *_rest) in providers.items(): + package_root = module_path.split(".", maxsplit=1)[0] + profile_module = f"{package_root}.data._profiles" + key = (provider_name, profile_module) + if key not in seen: + seen.add(key) + result.append((provider_name, profile_module)) + + return result + + +def _load_provider_profiles(module_path: str) -> dict[str, Any]: + """Load `_PROFILES` from a provider's data module. + + Results are cached by `module_path` so repeated calls (e.g., from both + `get_available_models` and `get_model_profiles`) reuse the same dict. + Use `clear_caches()` to reset. + + Locates the package on disk with `importlib.util.find_spec` and loads *only* + the `_profiles.py` file via `spec_from_file_location`. + + Args: + module_path: Dotted module path (e.g., `"langchain_openai.data._profiles"`). + + Returns: + The `_PROFILES` dictionary from the module, or an empty dict if + the module has no such attribute. + + Raises: + ImportError: If the package is not installed or the profile module + cannot be found on disk. + """ + with _provider_profiles_lock: + cached = _provider_profiles_cache.get(module_path) + if cached is not None: # `is not None` so empty profile dicts are cached + return cached + + parts = module_path.split(".") + package_root = parts[0] + + spec = importlib.util.find_spec(package_root) + if spec is None: + msg = f"Package {package_root} is not installed" + raise ImportError(msg) + + # Determine the package directory from the spec. + if spec.origin: + package_dir = Path(spec.origin).parent + elif spec.submodule_search_locations: + package_dir = Path(next(iter(spec.submodule_search_locations))) + else: + msg = f"Cannot determine location for {package_root}" + raise ImportError(msg) + + # Build the path to the target file (e.g., data/_profiles.py). + relative_parts = parts[1:] # ["data", "_profiles"] + profiles_path = package_dir.joinpath( + *relative_parts[:-1], f"{relative_parts[-1]}.py" + ) + + if not profiles_path.exists(): + msg = f"Profile module not found: {profiles_path}" + raise ImportError(msg) + + file_spec = importlib.util.spec_from_file_location(module_path, profiles_path) + if file_spec is None or file_spec.loader is None: + msg = f"Could not create module spec for {profiles_path}" + raise ImportError(msg) + + module = importlib.util.module_from_spec(file_spec) + file_spec.loader.exec_module(module) + profiles = getattr(module, "_PROFILES", {}) + _provider_profiles_cache[module_path] = profiles + return profiles + + +def _profile_module_from_class_path(class_path: str) -> str | None: + """Derive the profile module path from a `class_path` config value. + + Args: + class_path: Fully-qualified class in `module.path:ClassName` format. + + Returns: + Dotted module path like `langchain_baseten.data._profiles`, or None + if `class_path` is malformed. + """ + if ":" not in class_path: + return None + module_part, _ = class_path.split(":", 1) + package_root = module_part.split(".", maxsplit=1)[0] + if not package_root: + return None + return f"{package_root}.data._profiles" + + +def get_available_models() -> dict[str, list[str]]: + """Get available models dynamically from installed LangChain provider packages. + + Imports model profiles from each provider package and extracts model names. + + Results are cached after the first call; use `clear_caches()` to reset. + + Returns: + Dictionary mapping provider names to lists of model identifiers. + Includes providers from the langchain registry, config-file + providers with explicit model lists, and `class_path` providers + whose packages expose a `_profiles` module. + """ + global _available_models_cache # noqa: PLW0603 # Module-level cache requires global statement + if _available_models_cache is not None: + return _available_models_cache + + available: dict[str, list[str]] = {} + config = ModelConfig.load() + + # Try to load from langchain provider profile data. + # Build the list dynamically from langchain's supported-provider registry + # so new providers are picked up automatically when langchain adds them. + provider_modules = _get_provider_profile_modules() + registry_providers: set[str] = set() + + for provider, module_path in provider_modules: + registry_providers.add(provider) + # Skip providers explicitly disabled in config. + if not config.is_provider_enabled(provider): + logger.debug( + "Provider '%s' is disabled in config; skipping registry discovery", + provider, + ) + continue + try: + profiles = _load_provider_profiles(module_path) + except ImportError: + logger.debug( + "Could not import profiles from %s (package may not be installed)", + module_path, + ) + continue + except Exception: + logger.warning( + "Failed to load profiles from %s, skipping provider '%s'", + module_path, + provider, + exc_info=True, + ) + continue + + # Filter to models that support tool calling and text I/O. + models = [ + name + for name, profile in profiles.items() + if profile.get("tool_calling", False) + and profile.get("text_inputs", True) is not False + and profile.get("text_outputs", True) is not False + ] + + models.sort() + if models: + available[provider] = models + + # Merge in models from config file (custom providers like ollama, fireworks) + for provider_name, provider_config in config.providers.items(): + # Respect enabled = false (hide provider entirely). + if not config.is_provider_enabled(provider_name): + logger.debug( + "Provider '%s' is disabled in config; skipping", + provider_name, + ) + continue + + config_models = list(provider_config.get("models", [])) + + # For class_path providers not in the built-in registry, auto-discover + # models from the package's _profiles.py when no explicit models list. + if ( + not config_models + and provider_name not in registry_providers + and provider_name not in available + ): + class_path = provider_config.get("class_path", "") + profile_module = _profile_module_from_class_path(class_path) + if profile_module: + try: + profiles = _load_provider_profiles(profile_module) + except ImportError: + logger.debug( + "Could not import profiles from %s for class_path " + "provider '%s' (package may not be installed)", + profile_module, + provider_name, + ) + except Exception: + logger.warning( + "Failed to load profiles from %s for class_path provider '%s'", + profile_module, + provider_name, + exc_info=True, + ) + else: + config_models = sorted( + name + for name, profile in profiles.items() + if profile.get("tool_calling", False) + and profile.get("text_inputs", True) is not False + and profile.get("text_outputs", True) is not False + ) + + if provider_name not in available: + if config_models: + available[provider_name] = config_models + else: + # Append any config models not already discovered + existing = set(available[provider_name]) + for model in config_models: + if model not in existing: + available[provider_name].append(model) + + # `langchain-ollama` ships no profile data, so the steps above leave the + # switcher empty unless the user hand-curates `models = [...]` in config. + # Probe the daemon for installed models and merge them in, + # preserving explicit config order (config wins) with discoveries appended. + # Cached alongside the rest of `available`; refresh by + # calling `clear_caches()` (e.g. via the `/reload` slash command). + if ( + _ollama_discovery_enabled() + and "ollama" in registry_providers + and config.is_provider_enabled("ollama") + and importlib.util.find_spec("langchain_ollama") is not None + ): + endpoint = _get_provider_endpoint("ollama", config) + discovered = _get_ollama_installed_models(endpoint) + if discovered: + available["ollama"] = list( + dict.fromkeys([*available.get("ollama", []), *discovered]) + ) + else: + logger.debug( + "Ollama discovery returned no models for %s; " + "daemon may be down or have no pulls", + endpoint or OLLAMA_DEFAULT_BASE_URL, + ) + + # Mirror the curated `CODEX_MODELS` subset of `openai` models under a + # dedicated `openai_codex` provider entry so the switcher offers them under + # their own ChatGPT-OAuth auth context. Eligibility is filtered by the + # allowlist because the Codex backend serves a narrower lineup than the + # full `openai` API and rejects unsupported models at call time. + if config.is_provider_enabled(CODEX_PROVIDER): + openai_models = available.get("openai") + if openai_models: + mirrored = [name for name in openai_models if name in CODEX_MODELS] + codex_models = list( + dict.fromkeys([*available.get(CODEX_PROVIDER, []), *mirrored]) + ) + # Place `openai_codex` directly after `openai` so the switcher + # keeps the two OpenAI-backed providers adjacent (codex before + # azure_openai etc.) instead of trailing it at the end of the + # dict. dict insertion order is the switcher's display order, so + # rebuild the dict, dropping any prior codex entry and re-inserting + # it right after `openai`. + reordered: dict[str, list[str]] = {} + for name, models in available.items(): + if name == CODEX_PROVIDER: + continue + reordered[name] = models + if name == "openai": + reordered[CODEX_PROVIDER] = codex_models + available = reordered + + _available_models_cache = available + return available + + +def _build_entry( + base: dict[str, Any], + overrides: dict[str, Any], + cli_override: dict[str, Any] | None, +) -> ModelProfileEntry: + """Build a profile entry by merging base, overrides, and app override. + + Args: + base: Upstream profile dict (empty for config-only models). + overrides: `config.toml` profile overrides. + cli_override: Extra fields from `--profile-override`. + + Returns: + Profile entry with merged data and override tracking. + """ + merged = {**base, **overrides} + overridden_keys = set(overrides) + if cli_override: + merged = {**merged, **cli_override} + overridden_keys |= set(cli_override) + return ModelProfileEntry( + profile=merged, + overridden_keys=frozenset(overridden_keys), + ) + + +def get_model_profiles( + *, + cli_override: dict[str, Any] | None = None, +) -> Mapping[str, ModelProfileEntry]: + """Load upstream profiles merged with config.toml overrides. + + Keyed by `provider:model` spec string. Each entry contains the + merged profile dict and the set of keys overridden by config.toml. + + Unlike `get_available_models()`, this includes all models from upstream + profiles regardless of capability filters (tool calling, text I/O). + + Results are cached; use `clear_caches()` to reset. When `cli_override` is + provided the result is stored in a single-slot cache keyed by + `id(cli_override)`. This relies on the caller retaining the same dict + object for the session (the app stores it once on the app instance); + passing a different dict with the same contents will bypass the cache + and overwrite the previous entry. + + Args: + cli_override: Extra profile fields from `--profile-override`. + + When provided, these are merged on top of every profile entry + (after upstream + config.toml) and their keys are added to + `overridden_keys`. + + Returns: + Read-only mapping of spec strings to profile entries. + """ + global _profiles_cache, _profiles_override_cache # noqa: PLW0603 # Module-level caches require global statement + if cli_override is None and _profiles_cache is not None: + return _profiles_cache + if cli_override is not None and _profiles_override_cache is not None: + cached_id, cached_result = _profiles_override_cache + if cached_id == id(cli_override): + return cached_result + + result: dict[str, ModelProfileEntry] = {} + config = ModelConfig.load() + + # Collect upstream profiles from provider packages. + seen_specs: set[str] = set() + provider_modules = _get_provider_profile_modules() + registry_providers: set[str] = set() + for provider, module_path in provider_modules: + registry_providers.add(provider) + # Skip providers explicitly disabled in config. + if not config.is_provider_enabled(provider): + logger.debug( + "Provider '%s' is disabled in config; skipping profiles", + provider, + ) + continue + try: + profiles = _load_provider_profiles(module_path) + except ImportError: + logger.debug( + "Could not import profiles from %s for provider '%s'", + module_path, + provider, + ) + continue + except Exception: + logger.warning( + "Failed to load profiles from %s for provider '%s'", + module_path, + provider, + exc_info=True, + ) + continue + + for model_name, upstream_profile in profiles.items(): + spec = f"{provider}:{model_name}" + seen_specs.add(spec) + overrides = config.get_profile_overrides(provider, model_name=model_name) + result[spec] = _build_entry(upstream_profile, overrides, cli_override) + # Mirror the curated `CODEX_MODELS` subset of openai profiles under + # the `openai_codex` provider so `/model openai_codex:` + # resolves to the same upstream profile without duplicating data. + # Filtered by the allowlist — see the note in `get_available_models`. + if ( + provider == "openai" + and model_name in CODEX_MODELS + and config.is_provider_enabled(CODEX_PROVIDER) + ): + codex_spec = f"{CODEX_PROVIDER}:{model_name}" + seen_specs.add(codex_spec) + codex_overrides = config.get_profile_overrides( + CODEX_PROVIDER, model_name=model_name + ) + result[codex_spec] = _build_entry( + upstream_profile, codex_overrides, cli_override + ) + + # Add config-only models and class_path provider profiles. + for provider_name, provider_config in config.providers.items(): + if not config.is_provider_enabled(provider_name): + logger.debug( + "Provider '%s' is disabled in config; skipping profiles", + provider_name, + ) + continue + # For class_path providers not in the built-in registry, load + # upstream profiles from the package's _profiles.py. + if provider_name not in registry_providers: + class_path = provider_config.get("class_path", "") + profile_module = _profile_module_from_class_path(class_path) + if profile_module: + try: + pkg_profiles = _load_provider_profiles(profile_module) + except ImportError: + logger.debug( + "Could not import profiles from %s for class_path " + "provider '%s' (package may not be installed)", + profile_module, + provider_name, + ) + except Exception: + logger.warning( + "Failed to load profiles from %s for class_path provider '%s'", + profile_module, + provider_name, + exc_info=True, + ) + else: + for model_name, upstream_profile in pkg_profiles.items(): + spec = f"{provider_name}:{model_name}" + seen_specs.add(spec) + overrides = config.get_profile_overrides( + provider_name, model_name=model_name + ) + result[spec] = _build_entry( + upstream_profile, overrides, cli_override + ) + + config_models = provider_config.get("models", []) + for model_name in config_models: + spec = f"{provider_name}:{model_name}" + if spec not in seen_specs: + overrides = config.get_profile_overrides( + provider_name, model_name=model_name + ) + result[spec] = _build_entry({}, overrides, cli_override) + + # `langchain-ollama` does not ship static profile data. When discovery is + # enabled, ask the daemon for model metadata so the selector can show + # context length and capabilities for locally pulled models. + if ( + _ollama_discovery_enabled() + and "ollama" in registry_providers + and config.is_provider_enabled("ollama") + and importlib.util.find_spec("langchain_ollama") is not None + ): + endpoint = _get_provider_endpoint("ollama", config) + discovered_model_names = _get_ollama_installed_models(endpoint) + configured_model_names = [ + spec.removeprefix("ollama:") + for spec in result + if spec.startswith("ollama:") + ] + model_names = list( + dict.fromkeys([*configured_model_names, *discovered_model_names]) + ) + if model_names: + discovered_profiles = _fetch_ollama_installed_model_profiles( + endpoint, + model_names, + ) + for model_name in model_names: + profile = discovered_profiles.get(model_name, {}) + spec = f"ollama:{model_name}" + existing = result.get(spec) + base = dict(existing["profile"]) if existing is not None else {} + base.update(profile) + overrides = config.get_profile_overrides( + "ollama", model_name=model_name + ) + result[spec] = _build_entry(base, overrides, cli_override) + seen_specs.add(spec) + + frozen = MappingProxyType(result) + if cli_override is None: + _profiles_cache = frozen + else: + _profiles_override_cache = (id(cli_override), frozen) + return frozen + + +_LOCAL_HOSTNAMES: frozenset[str] = frozenset( + { + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", # noqa: S104 # hostname comparison, not socket binding + } +) + + +def _is_local_endpoint(url: object) -> bool: + """Return whether a provider endpoint points at the local machine. + + Accepts `object` rather than `str | None` because the endpoint originates + from untyped TOML; the `isinstance` guard below defends against drift. + """ + if not url: + return True + if not isinstance(url, str): + return False + + # Bare hostname literal (no scheme, no port) — short-circuit so IPv6 + # forms like `::1` don't get misparsed by urlparse. + if url in _LOCAL_HOSTNAMES: + return True + + candidate = url if "://" in url else f"http://{url}" + try: + parsed = urlparse(candidate) + except ValueError: + return False + return parsed.hostname in _LOCAL_HOSTNAMES + + +def _get_provider_endpoint(provider: str, config: ModelConfig) -> str | None: + """Return a provider endpoint from config or provider-specific env vars.""" + base_url = config.get_base_url(provider) + if base_url: + return base_url + + host_env = PROVIDER_HOST_ENV.get(provider) + if not host_env: + return None + return resolve_env_var(host_env) + + +_OLLAMA_DISCOVERY_FALSY: frozenset[str] = frozenset({"0", "false", "no", "off"}) +"""Normalized values that disable Ollama discovery when set in `OLLAMA_DISCOVERY`.""" + +_OLLAMA_DISCOVERY_TRUTHY: frozenset[str] = frozenset({"1", "true", "yes", "on"}) +"""Normalized values that enable Ollama discovery when set in `OLLAMA_DISCOVERY`.""" + + +def _ollama_discovery_enabled() -> bool: + """Return whether Ollama model/profile discovery may run. + + Defaults to enabled. Opt out via `_env_vars.OLLAMA_DISCOVERY` set to a + falsy value (`0`, `false`, `no`, `off`); truthy values (`1`, `true`, + `yes`, `on`) explicitly enable. Unrecognized values warn and fall through + to the default because the user clearly tried to configure something. + """ + raw = resolve_env_var(_env_vars.OLLAMA_DISCOVERY) + if raw is None: + return True + normalized = raw.strip().lower() + if normalized in _OLLAMA_DISCOVERY_FALSY: + return False + if normalized in _OLLAMA_DISCOVERY_TRUTHY: + return True + logger.warning( + "Unrecognized value for %s: %r; expected one of %s. Defaulting to enabled.", + _env_vars.OLLAMA_DISCOVERY, + raw, + sorted(_OLLAMA_DISCOVERY_FALSY | _OLLAMA_DISCOVERY_TRUTHY), + ) + return True + + +def _get_ollama_installed_models(endpoint: str | None) -> list[str]: + """Return cached Ollama model names for `endpoint`. + + The result is cached when the daemon returns models, and also when a local + daemon definitively refuses the TCP presence preflight, so the two startup + callers (`get_available_models` and `get_model_profiles`) share a single + probe and a single "not detected" log line per reload. A reachable daemon + that reports no models -- and one whose preflight is merely ambiguous (a + connect timeout) -- is left uncached so a later pull can still be discovered + without `/reload`. + + Args: + endpoint: Base URL of the Ollama daemon. When `None`, defaults to + `OLLAMA_DEFAULT_BASE_URL`. + + Returns: + Sorted list of model names reported by `/api/tags`. + """ + key = (endpoint or OLLAMA_DEFAULT_BASE_URL).rstrip("/") + cached = _ollama_installed_models_cache.get(key) + if cached is not None: + return list(cached) + models = _fetch_ollama_installed_models(endpoint) + if models or key in _ollama_unreachable_endpoints: + _ollama_installed_models_cache[key] = models + return list(models) + + +def _ollama_host_reachable( + base: str, *, timeout: float = OLLAMA_DISCOVERY_TIMEOUT_SECONDS +) -> bool: + """Return whether a TCP listener appears to accept connections at `base`. + + A lightweight presence preflight so Ollama discovery can skip the HTTP + probe entirely when no daemon is running (e.g. Ollama is not installed). + The check opens and immediately closes a TCP connection to the endpoint's + host and port. A *definitive* failure -- connection refused, DNS error, or + sockets blocked under `pytest-socket` -- reports "not reachable" so + discovery falls back gracefully (and the caller may negatively cache it). A + *connect timeout* is ambiguous -- a present-but-slow or still-booting daemon + times out just like an absent one -- so it defers to the HTTP probe + (reports "reachable") rather than being cached as absent. An unexpected + (non-`OSError`) failure is additionally logged at warning so a real bug + isn't misreported as absence. + + Args: + base: Base URL of the Ollama daemon, e.g. `http://localhost:11434`. + timeout: Socket connection timeout in seconds. + + Returns: + `True` when a connection is established (a daemon appears present) or + when presence cannot be determined -- unparseable target or a + connect timeout -- so the caller defers to the HTTP probe; `False` + when the connection is definitively refused. + """ + import socket + + parsed = urlparse(base) + host = parsed.hostname + if not host: + # Can't determine a target host; let the HTTP probe make the decision. + return True + try: + port = parsed.port + except ValueError: + # Malformed port (out of range / non-numeric); defer to the HTTP probe. + return True + if port is None: + port = 443 if parsed.scheme == "https" else 80 + # Expected transport failures split by how definitive they are. A refusal + # (`ECONNREFUSED` and friends -- an `OSError`) is a fast, certain "nothing + # is listening", so it reports absent and lets the caller negatively cache + # it. A connect *timeout* is ambiguous (present-but-slow vs. absent-and- + # firewalled), so it defers to the HTTP probe rather than being cached as + # absent and stuck until the next reload. `TimeoutError` is an `OSError` + # subclass, so its branch must precede the broad `OSError` one. Anything + # non-`OSError` is surfaced at warning so a real bug isn't misreported as + # "not detected"; `pytest-socket`'s `SocketBlockedError` inherits from + # `Exception` (not `OSError`), so the broad branch catches it. The socket + # is its own context manager, so `with` closes the probe connection. + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except TimeoutError: + return True + except OSError: + return False + except Exception as exc: # noqa: BLE001 # see comment above + logger.warning( + "Ollama presence preflight raised unexpected %s for %s: %s", + type(exc).__name__, + base, + exc, + ) + return False + + +def _fetch_ollama_installed_models( + endpoint: str | None, + *, + timeout: float = OLLAMA_DISCOVERY_TIMEOUT_SECONDS, +) -> list[str]: + """Discover models installed in a local or hosted Ollama daemon. + + Issues a `GET {endpoint}/api/tags` and returns the sorted list of model + names reported by the daemon. The probe is best-effort: any error + (timeout, connection refused, malformed JSON) yields an empty list and is + logged at debug level so the model switcher can fall back gracefully. + + When probing a local endpoint and `OLLAMA_API_KEY` (or the + `DEEPAGENTS_CODE_`-prefixed variant) is set, its value is forwarded as a + `Bearer` token. Discovery never forwards credentials to non-local endpoints. + + Args: + endpoint: Base URL of the Ollama daemon. When `None`, defaults to + `OLLAMA_DEFAULT_BASE_URL`. A trailing `/` is tolerated. + timeout: Socket timeout in seconds. + + Returns: + Sorted list of model names; empty when the daemon is unreachable or + returns no models. + """ + import json + from urllib.error import URLError + from urllib.request import Request, urlopen + + base = (endpoint or OLLAMA_DEFAULT_BASE_URL).rstrip("/") + if not base.startswith(("http://", "https://")): + logger.warning( + "Skipping Ollama discovery: %r has no http:// or https:// scheme. " + "Set base_url or OLLAMA_HOST to e.g. http://localhost:11434.", + base, + ) + return [] + + # Presence preflight (local endpoints only -- remote hosts may be reachable + # only through a proxy that the HTTP probe honors but a raw socket does + # not). A dead/absent daemon (the common case when Ollama is not installed) + # refuses the connection; detecting that here lets us skip the HTTP probe + # and log a quiet "not detected" line instead of a misleading + # "discovery failed ... Connection refused" debug line. + if _is_local_endpoint(base) and not _ollama_host_reachable(base, timeout=timeout): + logger.debug("Ollama daemon not detected at %s; skipping discovery", base) + _ollama_unreachable_endpoints.add(base) + return [] + + url = f"{base}/api/tags" + + headers = _ollama_discovery_headers(base, content_type=False) + request = Request(url, headers=headers) # noqa: S310 # scheme guarded above + # Catch-all is intentional: discovery is best-effort and must never break + # the model selector. The narrow tuple is fully subsumed by `Exception` + # below; we keep it only to log expected transport failures at debug while + # surfacing unexpected ones at warning so a real bug doesn't disappear. + # Notably catches `pytest-socket`'s `SocketBlockedError`, which inherits + # from `Exception` (not `OSError`) and would otherwise propagate during + # unit tests run with `--disable-socket`. `KeyboardInterrupt` and + # `SystemExit` derive from `BaseException` and bypass both branches. + try: + with urlopen(request, timeout=timeout) as response: # noqa: S310 # scheme guarded above + payload = json.loads(response.read().decode("utf-8")) + except (URLError, TimeoutError, OSError, ValueError) as exc: + logger.debug("Ollama model discovery failed for %s: %s", url, exc) + return [] + except Exception as exc: # noqa: BLE001 # see comment above + logger.warning( + "Ollama model discovery raised unexpected %s for %s: %s", + type(exc).__name__, + url, + exc, + ) + return [] + + if not isinstance(payload, dict) or not isinstance(payload.get("models"), list): + logger.debug( + "Ollama discovery: %s returned unexpected payload shape (%s); " + "endpoint may not be an Ollama daemon", + url, + type(payload).__name__, + ) + return [] + + names: list[str] = [] + for entry in payload["models"]: + if isinstance(entry, dict): + name = entry.get("name") + if isinstance(name, str) and name: + names.append(name) + names.sort() + return names + + +def _ollama_discovery_headers(endpoint: str, *, content_type: bool) -> dict[str, str]: + """Build headers for Ollama discovery requests. + + Args: + endpoint: Base URL for the discovery request. + content_type: Whether to include a JSON `Content-Type` header. + + Returns: + HTTP headers including optional bearer auth for local endpoints. + """ + headers: dict[str, str] = {"Accept": "application/json"} + if content_type: + headers["Content-Type"] = "application/json" + optional_env = OPTIONAL_AUTH_ENV.get("ollama") + if optional_env and _is_local_endpoint(endpoint): + api_key = resolve_env_var(optional_env) + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return headers + + +def _coerce_positive_int(value: object) -> int | None: + """Return `value` as a positive integer, or `None` when unavailable.""" + if isinstance(value, bool): + return None + if isinstance(value, int) and value > 0: + return value + if isinstance(value, float) and value > 0 and value.is_integer(): + return int(value) + if isinstance(value, str): + try: + parsed = int(value) + except ValueError: + return None + if parsed > 0: + return parsed + return None + + +def _profile_from_ollama_show_payload(payload: object) -> dict[str, Any]: + """Extract LangChain-style profile fields from an Ollama `/api/show` payload. + + Args: + payload: Decoded JSON response from `POST /api/show`. + + Returns: + Profile fields understood by the model selector, such as + `max_input_tokens` and `tool_calling`. + """ + if not isinstance(payload, dict): + return {} + payload_dict = cast("dict[str, object]", payload) + + profile: dict[str, Any] = {} + model_info = payload_dict.get("model_info") + if isinstance(model_info, dict): + context_lengths = [ + length + for key, value in model_info.items() + if isinstance(key, str) + and (key == "context_length" or key.endswith(".context_length")) + and (length := _coerce_positive_int(value)) is not None + ] + if context_lengths: + profile["max_input_tokens"] = max(context_lengths) + + capabilities = payload_dict.get("capabilities") + if isinstance(capabilities, list): + capability_names = {item for item in capabilities if isinstance(item, str)} + if "completion" in capability_names: + profile["text_inputs"] = True + profile["text_outputs"] = True + if "tools" in capability_names: + profile["tool_calling"] = True + if "thinking" in capability_names: + profile["reasoning_output"] = True + + if not profile and ("model_info" in payload_dict or "capabilities" in payload_dict): + logger.debug( + "Ollama profile discovery returned a payload with no recognized " + "profile fields; top-level keys: %s", + sorted(str(key) for key in payload_dict), + ) + + return profile + + +def _fetch_ollama_installed_model_profiles( + endpoint: str | None, + model_names: list[str], + *, + timeout: float = OLLAMA_DISCOVERY_TIMEOUT_SECONDS, +) -> dict[str, dict[str, Any]]: + """Discover profile metadata for installed Ollama models. + + Issues `POST {endpoint}/api/show` for each model. The probe is best-effort: + failures for one model are logged and do not stop profile discovery for the + remaining models. + + Args: + endpoint: Base URL of the Ollama daemon. When `None`, defaults to + `OLLAMA_DEFAULT_BASE_URL`. A trailing `/` is tolerated. + model_names: Model names to inspect. + timeout: Socket timeout in seconds. + + Returns: + Mapping of model name to extracted profile fields. + """ + import json + from urllib.error import URLError + from urllib.request import Request, urlopen + + base = (endpoint or OLLAMA_DEFAULT_BASE_URL).rstrip("/") + if not base.startswith(("http://", "https://")): + logger.warning( + "Skipping Ollama profile discovery: %r has no http:// or https:// scheme. " + "Set base_url or OLLAMA_HOST to e.g. http://localhost:11434.", + base, + ) + return {} + + url = f"{base}/api/show" + profiles: dict[str, dict[str, Any]] = {} + headers = _ollama_discovery_headers(base, content_type=True) + + for model_name in model_names: + cache_key = (base, model_name) + cached = _ollama_model_profiles_cache.get(cache_key) + if cached is not None: + profiles[model_name] = dict(cached) + continue + + body = json.dumps({"model": model_name}).encode("utf-8") + request = Request( # noqa: S310 # scheme guarded above + url, + data=body, + headers=headers, + method="POST", + ) + try: + with urlopen(request, timeout=timeout) as response: # noqa: S310 # scheme guarded above + payload = json.loads(response.read().decode("utf-8")) + except (URLError, TimeoutError, OSError, ValueError) as exc: + logger.debug( + "Ollama profile discovery failed for %s via %s: %s", + model_name, + url, + exc, + ) + continue + except Exception as exc: # noqa: BLE001 # see _fetch_ollama_installed_models + logger.warning( + "Ollama profile discovery raised unexpected %s for %s via %s: %s", + type(exc).__name__, + model_name, + url, + exc, + ) + continue + + profile = _profile_from_ollama_show_payload(payload) + if profile: + _ollama_model_profiles_cache[cache_key] = profile + profiles[model_name] = profile + + return profiles + + +def _has_stored_credential(provider: str) -> bool: + """Return whether `provider` has a credential persisted via `/auth`. + + A corrupt `auth.json` is swallowed (logged, treated as absent) so the + model selector and other read-side callers can keep listing providers. + The user-visible signal lives in `AuthManagerScreen` — opening `/auth` + surfaces a corruption banner directly. Read-side resilience here means + you can still pick a different provider while the file is broken. + """ + try: + return auth_store.get_stored_key(provider) is not None + except RuntimeError: + logger.warning( + "Could not read stored credentials for provider %s; treating as absent", + provider, + ) + return False + + +def resolve_provider_credential(provider: str) -> str | None: + """Resolve the credential value for `provider` from any configured source. + + Lookup order: + + 1. Stored API key in `~/.deepagents/.state/auth.json` (added via `/auth`). + 2. Canonical env var via `resolve_env_var()` (which honors the + `DEEPAGENTS_CODE_` prefix and dotenv files). + + A user who has *both* a stored key and an env var set gets the stored + key — entering one in the TUI is the more deliberate, more recent + action, so "I just typed this in" beats whatever the shell exported. + + Args: + provider: Provider name (e.g., `"anthropic"`). + + Returns: + The credential value, or `None` when no source has one or the + provider has no env-var mapping at all. + """ + try: + stored = auth_store.get_stored_key(provider) + except RuntimeError: + logger.warning( + "Could not read stored credentials for provider %s; falling back to env", + provider, + ) + stored = None + if stored: + return stored + env_var = get_credential_env_var(provider) + if env_var: + return resolve_env_var(env_var) + return None + + +def _resolve_gateway_configured(provider: str) -> ProviderAuthStatus | None: + """Return `CONFIGURED` when LangSmith Gateway can authenticate a provider. + + Credential preflight normally requires each provider's native API key + (for example `OPENAI_API_KEY`). Users who route traffic through the + LangSmith LLM Gateway often set only `LANGSMITH_GATEWAY` and + `LANGSMITH_GATEWAY_API_KEY`. Without this fallback, model selection and + startup treat those models as missing credentials even though the + gateway-aware chat integration will authenticate the request. + + Example: + A user enables the gateway with: + + LANGSMITH_GATEWAY=true + LANGSMITH_GATEWAY_API_KEY=lsv2_... + + and has no `OPENAI_API_KEY`. Selecting `openai:gpt-5.5` should still + pass preflight because OpenAI is a gateway-supported provider and + both gateway env vars are present. + + The gateway counts only when all of the following hold: + + - `provider` is in `LANGSMITH_GATEWAY_PROVIDERS` (built-in chats that + actually read the gateway env vars) + - `LANGSMITH_GATEWAY` is set and is not a disable value + (`false` / `0` / `no`) + - `LANGSMITH_GATEWAY_API_KEY` is non-empty + + Callers must still skip this path for `class_path` provider overrides: + those construct an arbitrary class that need not consume the gateway + variables, so their own `api_key_env` preflight has to stand alone. + + Args: + provider: Provider name (e.g., `"openai"`, `"anthropic"`). + + Returns: + A `CONFIGURED` status pointing at `LANGSMITH_GATEWAY_API_KEY`, or + `None` when the gateway cannot authenticate this provider. + """ + gateway = os.getenv(LANGSMITH_GATEWAY_ENV) + gateway_key = os.getenv(LANGSMITH_GATEWAY_API_KEY_ENV) + if ( + provider not in LANGSMITH_GATEWAY_PROVIDERS + or not gateway + or gateway.lower() in _LANGSMITH_GATEWAY_FALSE_VALUES + or not gateway_key + ): + return None + return ProviderAuthStatus( + state=ProviderAuthState.CONFIGURED, + provider=provider, + env_var=LANGSMITH_GATEWAY_API_KEY_ENV, + source=ProviderAuthSource.ENV, + detail="LangSmith Gateway credentials set", + ) + + +def _resolve_configured(provider: str, env_var: str) -> ProviderAuthStatus | None: + """Return a `CONFIGURED` status if a stored or env credential is set. + + Stored credentials beat env vars (matches `resolve_provider_credential`). + + Args: + provider: Provider name (e.g., `"anthropic"`). + env_var: Canonical env var name to check when no stored credential + exists. Recorded on the returned status either way. + + Returns: + A `CONFIGURED` status, or `None` when neither source is set. + """ + if _has_stored_credential(provider): + return ProviderAuthStatus( + state=ProviderAuthState.CONFIGURED, + provider=provider, + env_var=env_var, + source=ProviderAuthSource.STORED, + detail="stored credential", + ) + if resolve_env_var(env_var): + return ProviderAuthStatus( + state=ProviderAuthState.CONFIGURED, + provider=provider, + env_var=env_var, + source=ProviderAuthSource.ENV, + detail="credentials set", + ) + return None + + +def _get_codex_auth_status() -> ProviderAuthStatus: + """Translate the ChatGPT OAuth on-disk state into a `ProviderAuthStatus`. + + The codex provider uses a file-backed OAuth token store rather than + `auth_store`'s API-key map, so it gets its own branch in + `get_provider_auth_status`. The `STORED` source is reused only to satisfy + the `ProviderAuthStatus` "CONFIGURED implies a source" invariant; it is + cosmetic here, since `format_auth_badge` routes the codex provider to its + own `[chatgpt]` / `[sign in to chatgpt]` badge before the source is ever + consulted. + + Returns: + `CONFIGURED` / `STORED` when a token bundle sits at the upstream + default store path; `MISSING` otherwise. Expired access tokens + are still reported as configured because the file-backed model + provider can refresh them with the saved refresh token when the + model is constructed. + """ + from deepagents_code.integrations import openai_codex + + status = openai_codex.get_status() + if status.unreadable_reason: + return ProviderAuthStatus( + state=ProviderAuthState.MISSING, + provider=CODEX_PROVIDER, + detail=f"token store unreadable: {status.unreadable_reason}", + ) + if not status.logged_in: + return ProviderAuthStatus( + state=ProviderAuthState.MISSING, + provider=CODEX_PROVIDER, + detail="not signed in to ChatGPT", + ) + detail = "signed in to ChatGPT" + if status.plan_type: + detail = f"signed in to ChatGPT ({status.plan_type})" + if status.is_expired: + detail = f"{detail}; access token will refresh on use" + return ProviderAuthStatus( + state=ProviderAuthState.CONFIGURED, + provider=CODEX_PROVIDER, + source=ProviderAuthSource.STORED, + detail=detail, + ) + + +def get_provider_auth_status(provider: str) -> ProviderAuthStatus: + """Return credential readiness details for a provider. + + Combines config, well-known provider metadata, optional provider auth, + and implicit-auth provider metadata before attempting model creation: + + 1. **Config-file providers** (`config.toml` + `[models.providers.]`): + - If the section declares `api_key_env`, that env var is checked + via `resolve_env_var()` (which honors `DEEPAGENTS_CODE_` prefixes). + - If the section has `class_path` but no `api_key_env`, the provider is + assumed to manage its own auth (e.g., custom headers, JWT, mTLS). + - If neither `api_key_env` nor `class_path` is set, falls through + to provider-specific defaults. + 2. **Hardcoded registry** (`PROVIDER_API_KEY_ENV`): a module-level dict + mapping well-known provider names to their canonical env var + (e.g., `"anthropic"` → `"ANTHROPIC_API_KEY"`). The env var is checked + via `resolve_env_var()`. + 3. **Implicit auth providers** (e.g., Vertex AI ADC): a missing env var is + not treated as missing credentials. + 4. **Optional auth env vars** (`OPTIONAL_AUTH_ENV`): when present, mark + the provider as configured for hosted/cloud use. + 5. **No-auth-required providers** (`NO_AUTH_REQUIRED_PROVIDERS`): default + local endpoints report `NOT_REQUIRED`; non-local endpoints fall back + to `UNKNOWN` so the SDK can decide. + 6. **Unknown providers** not present in any source defer auth failures to + the provider SDK. + + Use `has_provider_credentials()` when compatibility with the historic + `True`/`False`/`None` contract is required. + + Args: + provider: Provider name (e.g., `"anthropic"`, `"openai"`). + + Returns: + Provider auth status for selectors, startup checks, and compatibility + wrappers. + """ + # ChatGPT-OAuth-backed codex provider has no env var and stores tokens + # in its own on-disk JSON; route it through a dedicated helper before + # the standard config / env-var lookup so callers get the codex-specific + # `[chatgpt]` / `[sign in to chatgpt]` badge and a "signed in as " + # detail. + if provider == CODEX_PROVIDER: + return _get_codex_auth_status() + + # Config-file providers take priority when api_key_env is specified. + config = ModelConfig.load() + provider_config = config.providers.get(provider) + if provider_config: + env_var = provider_config.get("api_key_env") + if env_var: + configured = _resolve_configured(provider, env_var) + if configured: + return configured + # The gateway fallback is only valid when the built-in, + # gateway-aware integration will actually be constructed. A + # `class_path` override builds an arbitrary custom class via + # `_create_model_from_class` that need not consume the gateway + # variables, so its own `api_key_env` preflight must stand. + if not provider_config.get("class_path"): + gateway_configured = _resolve_gateway_configured(provider) + if gateway_configured: + return gateway_configured + return ProviderAuthStatus( + state=ProviderAuthState.MISSING, + provider=provider, + env_var=env_var, + detail=f"{env_var} is not set or is empty", + ) + # class_path providers that omit api_key_env manage their own auth + # (e.g., custom headers, JWT, mTLS). + if provider_config.get("class_path"): + return ProviderAuthStatus( + state=ProviderAuthState.MANAGED, + provider=provider, + detail="custom auth", + ) + # No api_key_env in config — fall through to provider-specific and + # hardcoded maps. + + # Fall back to hardcoded well-known providers. + env_var = PROVIDER_API_KEY_ENV.get(provider) + if env_var: + configured = _resolve_configured(provider, env_var) + if configured: + return configured + gateway_configured = _resolve_gateway_configured(provider) + if gateway_configured: + return gateway_configured + if provider in IMPLICIT_AUTH_PROVIDERS: + return ProviderAuthStatus( + state=ProviderAuthState.IMPLICIT, + provider=provider, + env_var=env_var, + detail="implicit auth", + ) + return ProviderAuthStatus( + state=ProviderAuthState.MISSING, + provider=provider, + env_var=env_var, + detail=f"{env_var} is not set or is empty", + ) + + if provider in IMPLICIT_AUTH_PROVIDERS: + return ProviderAuthStatus( + state=ProviderAuthState.IMPLICIT, + provider=provider, + detail="implicit auth", + ) + + optional_env = OPTIONAL_AUTH_ENV.get(provider) + if optional_env: + configured = _resolve_configured(provider, optional_env) + if configured: + return configured + + if provider in NO_AUTH_REQUIRED_PROVIDERS: + endpoint = _get_provider_endpoint(provider, config) + if _is_local_endpoint(endpoint): + return ProviderAuthStatus( + state=ProviderAuthState.NOT_REQUIRED, + provider=provider, + detail="local provider", + ) + # Remote endpoint may or may not require auth (private network vs. + # hosted). Don't block; surface the optional env var as a hint. + detail = ( + f"remote endpoint; set {optional_env} if auth is required" + if optional_env + else "remote endpoint" + ) + return ProviderAuthStatus( + state=ProviderAuthState.UNKNOWN, + provider=provider, + env_var=optional_env, + detail=detail, + ) + + # Provider not found in config or hardcoded map — credential status is + # unknown. The provider itself will report auth failures at + # model-creation time. + logger.debug( + "No credential information for provider '%s'; deferring auth to provider", + provider, + ) + return ProviderAuthStatus( + state=ProviderAuthState.UNKNOWN, + provider=provider, + detail="credentials unknown", + ) + + +def has_provider_credentials(provider: str) -> bool | None: + """Check if credentials are available for a provider. + + This compatibility wrapper preserves the historic tri-state contract while + `get_provider_auth_status()` carries the richer user-facing distinctions: + configured credentials, missing credentials, no-auth local providers, + implicit auth, custom provider-managed auth, and unknown providers. + + Args: + provider: Provider name (e.g., `"anthropic"`, `"openai"`). + + Returns: + `True` if auth is configured, implicit, provider-managed, or not + required. + `False` if a required env var is known but not set. + `None` if credential status cannot be determined. + """ + return get_provider_auth_status(provider).as_legacy_bool() + + +def get_credential_env_var(provider: str) -> str | None: + """Return the env var name that holds credentials for a provider. + + Checks the config file first (user override), then falls back to the + hardcoded `PROVIDER_API_KEY_ENV` map. + + Args: + provider: Provider name. + + Returns: + Environment variable name, or None if unknown. + """ + config = ModelConfig.load() + config_env = config.get_api_key_env(provider) + if config_env: + return config_env + return PROVIDER_API_KEY_ENV.get(provider) + + +def get_base_url_env_vars(provider: str) -> tuple[str, ...]: + """Return base-URL env var names for a provider in resolution order. + + Checks the config file's `base_url_env` first (user override), then falls + back to the hardcoded `PROVIDER_BASE_URL_ENV` map. + + Args: + provider: Provider name. + + Returns: + Environment variable names, or an empty tuple if the provider has no + base-URL env var (config-declared or built-in). + """ + config = ModelConfig.load() + config_env = config.get_base_url_env(provider) + if config_env: + return (config_env,) + return PROVIDER_BASE_URL_ENV.get(provider, ()) + + +def get_base_url_env_var(provider: str) -> str | None: + """Return the canonical base-URL env var name for a provider. + + Checks the config file's `base_url_env` first (user override), then falls + back to the canonical name in the hardcoded `PROVIDER_BASE_URL_ENV` map. + Parallel to `get_credential_env_var`. + + Args: + provider: Provider name. + + Returns: + Environment variable name, or None if the provider has no base-URL env + var (config-declared or built-in). + """ + env_vars = get_base_url_env_vars(provider) + return env_vars[0] if env_vars else None + + +def get_default_base_url_env(provider: str) -> str | None: + """Return the env var that supplies a provider's endpoint when none is stored. + + Answers "what does leaving the `/auth` base-URL field blank fall back to?" + A blank save clears the *plain* endpoint env vars (so an inherited gateway + URL can't leak through — see `apply_stored_credentials`), so the only env + var that still supplies a value afterward is the `DEEPAGENTS_CODE_`-prefixed + one. The name is returned (not its value) for display next to the field, so + the user sees the knob rather than a long or sensitive URL. + + Returns `None` when that variable holds no value — the endpoint then comes + from a `config.toml` literal or the provider SDK's own default, neither of + which is a single env var to name here. + + Args: + provider: Provider name. + + Returns: + The `DEEPAGENTS_CODE_`-prefixed env var name still in effect after a + blank save, or `None`. + """ + for env_var in get_base_url_env_vars(provider): + prefixed = f"{_ENV_PREFIX}{env_var}" + if os.environ.get(prefixed): + return prefixed + return None + + +def is_service(name: str) -> bool: + """Return whether `name` is a non-model service configurable via `/auth`.""" + return name in SERVICE_API_KEY_ENV + + +def is_langsmith(name: str) -> bool: + """Return whether `name` is the LangSmith tracing service. + + Centralizes the identity check so the LangSmith-specific branches (project + field instead of a base URL, tracing auto-enable) share one definition + rather than scattering `== LANGSMITH_SERVICE` comparisons. + """ + return name == LANGSMITH_SERVICE + + +def get_service_auth_status(service: str) -> ProviderAuthStatus: + """Return credential readiness for a non-model service (e.g. `"tavily"`). + + Mirrors `get_provider_auth_status` but is scoped to `SERVICE_API_KEY_ENV`, + so a stored key beats the env var and the `/auth` manager can render the + same `[stored]` / `[env: ...]` / `[missing]` badges. + + Args: + service: Service name (e.g. `"tavily"`). + + Returns: + `CONFIGURED` when a stored or env credential is set, else `MISSING`. + """ + env_var = SERVICE_API_KEY_ENV[service] + configured = _resolve_configured(service, env_var) + if configured: + return configured + return ProviderAuthStatus( + state=ProviderAuthState.MISSING, + provider=service, + env_var=env_var, + detail=f"{env_var} is not set or is empty", + ) + + +def apply_stored_service_credentials() -> None: + """Export every stored service key into `os.environ`. + + Services (e.g. web search via Tavily) have no base URL to reconcile, so + this is a plain key copy onto the canonical env var name the underlying + SDK reads. A stored key takes precedence over an existing plain env var, + matching `apply_stored_credentials`; a `DEEPAGENTS_CODE_`-prefixed override + is left authoritative because the app already treats it as the top-priority + per-session credential. + """ + for service, env_var in SERVICE_API_KEY_ENV.items(): + try: + stored = auth_store.get_stored_key(service) + except RuntimeError: + logger.warning( + "Could not read stored credentials for service %s; the credential " + "file may be corrupt. Re-add the key via /auth.", + service, + ) + continue + if not stored: + continue + prefixed = f"{_ENV_PREFIX}{env_var}" + if prefixed in os.environ: + continue + if os.environ.get(env_var) != stored: + os.environ[env_var] = stored + + +def apply_stored_credentials(provider: str) -> bool: + """Export this provider's stored key *and endpoint* into `os.environ`. + + LangChain's chat-model factories read credentials from process env vars, + so a stored key only takes effect once it's copied onto the env var name + registered for that provider. This is a no-op when the provider has no + env-var mapping (custom auth) or no stored credential. + + The key env var is overwritten whether or not it was already set, matching + the precedence rule documented on `resolve_provider_credential`: a + credential the user typed in `/auth` is the most recent deliberate + action and should take effect. + + Because a key and its endpoint are a coherent pair (a gateway key only + works against the gateway URL; a provider-native key only against the + provider's own endpoint), the base URL is applied atomically with the key: + + - A stored `base_url` is written to the provider's canonical base-URL env + var, and every *other* base-URL name the SDK reads is cleared so an + inherited gateway URL can't leak through an alternate variable. + - No stored `base_url` (the user left the field blank) clears *all* of the + provider's base-URL env vars, so the SDK falls back to the provider + default rather than an inherited gateway URL. This is what prevents a + personal key from being shipped to the gateway. + + Only the unprefixed canonical names are written, so an explicit + `DEEPAGENTS_CODE_{VAR}` override still wins via `resolve_env_var`. + + Args: + provider: Provider name. + + Returns: + `True` if a stored key was applied, `False` otherwise. + """ + env_var = get_credential_env_var(provider) + if not env_var: + return False + try: + stored = auth_store.get_stored_key(provider) + stored_base_url = auth_store.get_stored_base_url(provider) + except RuntimeError: + logger.warning("Could not read stored credentials for provider %s", provider) + return False + if not stored: + return False + # Reconcile the endpoint first: it resolves env-var names (which can touch + # the config) and so is the only step that might raise. Doing it before the + # key write means the key is never left applied while an inherited gateway + # URL stays uncleared — the key and endpoint move together. + _apply_stored_base_url(provider, stored_base_url) + if os.environ.get(env_var) != stored: + os.environ[env_var] = stored + return True + + +def _apply_stored_base_url(provider: str, base_url: str | None) -> None: + """Reconcile a provider's base-URL env vars with a `/auth` credential. + + Writes `base_url` to the canonical name and clears the alternates, or + clears every name when `base_url` is `None` (reset to the provider + default). See `apply_stored_credentials` for the pairing rationale. + + When switching to a provider-native key (no `base_url`), also clears the + provider's custom-headers env var (e.g. `ANTHROPIC_CUSTOM_HEADERS`) so a + gateway-provisioned auth header isn't sent to the native endpoint. + + Args: + provider: Provider name. + base_url: The stored endpoint, or `None` to reset to the default. + """ + canonical = get_base_url_env_var(provider) + # Clear every name the SDK might read: the built-in alternates plus any + # config-declared `base_url_env` (which extends pairing to providers + # outside the hardcoded set). + names = set(PROVIDER_BASE_URL_ENV.get(provider, ())) + if canonical: + names.add(canonical) + if not names: + return + configured_base_url_survives = _configured_base_url_survives_env_clear(provider) + for name in names: + if base_url and name == canonical: + os.environ[name] = base_url + else: + os.environ.pop(name, None) + + # A provider SDK's custom-header env var (e.g. `ANTHROPIC_CUSTOM_HEADERS`) + # injects headers into every request. A gateway-provisioned environment + # often sets it to `X-Api-Key: `, which overrides the SDK's + # own `api_key`-derived header. When switching to a provider-native key + # (no stored `base_url`), that header must also be cleared — otherwise the + # gateway key is sent to the native endpoint and rejected. + custom_headers_env = PROVIDER_CUSTOM_HEADERS_ENV.get(provider) + if custom_headers_env and not base_url: + if not configured_base_url_survives: + if os.environ.pop(custom_headers_env, None) is not None: + # Log the env var name only — never its value, which carries + # auth headers. Surfaces the removal for the user who set a + # header deliberately for the native endpoint and later wonders + # where it went. + logger.info( + "Cleared %s while applying a provider-native %s key", + custom_headers_env, + provider, + ) + elif os.environ.get(custom_headers_env) is not None: + # A provider base URL still routes (config or a prefixed env var), + # so the custom-header env is deliberately kept. Log the name only — + # never the value — so the retention is observable when a user later + # wonders why a gateway header is still in effect after applying a + # native key. + logger.debug( + "Kept %s: a %s base URL is still configured", + custom_headers_env, + provider, + ) + + +def _configured_base_url_survives_env_clear(provider: str) -> bool: + """Return whether endpoint config still routes after plain env cleanup.""" + config = ModelConfig.load() + provider_cfg = config.providers.get(provider) + if provider_cfg and provider_cfg.get("base_url"): + return True + for env_var in get_base_url_env_vars(provider): + if os.environ.get(f"{_ENV_PREFIX}{env_var}"): + return True + return False + + +def warn_on_split_credential_source(provider: str) -> None: + """Log when a provider's key and endpoint resolve from different env tiers. + + The `DEEPAGENTS_CODE_` prefix is a *per-variable* override, not a credential + bundle: setting `DEEPAGENTS_CODE_OPENAI_API_KEY` while leaving the endpoint to + a plain `OPENAI_BASE_URL` makes the key resolve from the prefixed tier and the + endpoint from the unprefixed one. A key and its endpoint are a coherent pair + (see `PROVIDER_BASE_URL_ENV`), so a split source is a likely misconfiguration + -- e.g. a provider-native key shipped to a gateway URL, or vice versa. + + This is purely diagnostic: it never mutates `os.environ` or changes + resolution. Only the env var *names* are logged, never the secret value or + the URL. It is emitted at DEBUG because the `deepagents_code` package logger + only attaches a handler when `DEEPAGENTS_CODE_DEBUG` is set, and DEBUG stays + below `logging.lastResort`'s WARNING stderr threshold so it cannot bleed onto + stderr and corrupt the Textual TUI. The `DEEPAGENTS_CODE_DEBUG` file log is + where someone chasing a wrong-endpoint bug will look. + + A `config.toml` `base_url` literal wins over env vars in `get_base_url`, so + when one is set there is no env-tier split to flag and this returns early. + + Args: + provider: Provider name (e.g. `"openai"`). + """ + key_env = get_credential_env_var(provider) + base_env = get_base_url_env_var(provider) + if not key_env or not base_env: + return + config = ModelConfig.load() + provider_cfg = config.providers.get(provider) + if provider_cfg and provider_cfg.get("base_url"): + return + prefixed_key = f"{_ENV_PREFIX}{key_env}" + prefixed_base = f"{_ENV_PREFIX}{base_env}" + # Key must actually resolve from the prefixed tier (present and non-empty), + # while the endpoint falls back to the plain tier: no prefixed override + # present (an empty prefixed var would shadow the plain one in + # `resolve_env_var`, so its mere presence means the endpoint is not "plain"). + key_from_prefixed = bool(os.environ.get(prefixed_key)) + base_from_plain = prefixed_base not in os.environ and bool(os.environ.get(base_env)) + if key_from_prefixed and base_from_plain: + logger.debug( + "Provider %s: API key resolved from %s but base URL resolved from " + "the unprefixed %s. Key and endpoint came from different sources and " + "may not be a matching pair. Set %s to pin the endpoint, or unset %s.", + provider, + prefixed_key, + base_env, + prefixed_base, + base_env, + ) + + +@dataclass(frozen=True) +class ModelConfig: + """Parsed model configuration from `config.toml`. + + Instances are immutable once constructed. The `providers` mapping is + wrapped in `MappingProxyType` to prevent accidental mutation of the + globally cached singleton returned by `load()`. + """ + + default_model: str | None = None + """The user's intentional default model (from config file `[models].default`).""" + + recent_model: str | None = None + """The most recently switched-to model (from config file `[models].recent`).""" + + providers: Mapping[str, ProviderConfig] = field(default_factory=dict) + """Read-only mapping of provider names to their configurations.""" + + auto_classifier_model: str | None = None + """The stored Auto classifier model (from config file `[models].auto_classifier`). + + Carries the raw string with only a `str` type guard, so a blank or + unbuildable spec reaches this field. Its only *value* consumer is the + `/auto model` picker's `(default)` marker, which needs the stored text + rather than a resolved model, so this field never rewrites or drops a value: + `_validate` logs a warning when the text lacks a `provider:` prefix, + `config.resolve_auto_classifier_model_with_problem` rejects a blank or + non-string value at launch, and a spec that cannot be built fails closed at + review time. + + Not the resolution path — a `DEEPAGENTS_CODE_AUTO_CLASSIFIER_MODEL` export + or `--auto-classifier-model` flag outranks this value at launch, so it may + differ from the classifier Auto actually reviews with. + """ + + def __post_init__(self) -> None: + """Freeze the providers dict into a read-only proxy.""" + if not isinstance(self.providers, MappingProxyType): + object.__setattr__(self, "providers", MappingProxyType(self.providers)) + + @classmethod + def load(cls, config_path: Path | None = None) -> ModelConfig: + """Load config from file. + + When called with the default path, results are cached for the + lifetime of the process. Use `clear_caches()` to reset. + + Args: + config_path: Path to config file. Defaults to ~/.deepagents/config.toml. + + Returns: + Parsed `ModelConfig` instance. + Returns empty config if file is missing, unreadable, contains + invalid TOML syntax, or is structurally invalid (valid TOML of + the wrong shape, e.g. a scalar `[models]`). + """ + global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement + is_default = config_path is None + if is_default and _default_config_cache is not None: + return _default_config_cache + + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + + if not config_path.exists(): + fallback = cls() + if is_default: + _default_config_cache = fallback + return fallback + + try: + with config_path.open("rb") as f: + data = tomllib.load(f) + models_section = data.get("models", {}) + stored_classifier = models_section.get("auto_classifier") + config = cls( + default_model=models_section.get("default"), + recent_model=models_section.get("recent"), + auto_classifier_model=( + stored_classifier if isinstance(stored_classifier, str) else None + ), + providers=models_section.get("providers", {}), + ) + except tomllib.TOMLDecodeError as e: + logger.warning( + "Config file %s has invalid TOML syntax: %s. " + "Ignoring config file. Fix the file or delete it to reset.", + config_path, + e, + ) + config = cls() + except (PermissionError, OSError) as e: + logger.warning("Could not read config file %s: %s", config_path, e) + config = cls() + except (AttributeError, TypeError) as e: + # Syntactically valid TOML can still have the wrong shape — a scalar + # `[models]`, a non-table `providers` — which surfaces here as an + # AttributeError from `.get(...)` or a TypeError from the dataclass + # constructor. Treat it like any other unreadable config rather than + # letting it crash callers (e.g. the /auth modal on Ctrl+R) that + # assume load() is total and never raises. + logger.warning( + "Config file %s is structurally invalid: %s. " + "Ignoring config file. Fix the file or delete it to reset.", + config_path, + e, + ) + config = cls() + + # Validate config consistency + config._validate() + + if is_default: + _default_config_cache = config + + return config + + def _validate(self) -> None: + """Validate internal consistency of the config. + + Issues warnings for invalid configurations but does not raise exceptions, + allowing the app to continue with potentially degraded functionality. + """ + # Warn if default_model is set but doesn't use provider:model format + if self.default_model and ":" not in self.default_model: + logger.warning( + "default_model '%s' should use provider:model format " + "(e.g., 'anthropic:claude-sonnet-4-5')", + self.default_model, + ) + + # Warn if recent_model is set but doesn't use provider:model format + if self.recent_model and ":" not in self.recent_model: + logger.warning( + "recent_model '%s' should use provider:model format " + "(e.g., 'anthropic:claude-sonnet-4-5')", + self.recent_model, + ) + + # Warn if auto_classifier_model is set but doesn't use provider:model format + if self.auto_classifier_model and ":" not in self.auto_classifier_model: + logger.warning( + "auto_classifier_model '%s' should use provider:model format " + "(e.g., 'anthropic:claude-sonnet-4-5')", + self.auto_classifier_model, + ) + + # Validate enabled field type and class_path format / params references + for name, provider in self.providers.items(): + # `enabled` originates from untyped TOML; cast to `object` so the + # runtime non-bool validation below stays reachable (the TypedDict + # types it as `bool`, which would otherwise mark this branch dead). + enabled = cast("object", provider.get("enabled")) + if enabled is not None and not isinstance(enabled, bool): + logger.warning( + "Provider '%s' has non-boolean 'enabled' value %r " + "(expected true/false). Provider will remain visible.", + name, + enabled, + ) + + # `display_name`/`api_key_url` also originate from untyped TOML; cast + # to `object` so the runtime non-string checks stay reachable (the + # TypedDict types them as `str`). + display_name = cast("object", provider.get("display_name")) + if display_name is not None and not isinstance(display_name, str): + logger.warning( + "Provider '%s' has non-string 'display_name' value %r " + "(expected a string). Falling back to the default label.", + name, + display_name, + ) + + short_name = cast("object", provider.get("short_name")) + if short_name is not None and not isinstance(short_name, str): + logger.warning( + "Provider '%s' has non-string 'short_name' value %r " + "(expected a string). Falling back to the display name.", + name, + short_name, + ) + + api_key_url = cast("object", provider.get("api_key_url")) + if api_key_url is not None and not isinstance(api_key_url, str): + logger.warning( + "Provider '%s' has non-string 'api_key_url' value %r " + "(expected a string). Ignoring it.", + name, + api_key_url, + ) + + class_path = provider.get("class_path") + if class_path and ":" not in class_path: + logger.warning( + "Provider '%s' has invalid class_path '%s': " + "must be in module.path:ClassName format " + "(e.g., 'my_package.models:MyChatModel')", + name, + class_path, + ) + + models = set(provider.get("models", [])) + + params = provider.get("params", {}) + for key, value in params.items(): + if isinstance(value, dict) and key not in models: + logger.warning( + "Provider '%s' has params for '%s' " + "which is not in its models list", + name, + key, + ) + + def is_provider_enabled(self, provider_name: str) -> bool: + """Check whether a provider should appear in the model switcher. + + A provider is disabled when its config explicitly sets + `enabled = false`. Providers not present in the config file are + always considered enabled. + + Args: + provider_name: The provider to check. + + Returns: + `False` if the provider is explicitly disabled, `True` otherwise. + """ + provider = self.providers.get(provider_name) + if not provider: + return True + return provider.get("enabled") is not False + + def get_all_models(self) -> list[tuple[str, str]]: + """Get all models as `(model_name, provider_name)` tuples. + + Returns raw config data — does not filter by `is_provider_enabled`. + For the filtered set shown in the model switcher, use + `get_available_models()`. + + Returns: + List of tuples containing `(model_name, provider_name)`. + """ + return [ + (model, provider_name) + for provider_name, provider_config in self.providers.items() + for model in provider_config.get("models", []) + ] + + def get_provider_for_model(self, model_name: str) -> str | None: + """Find the provider that contains this model. + + Returns raw config data — does not filter by `is_provider_enabled`. + + Args: + model_name: The model identifier to look up. + + Returns: + Provider name if found, None otherwise. + """ + for provider_name, provider_config in self.providers.items(): + if model_name in provider_config.get("models", []): + return provider_name + return None + + def has_credentials(self, provider_name: str) -> bool | None: + """Check if credentials are available for a provider. + + This is the config-file-driven credential check, supporting custom + providers (e.g., local Ollama with no key required). For the hardcoded + `PROVIDER_API_KEY_ENV`-based check used in the hot-swap path, see the + module-level `has_provider_credentials()`. + + Args: + provider_name: The provider to check. + + Returns: + True if credentials are confirmed available, False if confirmed + missing, or None if no `api_key_env` is configured and + credential status cannot be determined. + """ + provider = self.providers.get(provider_name) + if not provider: + return False + env_var = provider.get("api_key_env") + if not env_var: + return None # No key configured — can't verify + return bool(resolve_env_var(env_var)) + + def get_base_url(self, provider_name: str) -> str | None: + """Get the configured base URL for a provider. + + Resolution order (first match wins): + + 1. `base_url` in the provider's `config.toml` section. + 2. The provider's base-URL env vars via `resolve_env_var`, in provider + precedence order, so `DEEPAGENTS_CODE_{VAR}` beats the plain `{VAR}` + for each name — mirroring how API keys resolve. This also surfaces + the value `apply_stored_credentials` bridged in from a `/auth` + credential, and the gateway-provisioned URL in the default + (no-override) case. + 3. The endpoint stored with a `/auth` credential. This is the source + for providers that have no base-URL env var (e.g. an OpenAI- + compatible provider like Litellm): step 2 has no name to read, so + the stored endpoint is taken directly. It then reaches the model as + the `base_url` constructor kwarg via + `_get_provider_kwargs`, the same path a `config.toml` literal uses. + For providers that *do* have an env var, the stored endpoint already + arrives via step 2 (it was bridged onto the env var), so this step + is a redundant — and consistent — fallback. + + This function only *resolves* the endpoint; whether it takes effect is a + separate contract owned by the provider's LangChain class. The value is + delivered as the `base_url` kwarg (see `_get_provider_kwargs`), which the + OpenAI/Anthropic-compatible classes accept via a Pydantic `base_url` + alias. A class that names the field differently may silently + ignore `base_url` — Pydantic models default to `extra="ignore"` — so for + those the endpoint must be set via `params`. + + A corrupt credential store is treated as "no stored endpoint" rather than + propagating, so endpoint resolution never newly raises. + + Args: + provider_name: The provider to get base URL for. + + Returns: + Base URL if configured, None otherwise. + """ + provider = self.providers.get(provider_name) + config_url = provider.get("base_url") if provider else None + if config_url: + return config_url + config_env = provider.get("base_url_env") if provider else None + env_vars = ( + (config_env,) + if config_env + else PROVIDER_BASE_URL_ENV.get(provider_name, ()) + ) + for env_var in env_vars: + resolved = resolve_env_var(env_var) + if resolved: + return resolved + try: + return auth_store.get_stored_base_url(provider_name) + except RuntimeError: + return None + + def get_api_key_env(self, provider_name: str) -> str | None: + """Get the environment variable name for a provider's API key. + + Args: + provider_name: The provider to get API key env var for. + + Returns: + Environment variable name if configured, None otherwise. + """ + provider = self.providers.get(provider_name) + return provider.get("api_key_env") if provider else None + + def get_provider_display_name(self, provider_name: str) -> str | None: + """Get the configured display name for a provider. + + Args: + provider_name: The provider to look up. + + Returns: + Human-readable display name if configured, None otherwise. + """ + provider = self.providers.get(provider_name) + name = provider.get("display_name") if provider else None + return name if isinstance(name, str) else None + + def get_provider_short_name(self, provider_name: str) -> str | None: + """Get the configured compact brand name for a provider. + + Args: + provider_name: The provider to look up. + + Returns: + Compact brand name if configured, None otherwise. + """ + provider = self.providers.get(provider_name) + name = provider.get("short_name") if provider else None + return name if isinstance(name, str) else None + + def get_provider_api_key_url(self, provider_name: str) -> str | None: + """Get the configured API-key management URL for a provider. + + Args: + provider_name: The provider to look up. + + Returns: + API-key management URL if configured, None otherwise. + """ + provider = self.providers.get(provider_name) + url = provider.get("api_key_url") if provider else None + return url if isinstance(url, str) else None + + def get_base_url_env(self, provider_name: str) -> str | None: + """Get the environment variable name for a provider's base URL. + + Args: + provider_name: The provider to get the base-URL env var for. + + Returns: + Environment variable name if configured, None otherwise. + """ + provider = self.providers.get(provider_name) + return provider.get("base_url_env") if provider else None + + def get_class_path(self, provider_name: str) -> str | None: + """Get the custom class path for a provider. + + Args: + provider_name: The provider to look up. + + Returns: + Class path in `module.path:ClassName` format, or None. + """ + provider = self.providers.get(provider_name) + return provider.get("class_path") if provider else None + + def get_kwargs( + self, provider_name: str, *, model_name: str | None = None + ) -> dict[str, Any]: + """Get extra constructor kwargs for a provider. + + Reads the `params` table from the provider config. Flat keys are + provider-wide defaults; model-keyed sub-tables are per-model + overrides that shallow-merge on top (model wins on conflict). + + Args: + provider_name: The provider to look up. + model_name: Optional model name for per-model overrides. + + Returns: + Dictionary of extra kwargs (empty if none configured). + """ + provider = self.providers.get(provider_name) + if not provider: + return {} + params = provider.get("params", {}) + result = {k: v for k, v in params.items() if not isinstance(v, dict)} + if model_name: + overrides = params.get(model_name) + if isinstance(overrides, dict): + result.update(overrides) + return result + + def get_profile_overrides( + self, provider_name: str, *, model_name: str | None = None + ) -> dict[str, Any]: + """Get profile overrides for a provider. + + Reads the `profile` table from the provider config. Flat keys are + provider-wide defaults; model-keyed sub-tables are per-model overrides + that shallow-merge on top (model wins on conflict). + + Args: + provider_name: The provider to look up. + model_name: Optional model name for per-model overrides. + + Returns: + Dictionary of profile overrides (empty if none configured). + """ + provider = self.providers.get(provider_name) + if not provider: + return {} + profile = provider.get("profile", {}) + result = {k: v for k, v in profile.items() if not isinstance(v, dict)} + if model_name: + overrides = profile.get(model_name) + if isinstance(overrides, dict): + result.update(overrides) + return result + + +def _save_toml_field( + section: str, + field: str, + value: str | bool, + config_path: Path | None = None, +) -> bool: + """Read-modify-write a `[section].` key in the config file. + + Args: + section: TOML table name (e.g., `'models'`, `'agents'`). + field: Key within the table (e.g., `'default'`, `'recent'`). + value: String or boolean value to persist. + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + True if save succeeded, False if it failed due to I/O errors. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + + try: + with _config_write_lock: + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Read existing config or start fresh + if config_path.exists(): + with config_path.open("rb") as f: + data = tomllib.load(f) + else: + data = {} + + if section not in data: + data[section] = {} + data[section][field] = value + + # Write to temp file then rename so an interrupted write can't corrupt + fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + tomli_w.dump(data, f) + Path(tmp_path).replace(config_path) + except BaseException: + # Clean up temp file on any failure + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError): + # `TypeError` covers `tomli_w.dump` rejecting a non-serializable + # payload; `ValueError` covers things like `os.fdopen` on a + # closed fd. Folding them in keeps the `bool` contract intact for + # the UI branches that toggle on the return value. + logger.exception("Could not save %s.%s preference", section, field) + return False + else: + # Invalidate config cache so the next load() picks up the change. + global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement + _default_config_cache = None + return True + + +def save_goal_auto_accept_criteria( + enabled: bool, + config_path: Path | None = None, +) -> bool: + """Persist whether Auto mode applies generated goal criteria without review. + + Args: + enabled: Whether Auto should accept goal criteria automatically. + config_path: Path to config file. Defaults to + `~/.deepagents/config.toml`. + + Returns: + `True` when the preference was saved, otherwise `False`. + """ + return _save_toml_field( + "goals", + "auto_accept_criteria", + enabled, + config_path, + ) + + +def _save_model_field( + field: str, model_spec: str, config_path: Path | None = None +) -> bool: + """Read-modify-write a `[models].` key in the config file. + + Thin wrapper around `_save_toml_field` for the `[models]` section. + + Args: + field: Key name under the `[models]` table (e.g., `'default'` or `'recent'`). + model_spec: The model to save in `provider:model` format. + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + True if save succeeded, False if it failed due to I/O errors. + """ + return _save_toml_field("models", field, model_spec, config_path) + + +def save_default_model(model_spec: str, config_path: Path | None = None) -> bool: + """Update the default model in config file. + + Reads existing config (if any), updates `[models].default`, and writes + back using proper TOML serialization. + + Args: + model_spec: The model to set as default in `provider:model` format. + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + True if save succeeded, False if it failed due to I/O errors. + + Note: + This function does not preserve comments in the config file. + """ + return _save_model_field("default", model_spec, config_path) + + +def save_auto_classifier_model( + model_spec: str, config_path: Path | None = None +) -> bool: + """Persist the model the Auto approval classifier reviews actions with. + + Writes `[models].auto_classifier`, the persistent counterpart of the + session-only `/auto model` switch. Both `--auto-classifier-model` and a + `DEEPAGENTS_CODE_AUTO_CLASSIFIER_MODEL` export outrank the stored value at + launch (flag > env > this key), so a successful write does not guarantee the + next launch reviews with it. + + Args: + model_spec: The classifier model in `provider:model` format. + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + True if save succeeded, False if it failed due to I/O errors. + + Note: + This function does not preserve comments in the config file. + """ + return _save_model_field("auto_classifier", model_spec, config_path) + + +def clear_default_model(config_path: Path | None = None) -> bool: + """Remove the default model from the config file. + + Deletes the `[models].default` key so that future launches fall back to + `[models].recent` or environment auto-detection. + + Args: + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + True if the key was removed or was already absent, False when the config + file could not be read or written or its `[models]` section is not a + table. See `_clear_model_field` for the full contract. + """ + return _clear_model_field("default", config_path) + + +def clear_auto_classifier_model(config_path: Path | None = None) -> bool: + """Remove the stored Auto classifier model from the config file. + + Deletes the `[models].auto_classifier` key so future launches review gated + actions with the main agent model, unless `--auto-classifier-model` or + `DEEPAGENTS_CODE_AUTO_CLASSIFIER_MODEL` supplies one — both outrank this key, + so clearing it does not guarantee the main agent model is used. + + Args: + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + True if the key was removed or was already absent, False when the config + file could not be read or written or its `[models]` section is not a + table. See `_clear_model_field` for the full contract. + + Note: + This function does not preserve comments in the config file. + """ + return _clear_model_field("auto_classifier", config_path) + + +def _clear_model_field(field: str, config_path: Path | None = None) -> bool: + """Delete a `[models].` key from the config file. + + Args: + field: Key name under the `[models]` table (e.g., `'default'`). + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + True if the key was removed, or was already absent because the file or + its `[models]` table does not exist. False on I/O error, on + unparseable TOML, and when `[models]` is present but is not a table + — nothing can be deleted from those and the file needs hand repair, + so callers must not report a clean clear. + + Note: + This function does not preserve comments in the config file. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + + try: + with _config_write_lock: + if not config_path.exists(): + return True # Nothing to clear + + with config_path.open("rb") as f: + data = tomllib.load(f) + + models_section = data.get("models") + if models_section is None: + # No `[models]` table at all — an ordinary config that simply + # never stored a model. Nothing to clear and nothing to report. + return True + if not isinstance(models_section, dict): + # Valid TOML of the wrong shape (e.g. a scalar `models = 1`). + # There is no key to delete and the file needs hand repair, so + # report failure: `True` is this contract's clean-clear signal, + # and callers relay it to the user as "cleared". + logger.warning( + "Config file %s has a non-table [models] section (%s); " + "cannot clear models.%s", + config_path, + type(models_section).__name__, + field, + ) + return False + if field not in models_section: + return True # Already absent + + del models_section[field] + + fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + tomli_w.dump(data, f) + Path(tmp_path).replace(config_path) + except BaseException: + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError): + # See `_save_toml_field` for why `TypeError` / `ValueError` are + # folded into the bool return contract. + logger.exception("Could not clear models.%s preference", field) + return False + else: + global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement + _default_config_cache = None + return True + + +def save_effort_for_model( + model_spec: str, + effort: str, + config_path: Path | None = None, +) -> bool: + """Persist the selected reasoning effort for a model. + + Args: + model_spec: Model in `provider:model` format. + effort: Reasoning effort label selected by the user. + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + `True` if save succeeded, `False` if it failed. + """ + return _update_effort_for_model(model_spec, effort, config_path) + + +def load_effort_for_model( + model_spec: str, + config_path: Path | None = None, +) -> str | None: + """Load the selected reasoning effort for a model. + + Args: + model_spec: Model in `provider:model` format. + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + The persisted effort label, or `None`. `None` is returned both when no + preference is stored and when one exists but cannot be read (unreadable + file, invalid TOML, or a malformed `[effort]` section); the two cases + are not distinguished by the return value, but a read failure is always + logged rather than swallowed silently. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + if not config_path.exists(): + return None + + try: + with config_path.open("rb") as f: + data = tomllib.load(f) + effort_section = data.get("effort") + if effort_section is None: + return None # No preference stored; not a failure. + if not isinstance(effort_section, dict): + logger.warning( + "Ignoring malformed [effort] in %s: expected a table, got %s", + config_path, + type(effort_section).__name__, + ) + return None + by_model = effort_section.get("by_model") + if by_model is None: + return None + if not isinstance(by_model, dict): + logger.warning( + "Ignoring malformed [effort.by_model] in %s: expected a table, got %s", + config_path, + type(by_model).__name__, + ) + return None + effort = by_model.get(model_spec) + if effort is None: + return None + if not isinstance(effort, str): + logger.warning( + "Ignoring malformed reasoning effort for %s in %s: expected a " + "string, got %s", + model_spec, + config_path, + type(effort).__name__, + ) + return None + return effort.strip() or None + except (OSError, tomllib.TOMLDecodeError): + logger.exception( + "Could not load reasoning effort preference for %s", model_spec + ) + return None + + +def clear_effort_for_model( + model_spec: str, + config_path: Path | None = None, +) -> bool: + """Remove the selected reasoning effort for a model. + + Args: + model_spec: Model in `provider:model` format. + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + `True` if the entry was removed or absent, `False` if clearing failed. + """ + return _update_effort_for_model(model_spec, None, config_path) + + +def _update_effort_for_model( + model_spec: str, + effort: str | None, + config_path: Path | None = None, +) -> bool: + """Read-modify-write one entry in `[effort.by_model]`. + + Args: + model_spec: Model in `provider:model` format. + effort: Reasoning effort label to save, or `None` to clear it. + config_path: Path to config file. + + Returns: + `True` if the update succeeded, `False` if it failed. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + if effort is None and not config_path.exists(): + return True + + def _require_table(value: object, name: str) -> dict: + if not isinstance(value, dict): + msg = f"{name} must be a table" + raise TypeError(msg) + return value + + try: + with _config_write_lock: + config_path.parent.mkdir(parents=True, exist_ok=True) + if config_path.exists(): + with config_path.open("rb") as f: + data = tomllib.load(f) + else: + data = {} + + effort_section = _require_table(data.setdefault("effort", {}), "[effort]") + by_model = _require_table( + effort_section.setdefault("by_model", {}), "[effort.by_model]" + ) + + if effort is None: + if model_spec not in by_model: + return True + del by_model[model_spec] + if not by_model: + del effort_section["by_model"] + if not effort_section: + del data["effort"] + else: + by_model[model_spec] = effort + + fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + tomli_w.dump(data, f) + Path(tmp_path).replace(config_path) + except BaseException: + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError): + logger.exception( + "Could not update reasoning effort preference for %s", model_spec + ) + return False + else: + # `_default_config_cache` holds only the `[models]` table (default / + # recent / providers), never `[effort]`, so this write cannot stale it. + # Invalidating anyway is defensive parity with the other config writers + # (`_save_toml_field`, `clear_default_model`, ...) that share the file. + global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement + _default_config_cache = None + return True + + +def is_warning_suppressed(key: str, config_path: Path | None = None) -> bool: + """Check if a warning key is suppressed in the config file. + + Reads the `[warnings].suppress` list from `config.toml` and checks + whether `key` is present. + + Args: + key: Warning identifier to check (e.g., `'ripgrep'`). + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + `True` if the warning is suppressed, `False` otherwise (including + when the file is missing, unreadable, or has a missing or + malformed `[warnings]` section). + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + + try: + if not config_path.exists(): + return False + with config_path.open("rb") as f: + data = tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError): + logger.debug( + "Could not read config file %s for warning suppression check", + config_path, + exc_info=True, + ) + return False + + # A hand-edited `warnings = [...]` (or any non-table) would make the + # chained `.get` below raise `AttributeError`; fail open instead so a + # typo can never silently mute a warning. + warnings_section = data.get("warnings", {}) + if not isinstance(warnings_section, dict): + logger.debug( + "[warnings] in %s should be a table, got %s", + config_path, + type(warnings_section).__name__, + ) + return False + + suppress_list = warnings_section.get("suppress", []) + if not isinstance(suppress_list, list): + logger.debug( + "[warnings].suppress in %s should be a list, got %s", + config_path, + type(suppress_list).__name__, + ) + return False + return key in suppress_list + + +def suppress_warning(key: str, config_path: Path | None = None) -> bool: + """Add a warning key to the suppression list in the config file. + + Reads existing config (if any), adds `key` to `[warnings].suppress`, + and writes back using atomic temp-file rename. Deduplicates entries. + + Args: + key: Warning identifier to suppress (e.g., `'ripgrep'`). + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + `True` if save succeeded, `False` if it failed due to I/O errors. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + + try: + with _config_write_lock: + config_path.parent.mkdir(parents=True, exist_ok=True) + + if config_path.exists(): + with config_path.open("rb") as f: + data = tomllib.load(f) + else: + data = {} + + if "warnings" not in data: + data["warnings"] = {} + suppress_list = data["warnings"].get("suppress", []) + if not isinstance(suppress_list, list): + logger.debug( + "[warnings].suppress in %s should be a list, got %s", + config_path, + type(suppress_list).__name__, + ) + suppress_list = [] + if key not in suppress_list: + suppress_list.append(key) + data["warnings"]["suppress"] = suppress_list + + fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + tomli_w.dump(data, f) + Path(tmp_path).replace(config_path) + except BaseException: + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except (OSError, tomllib.TOMLDecodeError): + logger.exception("Could not save warning suppression for '%s'", key) + return False + return True + + +def unsuppress_warning(key: str, config_path: Path | None = None) -> bool: + """Remove a warning key from the suppression list in the config file. + + Reads existing config (if any), removes `key` from `[warnings].suppress`, + and writes back using atomic temp-file rename. No-op if the key is not + present or the file does not exist. + + Args: + key: Warning identifier to unsuppress (e.g., `'ripgrep'`). + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + `True` if save succeeded, `False` if it failed due to I/O errors. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + + try: + with _config_write_lock: + if not config_path.exists(): + return True # nothing to remove + + with config_path.open("rb") as f: + data = tomllib.load(f) + + suppress_list = data.get("warnings", {}).get("suppress", []) + if not isinstance(suppress_list, list): + logger.debug( + "[warnings].suppress in %s should be a list, got %s", + config_path, + type(suppress_list).__name__, + ) + return True # treat as nothing to remove + if key not in suppress_list: + return True # already unsuppressed + + suppress_list.remove(key) + data.setdefault("warnings", {})["suppress"] = suppress_list + + fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + tomli_w.dump(data, f) + Path(tmp_path).replace(config_path) + except BaseException: + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except (OSError, tomllib.TOMLDecodeError): + logger.exception("Could not remove warning suppression for '%s'", key) + return False + return True + + +class _McpProjectScope(NamedTuple): + """A resolved MCP trust identity and whether it is Git-common scoped. + + A `NamedTuple` (mirroring `_git.RepositoryMetadata`) so the boolean slot is + self-documenting at every call site instead of a load-bearing positional. + """ + + identity: str + """Normalized trust identity: a Git common directory or an exact root.""" + + git_common_dir: bool + """Whether `identity` is a validated Git common-directory path.""" + + +@dataclass(frozen=True, order=True) +class McpProjectServerApproval: + """A project-scoped, definition-bound MCP server approval. + + Membership in a `McpServerTrustLists.approvals` set *is* the trust decision + (`is_enabled` reconstructs an approval and tests `approval in approvals`), so + value equality must line up between the write side + (`add_enabled_project_mcp_servers`) and the read side (`is_enabled`). Build new + approvals through `create` and persisted ones through `from_toml`, never the raw + constructor. Legacy unmarked entries intentionally retain their exact-worktree + scope, while new entries reconstruct the same transport-aware scope on both + sides. `order=True` exists only so `sorted()` yields deterministic TOML output. + + The raw constructor only enforces non-emptiness (`__post_init__`), not that + `project_root` is normalized or that `fingerprint` is a real digest. A + hand-built instance is therefore *safe but useless*: with a mismatched root + or fingerprint it simply never equals a `create`/`from_toml` peer, so it + fails closed (nothing is trusted) rather than granting stray access — but it + also won't authorize anything. Always go through the factories. + """ + + project_root: str + """Shared fixed-URL identity or exact worktree-scoped identity.""" + + name: str + """MCP server name within the project config.""" + + fingerprint: str + """Fingerprint of the approved MCP server definition.""" + + git_common_dir: bool = field(default=False, kw_only=True) + """Whether `project_root` is a persisted Git common-directory identity.""" + + def __post_init__(self) -> None: + """Reject degenerate approvals so a bad one can't silently never match. + + An empty `project_root`, `name`, or `fingerprint` can only ever equal a + malformed peer, so forbid the state entirely rather than let it persist. + + Raises: + ValueError: If any field is empty or whitespace-only. + """ + if not ( + self.project_root.strip() and self.name.strip() and self.fingerprint.strip() + ): + msg = ( + "McpProjectServerApproval requires non-empty project_root, name, " + "and fingerprint" + ) + raise ValueError(msg) + + @classmethod + def _create_for_scope( + cls, + *, + scope: _McpProjectScope, + name: str, + server: JsonValue, + ) -> McpProjectServerApproval: + """Build an approval from one already-resolved trust scope. + + Args: + scope: Normalized identity and Git-common marker. + name: MCP server name. + server: Parsed MCP server definition to fingerprint. + + Returns: + The normalized, definition-bound approval. + """ + return cls( + project_root=scope.identity, + name=name.strip(), + fingerprint=fingerprint_mcp_server_config(server), + git_common_dir=scope.git_common_dir, + ) + + @classmethod + def create( + cls, *, project_root: str | Path | None, name: str, server: JsonValue + ) -> McpProjectServerApproval | None: + """Build an approval, normalizing the root and fingerprinting `server`. + + Remote servers with fixed URLs use the validated Git common directory so + their approvals can be shared across linked worktrees. Local commands and + remote definitions with interpolated URLs use the exact resolved worktree + because their behavior can differ between checkouts. + + Args: + project_root: Project root to normalize. + name: MCP server name. + server: Parsed MCP server definition to fingerprint. + + Returns: + The approval, or `None` when `project_root` cannot be normalized. + """ + scope = _normalize_mcp_project_scope( + project_root, + share_across_worktrees=_mcp_server_uses_remote_transport(server), + ) + if scope is None: + return None + return cls._create_for_scope(scope=scope, name=name, server=server) + + @classmethod + def from_toml(cls, item: Mapping[str, object]) -> McpProjectServerApproval | None: + """Deserialize a persisted approval table, normalizing the root. + + Legacy entries without `git_common_dir` remain scoped to their exact + stored worktree. Marked entries retain their exact Git identity, so stale + metadata cannot redirect them to an enclosing repository. + + Args: + item: A parsed TOML table with `project_root`, `name`, and + `fingerprint` string fields plus an optional `git_common_dir` + boolean. + + Returns: + The approval, or `None` for a malformed table — fail-closed for an + allowlist. + """ + project_root = item.get("project_root") + name = item.get("name") + fingerprint = item.get("fingerprint") + git_common_dir = item.get("git_common_dir", False) + if not ( + isinstance(project_root, str) + and project_root.strip() + and isinstance(name, str) + and name.strip() + and isinstance(fingerprint, str) + and fingerprint.strip() + and isinstance(git_common_dir, bool) + ): + return None + + if git_common_dir: + normalized_root = _normalize_persisted_git_common_dir(project_root) + normalized_is_common = True + else: + scope = _normalize_mcp_project_scope( + project_root, share_across_worktrees=False + ) + if scope is None: + return None + normalized_root, normalized_is_common = scope.identity, scope.git_common_dir + if normalized_root is None: + return None + return cls( + project_root=normalized_root, + name=name.strip(), + fingerprint=fingerprint.strip(), + git_common_dir=normalized_is_common, + ) + + def as_toml(self) -> dict[str, str | bool]: + """Return a TOML-serializable representation.""" + item: dict[str, str | bool] = { + "project_root": self.project_root, + "name": self.name, + "fingerprint": self.fingerprint, + } + if self.git_common_dir: + item["git_common_dir"] = True + return item + + +def _normalize_mcp_project_scope( + project_root: str | Path | None, + *, + share_across_worktrees: bool, +) -> _McpProjectScope | None: + """Resolve an MCP trust identity and whether it is Git-common scoped. + + Args: + project_root: Project root path to normalize. + share_across_worktrees: Whether a validated Git common directory may be + used instead of the exact worktree root. + + Returns: + One of three outcomes: + + - `(, True)` when `share_across_worktrees` is set and the + resolved root validates as a Git worktree. + - `(, False)` otherwise. + - `(, False)` when `resolve()` raises `OSError`; + the returned string is the expanded-but-unresolved path. A transient + resolve failure on only one of the write/read sides then yields + different identity strings and a spurious re-prompt (fail-closed), + never a false match. + + Returns `None` only when `project_root` is `None`, cannot be expanded, or + resolution detects a path loop (`RuntimeError`). + """ + if project_root is None: + return None + try: + expanded_root = Path(project_root).expanduser() + except (OSError, RuntimeError): + logger.warning( + "Could not expand MCP project root %s", + project_root, + exc_info=True, + ) + return None + + try: + resolved_root = expanded_root.resolve() + except OSError: + logger.warning( + "Could not resolve MCP project root %s", + project_root, + exc_info=True, + ) + return _McpProjectScope(str(expanded_root), False) + except RuntimeError: + logger.warning( + "Could not resolve MCP project root %s", + project_root, + exc_info=True, + ) + return None + + if share_across_worktrees: + common_dir = find_git_common_dir(resolved_root) + if common_dir is not None: + return _McpProjectScope(str(common_dir), True) + return _McpProjectScope(str(resolved_root), False) + + +def _normalize_persisted_git_common_dir(project_root: str) -> str | None: + """Normalize a marked Git identity without following or rediscovering it. + + Args: + project_root: Persisted Git common-directory path. + + Returns: + The absolute lexical path, or `None` for an invalid stored identity. + """ + try: + expanded_root = Path(project_root).expanduser() + except (OSError, RuntimeError): + logger.warning( + "Could not expand persisted MCP Git identity %s", + project_root, + exc_info=True, + ) + return None + if not expanded_root.is_absolute(): + logger.warning( + "Persisted MCP Git identity %s is not absolute; dropping approval", + project_root, + ) + return None + try: + return os.path.abspath(expanded_root) # noqa: PTH100 # do not follow links + except (OSError, RuntimeError, ValueError): + logger.warning( + "Could not normalize persisted MCP Git identity %s", + project_root, + exc_info=True, + ) + return None + + +_REMOTE_MCP_TRANSPORTS = frozenset( + {"http", "sse", "streamable_http", "streamable-http"} +) + + +def _mcp_server_uses_remote_transport(server: JsonValue) -> bool: + """Return whether `server` is confidently a remote-only definition. + + Malformed, ambiguous, or environment-dependent definitions stay + worktree-scoped. A definition containing `command` is never shared even if it + also contains a remote transport field, and an interpolated URL can resolve to + different endpoints from different worktree `.env` files. + + Args: + server: Parsed MCP server definition. + + Returns: + Whether approvals for the definition may be shared across worktrees. + """ + if not isinstance(server, dict) or "command" in server: + return False + url = server.get("url") + if not isinstance(url, str) or "${" in url: + return False + transport = server.get("type") or server.get("transport") + return transport is None or ( + isinstance(transport, str) and transport in _REMOTE_MCP_TRANSPORTS + ) + + +def normalize_mcp_project_root(project_root: str | Path | None) -> str | None: + """Normalize an exact project root for persisted MCP trust comparisons. + + Args: + project_root: Project root path to normalize. + + Returns: + The resolved absolute project root (or the expanded, unresolved path when + `resolve()` raises `OSError`), or `None` when `project_root` is + unavailable. + """ + scope = _normalize_mcp_project_scope(project_root, share_across_worktrees=False) + return scope.identity if scope is not None else None + + +def fingerprint_mcp_server_config(server: JsonValue) -> str: + """Return a stable fingerprint for an MCP server definition. + + The contract is a JSON-serializable value (in practice the `dict` parsed + from `.mcp.json`, though a malformed entry may be any JSON scalar/array); a + non-serializable input raises `TypeError` from `json.dumps`. `sort_keys=True` + makes the digest independent of key order, so reordering fields in the config + does not force a re-prompt. + + Args: + server: Parsed MCP server config (a JSON-serializable value). + + Returns: + A SHA-256 fingerprint over the canonical JSON representation. + """ + encoded = json.dumps( + server, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" + + +@dataclass(frozen=True) +class McpServerTrustLists: + """User-level allow/deny lists for project MCP servers. + + Sourced only from the user's own configuration — the home `config.toml`, the + global `~/.deepagents/.env`, and shell-exported env — never from a repo, so a + committed `.mcp.json` cannot self-approve. Persisted approvals for fixed + remote URLs bind to one validated local Git repository. Local commands and + interpolated remote URLs bind to the exact resolved worktree. All include the + server definition's fingerprint. Env-sourced approvals remain explicit + process-wide name approvals. + + The "reject wins" invariant — a name in both approval and rejection data is + only rejected — is enforced in `__post_init__`, so every instance is disjoint + no matter how it was constructed; callers need not pre-subtract. + """ + + enabled: frozenset[str] + """Env-sourced server names pre-approved for any project config.""" + + disabled: frozenset[str] + """Server names always rejected; reject wins over approvals and over trust.""" + + read_error: str | None = field(default=None, compare=False) + """Non-`None` when the user's `config.toml` existed but its trust policy + could not be fully read: the file was unreadable/unparseable, its `[mcp]` + value was not a table, or its `disabled_project_servers` was a wrong type + that could not be interpreted as a deny list. Callers must treat this as + fail-closed (do not grant whole-config project trust) and surface it, rather + than proceeding with a deny list that may not have loaded — use `load_failed` + for that check. Note the resolved `enabled`/`disabled` sets are not + necessarily empty here: names from a still-readable source (the env vars) + continue to apply. Excluded from equality so a failed load still compares + equal to empty lists for tests that only care about the resolved names.""" + + approvals: frozenset[McpProjectServerApproval] = field( + default_factory=frozenset, kw_only=True + ) + """Project-scoped approvals loaded from user `config.toml`.""" + + legacy_ignored: frozenset[str] = field( + default_factory=frozenset, compare=False, kw_only=True + ) + """Names found in a legacy `[mcp].enabled_project_servers` list that this + build no longer honors. Non-empty means the user relied on the removed flat + allowlist, so those servers silently stopped loading; callers should surface + it (a bare `logger.warning` is invisible outside debug mode) so + non-interactive paths can explain the change. Diagnostic, not resolved + policy — excluded from equality like `read_error`.""" + + legacy_env_ignored: bool = field(default=False, compare=False, kw_only=True) + """`True` when the removed `DEEPAGENTS_CODE_ENABLED_PROJECT_MCP_SERVERS` env + var is set. It was renamed to the `DANGEROUSLY_`-prefixed var and is no longer + read, so its names silently stopped pre-approving. The diagnostic twin of + `legacy_ignored` for the env surface; callers should surface the rename so the + change is not silent. Excluded from equality like `read_error`.""" + + malformed_approvals: int = field(default=0, compare=False, kw_only=True) + """Count of `[mcp].enabled_project_server_approvals` rows that were dropped as + malformed (wrong-typed key, non-table entry, a table missing/blank + `project_root`/`name`/`fingerprint`, or an invalid Git identity marker). + Non-zero means a persisted approval + could not be read, so its server silently re-prompts; callers should surface + it (a bare `logger.warning` is invisible outside debug mode) for parity with + `legacy_ignored`. Diagnostic, not resolved policy — excluded from equality.""" + + def __post_init__(self) -> None: + """Enforce reject precedence by stripping disabled names from both sets. + + A rejected name must never survive in `enabled` or `approvals`, whatever + the caller passed, so a future allow-first consumer can't be tricked + into loading a denied server. Frozen dataclass, so assign via + `object.__setattr__`. + """ + if self.enabled & self.disabled: + object.__setattr__(self, "enabled", self.enabled - self.disabled) + if any(approval.name in self.disabled for approval in self.approvals): + object.__setattr__( + self, + "approvals", + frozenset( + approval + for approval in self.approvals + if approval.name not in self.disabled + ), + ) + + @property + def load_failed(self) -> bool: + """Whether the user's trust policy failed to load (see `read_error`). + + Callers gating on trust MUST check this and fail closed: a failed load + means a configured deny may be missing, so whole-config project trust + must not be honored. Named so the fail-closed contract is discoverable + rather than resting on every caller remembering the `read_error` + sentinel. + """ + return self.read_error is not None + + def is_enabled( + self, + name: str, + *, + project_root: str | Path | None, + server: JsonValue, + ) -> bool: + """Return whether `server` is approved by name or scoped fingerprint. + + Args: + name: MCP server name. + project_root: Resolved project root for the config that defined it. + server: Parsed MCP server config for fingerprint comparison. + + Returns: + `True` when the server is approved and not disabled. + """ + if not name.strip(): + # A blank name can only come from a malformed config. Fail closed + # here rather than let `McpProjectServerApproval.create` raise + # `ValueError` from its non-empty invariant out of the trust filter. + return False + # These membership tests use the raw (unstripped) `name`, while the + # approval path below strips it via `create`. Reject precedence for a + # whitespace-padded name (e.g. `" docs "` vs `disabled={"docs"}`) does + # NOT rest on this check — it survives only because `__post_init__` + # already stripped every disabled name out of `enabled` and `approvals` + # (it compares the always-stripped `approval.name`). Keep that stripping + # in sync with this check: a padded name sails past both lines here. + if name in self.disabled: + return False + if name in self.enabled: + return True + approval = McpProjectServerApproval.create( + project_root=project_root, name=name, server=server + ) + if approval is None: + return False + if approval in self.approvals: + return True + if not approval.git_common_dir: + return False + + # Approvals written before remote servers gained a shared Git identity + # have no marker and remain bound to their original worktree. Honor that + # exact-root entry there without broadening it to sibling worktrees. + legacy_scope = _normalize_mcp_project_scope( + project_root, share_across_worktrees=False + ) + if legacy_scope is None: + return False + legacy_approval = McpProjectServerApproval._create_for_scope( + scope=legacy_scope, name=name, server=server + ) + return legacy_approval in self.approvals + + +def _parse_csv_env(name: str) -> list[str] | None: + """Parse a comma-separated env var into a list of trimmed, non-empty names. + + Returns: + The parsed list when the variable is set (possibly empty after + trimming), or `None` when the variable is unset so callers can + distinguish "unset, fall back to TOML" from "set but empty". + """ + raw = os.environ.get(name) + if raw is None: + return None + return [item.strip() for item in raw.split(",") if item.strip()] + + +def _toml_str_list( + value: object, *, key: str, config_path: Path +) -> tuple[list[str], bool]: + """Coerce a raw TOML value into a list of trimmed, non-empty server names. + + A bare string is *split on commas* (e.g. `disabled_project_servers = "a, b"` + yields `["a", "b"]`), so a scalar written in the TOML parses identically to + the comma-separated env form in `_parse_csv_env` — the two forms can never + silently diverge into one bogus `"a, b"` token that matches no server. Non- + string list elements are dropped (with a log) while the surrounding valid + names survive. A genuinely wrong type (number, table, bool) cannot be + interpreted as names at all: it yields an empty list *and* flags `malformed`, + so a caller enforcing a deny list can fail closed rather than silently drop + the rejection. + + Args: + value: The raw value read from the `[mcp]` table (or `None` when the + key is absent). + key: The TOML key name, used only for log context. + config_path: The config file the value came from, for log context. + + Returns: + `(names, malformed)`. `names` are the trimmed, non-empty server names. + `malformed` is `True` only when `value` is present but neither a + string nor a list (so it could not be read as names); it is `False` + for an absent value, a string, or any list — even one whose non- + string elements were dropped. + """ + if value is None: + return [], False + if isinstance(value, str): + # Split on commas so a bare string parses exactly like the env form; a + # single name with no comma still yields a one-element list. + return [item.strip() for item in value.split(",") if item.strip()], False + if not isinstance(value, list): + logger.warning( + "[mcp].%s in %s should be a list of strings, got %s; ignoring it", + key, + config_path, + type(value).__name__, + ) + return [], True + result: list[str] = [] + discarded = 0 + for item in value: + if isinstance(item, str) and item.strip(): + result.append(item.strip()) + else: + discarded += 1 + if discarded: + logger.warning( + "[mcp].%s in %s: ignored %d non-string or empty entr%s", + key, + config_path, + discarded, + "y" if discarded == 1 else "ies", + ) + return result, False + + +def _toml_project_server_approvals( + value: object, *, config_path: Path +) -> tuple[list[McpProjectServerApproval], int]: + """Parse `[mcp].enabled_project_server_approvals` entries. + + Args: + value: Raw TOML value from the `[mcp]` table. + config_path: Config file the value came from, for log context. + + Returns: + `(approvals, dropped)`: the well-formed project-scoped approvals and the + count of malformed rows ignored. Dropping is fail-closed for an + allowlist; the count lets callers surface the loss (a bare + `logger.warning` is invisible outside debug mode) so a corrupt saved + approval doesn't just silently re-prompt. + """ + if value is None: + return [], 0 + if not isinstance(value, list): + logger.warning( + "[mcp].enabled_project_server_approvals in %s should be a list of " + "tables; ignoring it", + config_path, + ) + # Count the whole-key type error as one dropped diagnostic so it is + # surfaced rather than only logged. + return [], 1 + + approvals: list[McpProjectServerApproval] = [] + dropped = 0 + for item in value: + if not isinstance(item, dict): + logger.warning( + "[mcp].enabled_project_server_approvals in %s ignored a " + "non-table entry", + config_path, + ) + dropped += 1 + continue + approval = McpProjectServerApproval.from_toml( + cast("Mapping[str, object]", item) + ) + if approval is None: + logger.warning( + "[mcp].enabled_project_server_approvals in %s ignored a " + "malformed entry", + config_path, + ) + dropped += 1 + continue + approvals.append(approval) + return approvals, dropped + + +def load_mcp_server_trust_lists( + config_path: Path | None = None, +) -> McpServerTrustLists: + """Load per-server project MCP allow/deny lists from user-level config. + + Security boundary: this reads the `[mcp]` table only from the user-level + `config.toml` (`DEFAULT_CONFIG_PATH`, i.e. `~/.deepagents/config.toml`) and + the `DEEPAGENTS_CODE_DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS` / + `DEEPAGENTS_CODE_DISABLED_PROJECT_MCP_SERVERS` process env vars — never from + a project's `.mcp.json` or any repo-committed file. There is no + project-level `config.toml` discovery, so an attacker who commits a + malicious `.mcp.json` plus an in-repo config cannot pre-approve their own + servers; the approval must live in the user's home config. This mirrors + Claude Code's "untrusted folder → only non-checked-in settings" rule. + + Source resolution differs by list, matching each one's security direction: + + - `enabled` (permissive): the env var is an explicit process-wide name + allowlist. + - `approvals` (permissive): TOML approvals bind fixed remote URLs to one + validated local Git repository (shared across its worktrees). Local commands + and interpolated remote URLs bind to an exact worktree. All include a + server-definition fingerprint and remain active alongside env-enabled names, + so setting the process-wide escape hatch does not discard choices remembered + by the interactive prompt. + Legacy flat TOML + `enabled_project_servers` entries are ignored because they cannot be safely + scoped. + - `disabled` (restrictive): the env var *unions* with the TOML list — denies + accumulate and a lower-effort source can never silently empty a deny + entry set in the other, which would be a fail-open. There is + deliberately no way to *remove* a configured deny via env. + + Rejection wins: a name appearing in approval and disabled data is reported + only in `disabled`. + + Args: + config_path: Config file to read. Defaults to `DEFAULT_CONFIG_PATH`; + callers should not point this at a project path — doing so would + defeat the boundary above. + + Returns: + The resolved `McpServerTrustLists`. A missing file yields empty lists + (the normal "unset" case). `read_error` is set (so callers can fail + closed instead of treating a broken config as "nothing denied") when + the file exists but cannot be read/parsed, when `[mcp]` is not a + table, or when `disabled_project_servers` is a wrong type that cannot + be read as a deny list; env-sourced names still apply in that case. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + + toml_approvals: list[McpProjectServerApproval] = [] + malformed_approvals = 0 + toml_disabled: list[str] = [] + legacy_ignored: list[str] = [] + read_error: str | None = None + try: + if config_path.exists(): + with config_path.open("rb") as f: + data = tomllib.load(f) + mcp_section = data.get("mcp", {}) + if isinstance(mcp_section, dict): + toml_approvals, malformed_approvals = _toml_project_server_approvals( + mcp_section.get("enabled_project_server_approvals"), + config_path=config_path, + ) + legacy_enabled, _ = _toml_str_list( + mcp_section.get("enabled_project_servers"), + key="enabled_project_servers", + config_path=config_path, + ) + if legacy_enabled: + legacy_ignored = legacy_enabled + logger.warning( + "[mcp].enabled_project_servers in %s is ignored; run " + "the project MCP approval prompt again to save " + "project-scoped approvals", + config_path, + ) + toml_disabled, disabled_malformed = _toml_str_list( + mcp_section.get("disabled_project_servers"), + key="disabled_project_servers", + config_path=config_path, + ) + if disabled_malformed: + # A wrong-typed deny list cannot be read, so proceeding as + # if nothing were denied would be a fail-open. Surface it and + # fail closed, mirroring the unreadable-file path below. + read_error = ( + f"[mcp].disabled_project_servers in {config_path} must be " + "a list of strings; refusing to proceed with an " + "unenforced deny list" + ) + else: + # An `[mcp]` value that is not a table means the deny list is + # unreadable too; fail closed rather than leave it unenforced. + read_error = ( + f"[mcp] in {config_path} must be a table, got " + f"{type(mcp_section).__name__}" + ) + logger.warning( + "[mcp] in %s should be a table, got %s; treating project " + "configs as untrusted", + config_path, + type(mcp_section).__name__, + ) + except (OSError, tomllib.TOMLDecodeError) as exc: + # The file exists but is unreadable/unparseable. Record it so callers + # fail closed rather than silently proceeding with an empty deny list. + read_error = f"Could not read MCP trust lists from {config_path}: {exc}" + logger.warning( + "Could not read %s for MCP server trust lists; treating project " + "configs as untrusted", + config_path, + exc_info=True, + ) + + env_enabled = _parse_csv_env(_env_vars.DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS) + env_disabled = _parse_csv_env(_env_vars.DISABLED_PROJECT_MCP_SERVERS) + # The old name was renamed to the `DANGEROUSLY_`-prefixed var and is no + # longer read; flag it set-but-ignored so callers can explain the rename + # instead of the names silently ceasing to pre-approve. + legacy_env_ignored = _env_vars.LEGACY_ENABLED_PROJECT_MCP_SERVERS in os.environ + if legacy_env_ignored: + logger.warning( + "%s is no longer used; it was renamed to %s", + _env_vars.LEGACY_ENABLED_PROJECT_MCP_SERVERS, + _env_vars.DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS, + ) + + # Process-wide env names and scoped TOML approvals are independent grants. + # Keep both active so the escape hatch cannot make the interactive prompt's + # successfully persisted choices ineffective on the next launch. + enabled = frozenset(env_enabled or ()) + approvals = frozenset(() if read_error is not None else toml_approvals) + disabled = frozenset(toml_disabled) | frozenset(env_disabled or ()) + # Corner: when `read_error` is set because `config.toml` was unreadable, + # `toml_disabled` is lost, so a name that is both TOML-`disabled` *and* + # exported in `DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS` would survive here — + # "reject wins" does not hold in that one corner. It requires a + # self-contradicting config plus the explicit `DANGEROUSLY_` opt-in, and the + # read error is surfaced to the user, so it stays an accepted footgun rather + # than a silent fail-open. + # Reject precedence is enforced by `McpServerTrustLists.__post_init__`, so no + # subtraction here. + return McpServerTrustLists( + enabled=enabled, + disabled=disabled, + approvals=approvals, + read_error=read_error, + legacy_ignored=frozenset(legacy_ignored), + legacy_env_ignored=legacy_env_ignored, + malformed_approvals=malformed_approvals, + ) + + +def add_enabled_project_mcp_servers( + names: Iterable[str], + config_path: Path | None = None, + *, + project_root: str | Path | None = None, + server_configs: Mapping[str, JsonValue] | None = None, +) -> bool: + """Persist project-scoped MCP server approvals. + + Backs the interactive approval prompt's "always allow" choice: the given + names are added to the user-level `config.toml` allowlist with each server + definition's fingerprint. Fixed remote URLs use the local Git repository + identity and are shared by its linked worktrees. Local commands and + interpolated remote URLs use the exact worktree root. A different clone or + changed definition asks again. + + Defaults to the user-level config (`DEFAULT_CONFIG_PATH`), the sole source + `load_mcp_server_trust_lists` reads the allowlist from — so writing to the + user's home config is what preserves the read-side trust boundary (a + committed `.mcp.json` can never self-approve). Any name being persisted is + also pruned from the deprecated flat `[mcp].enabled_project_servers` key + (the key is removed once empty), migrating callers off the ignored legacy + list. The write is atomic (`tempfile.mkstemp` + `Path.replace`) and holds + `_config_write_lock` across the whole read-modify-write, matching + `suppress_warning`. + + Args: + names: Server names to add to the allowlist. Blank/whitespace-only + names are ignored; a call with no usable names is a no-op success. + config_path: Config file to write. Defaults to `DEFAULT_CONFIG_PATH` + (`~/.deepagents/config.toml`). Callers should not point this at a + project path: the loader only ever reads the user-level config, so + an allowlist written elsewhere is never honored. + project_root: Project root whose MCP server definitions were approved. + server_configs: Current server definitions keyed by server name. + + Returns: + `True` if the save succeeded (or there was nothing to add), `False` on + I/O, parse failure, an unknown server name, or missing + project/server context. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + + clean_names = [name.strip() for name in names if name and name.strip()] + if not clean_names: + return True + + if project_root is None or server_configs is None: + logger.error( + "Cannot save enabled project MCP servers without project root and " + "server definitions" + ) + return False + + approvals_to_add: list[McpProjectServerApproval] = [] + for name in clean_names: + if name not in server_configs: + logger.error("Cannot save unknown project MCP server %r", name) + return False + approval = McpProjectServerApproval.create( + project_root=project_root, + name=name, + server=server_configs[name], + ) + if approval is None: + logger.error("Could not normalize project root for MCP server %r", name) + return False + approvals_to_add.append(approval) + + try: + # Hold the shared lock across read-through-replace: the atomic rename + # alone only prevents torn writes, not the lost update where a + # concurrent config.toml writer reads the same snapshot and its + # `replace()` lands last, silently dropping this approval. See the + # `_config_write_lock` contract; `suppress_warning` guards the same way. + with _config_write_lock: + config_path.parent.mkdir(parents=True, exist_ok=True) + + if config_path.exists(): + with config_path.open("rb") as f: + data = tomllib.load(f) + else: + data = {} + + mcp_section = data.get("mcp") + if not isinstance(mcp_section, dict): + mcp_section = {} + existing, _ = _toml_project_server_approvals( + mcp_section.get("enabled_project_server_approvals"), + config_path=config_path, + ) + merged = set(existing) | set(approvals_to_add) + mcp_section["enabled_project_server_approvals"] = [ + approval.as_toml() for approval in sorted(merged) + ] + legacy, legacy_malformed = _toml_str_list( + mcp_section.get("enabled_project_servers"), + key="enabled_project_servers", + config_path=config_path, + ) + if legacy and not legacy_malformed: + migrated = set(clean_names) + remaining = [name for name in legacy if name not in migrated] + if remaining: + mcp_section["enabled_project_servers"] = remaining + else: + mcp_section.pop("enabled_project_servers", None) + data["mcp"] = mcp_section + + fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + tomli_w.dump(data, f) + Path(tmp_path).replace(config_path) + except BaseException: + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError): + # Matches `suppress_warning`: `TypeError` covers `tomli_w.dump` + # rejecting a non-serializable payload; `ValueError` covers things like + # `os.fdopen` on a closed fd. Folding them in keeps the `bool` contract + # intact so the caller degrades to a "could not remember" warning + # instead of crashing with a raw traceback. + logger.exception( + "Could not save enabled project MCP servers to %s", config_path + ) + return False + return True + + +THREAD_COLUMN_DEFAULTS: dict[str, bool] = { + "thread_id": False, + "messages": True, + "created_at": True, + "updated_at": True, + "git_branch": False, + "cwd": False, + "initial_prompt": True, + "agent_name": False, +} +"""Default visibility for thread selector columns.""" + + +class ThreadConfig(NamedTuple): + """Coalesced thread-selector configuration read from a single TOML parse.""" + + columns: dict[str, bool] + """Column visibility settings.""" + + relative_time: bool + """Whether to display timestamps as relative time.""" + + sort_order: str + """`'updated_at'` or `'created_at'`.""" + + scope: str + """`'cwd'` (current working directory) or `'all'` (all directories).""" + + +_thread_config_cache: ThreadConfig | None = None + + +def load_thread_config(config_path: Path | None = None) -> ThreadConfig: + """Load all thread-selector settings from one config file read. + + Returns a cached result when reading the default config path. The + prewarm worker calls this at startup so subsequent opens of the + `/threads` modal avoid disk I/O entirely. + + Args: + config_path: Path to config file. + + Returns: + Coalesced thread configuration. + """ + global _thread_config_cache # noqa: PLW0603 # Module-level cache requires global statement + + if config_path is None: + if _thread_config_cache is not None: + return _thread_config_cache + config_path = DEFAULT_CONFIG_PATH + use_default = config_path == DEFAULT_CONFIG_PATH + + columns = dict(THREAD_COLUMN_DEFAULTS) + relative_time = True + sort_order = "updated_at" + scope = "cwd" + + try: + if not config_path.exists(): + result = ThreadConfig(columns, relative_time, sort_order, scope) + if use_default: + _thread_config_cache = result + return result + with config_path.open("rb") as f: + data = tomllib.load(f) + threads_section = data.get("threads", {}) + + # columns + raw_columns = threads_section.get("columns", {}) + if isinstance(raw_columns, dict): + for key in columns: + if key in raw_columns and isinstance(raw_columns[key], bool): + columns[key] = raw_columns[key] + + # relative_time + rt_value = threads_section.get("relative_time") + if isinstance(rt_value, bool): + relative_time = rt_value + + # sort_order + so_value = threads_section.get("sort_order") + if so_value in {"updated_at", "created_at"}: + sort_order = so_value + + # scope + scope_value = threads_section.get("scope") + if scope_value in {"cwd", "all"}: + scope = scope_value + except (OSError, tomllib.TOMLDecodeError): + logger.warning("Could not read thread config; using defaults", exc_info=True) + # Do not cache on error — allow retry on next call in case the + # file is fixed or permissions are restored. + return ThreadConfig(columns, relative_time, sort_order, scope) + + result = ThreadConfig(columns, relative_time, sort_order, scope) + if use_default: + _thread_config_cache = result + return result + + +def invalidate_thread_config_cache() -> None: + """Clear the cached `ThreadConfig` so the next load re-reads disk.""" + global _thread_config_cache # noqa: PLW0603 # Module-level cache requires global statement + _thread_config_cache = None + + +def load_thread_columns(config_path: Path | None = None) -> dict[str, bool]: + """Load thread column visibility from config file. + + Args: + config_path: Path to config file. + + Returns: + Dict mapping column names to visibility booleans. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + + result = dict(THREAD_COLUMN_DEFAULTS) + try: + if not config_path.exists(): + return result + with config_path.open("rb") as f: + data = tomllib.load(f) + columns = data.get("threads", {}).get("columns", {}) + if isinstance(columns, dict): + for key in result: + if key in columns and isinstance(columns[key], bool): + result[key] = columns[key] + except (OSError, tomllib.TOMLDecodeError): + logger.debug("Could not read thread column config", exc_info=True) + return result + + +def save_thread_columns( + columns: dict[str, bool], config_path: Path | None = None +) -> bool: + """Save thread column visibility to config file. + + Args: + columns: Dict mapping column names to visibility booleans. + config_path: Path to config file. + + Returns: + True if save succeeded, False on I/O error. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + + try: + with _config_write_lock: + config_path.parent.mkdir(parents=True, exist_ok=True) + + if config_path.exists(): + with config_path.open("rb") as f: + data = tomllib.load(f) + else: + data = {} + + if "threads" not in data: + data["threads"] = {} + data["threads"]["columns"] = columns + + fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + tomli_w.dump(data, f) + Path(tmp_path).replace(config_path) + except BaseException: + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except (OSError, tomllib.TOMLDecodeError): + logger.exception("Could not save thread column preferences") + return False + invalidate_thread_config_cache() + return True + + +def load_thread_relative_time(config_path: Path | None = None) -> bool: + """Load the relative-time display preference for thread timestamps. + + Args: + config_path: Path to config file. + + Returns: + True if timestamps should display as relative time. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + try: + if not config_path.exists(): + return True + with config_path.open("rb") as f: + data = tomllib.load(f) + value = data.get("threads", {}).get("relative_time") + if isinstance(value, bool): + return value + except (OSError, tomllib.TOMLDecodeError): + logger.debug("Could not read thread relative_time config", exc_info=True) + return True + + +def save_thread_relative_time(enabled: bool, config_path: Path | None = None) -> bool: + """Save the relative-time display preference for thread timestamps. + + Args: + enabled: Whether to display relative timestamps. + config_path: Path to config file. + + Returns: + True if save succeeded, False on I/O error. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + try: + with _config_write_lock: + config_path.parent.mkdir(parents=True, exist_ok=True) + if config_path.exists(): + with config_path.open("rb") as f: + data = tomllib.load(f) + else: + data = {} + if "threads" not in data: + data["threads"] = {} + data["threads"]["relative_time"] = enabled + fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + tomli_w.dump(data, f) + Path(tmp_path).replace(config_path) + except BaseException: + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except (OSError, tomllib.TOMLDecodeError): + logger.exception("Could not save thread relative_time preference") + return False + invalidate_thread_config_cache() + return True + + +def load_thread_sort_order(config_path: Path | None = None) -> str: + """Load the sort order preference for the thread selector. + + Args: + config_path: Path to config file. + + Returns: + `"updated_at"` or `"created_at"`. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + try: + if not config_path.exists(): + return "updated_at" + with config_path.open("rb") as f: + data = tomllib.load(f) + value = data.get("threads", {}).get("sort_order") + if value in {"updated_at", "created_at"}: + return value + except (OSError, tomllib.TOMLDecodeError): + logger.debug("Could not read thread sort_order config", exc_info=True) + return "updated_at" + + +STARTUP_MODE_MANUAL = "manual" +"""Startup approval mode that keeps human-in-the-loop approvals enabled.""" + +STARTUP_MODE_AUTO = "auto" +"""Startup approval mode that uses classifier-backed action review.""" + +STARTUP_MODE_YOLO = "yolo" +"""Startup approval mode that executes gated actions without review.""" + +STARTUP_MODE_DANGEROUSLY_AUTO = "dangerously-auto" +"""Rejected legacy spelling retained only for migration diagnostics.""" + +VALID_STARTUP_MODES = frozenset( + {STARTUP_MODE_MANUAL, STARTUP_MODE_AUTO, STARTUP_MODE_YOLO} +) +"""Accepted values for the `[startup].mode` config option.""" + +DEFAULT_STARTUP_MODE = STARTUP_MODE_MANUAL +"""Fallback startup mode when `[startup].mode` is missing, unreadable, or invalid.""" + + +def load_startup_mode(config_path: Path | None = None) -> str: + """Load the default startup approval mode from config.toml. + + Reads `[startup].mode`, which accepts fail-closed `manual`, classifier-backed + `auto`, or unrestricted `yolo`. The removed `dangerously-auto` spelling is + invalid and falls back to `manual`. + + Args: + config_path: Path to config file. + + Returns: + `"manual"`, `"auto"`, or `"yolo"`; falls back to `"manual"` when + unset, unreadable, or invalid. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + try: + if not config_path.exists(): + return DEFAULT_STARTUP_MODE + with config_path.open("rb") as f: + data = tomllib.load(f) + startup = data.get("startup") + value = startup.get("mode") if isinstance(startup, dict) else None + # `value` may be any TOML type; guard against non-strings (e.g. an + # array or table) before the frozenset membership test, which would + # otherwise raise `TypeError: unhashable type` and crash startup. + if isinstance(value, str) and value in VALID_STARTUP_MODES: + return value + if value is not None: + logger.warning( + "Ignoring [startup].mode=%r (expected 'manual', 'auto', or 'yolo')", + value, + ) + except (OSError, tomllib.TOMLDecodeError): + logger.debug("Could not read startup mode config", exc_info=True) + return DEFAULT_STARTUP_MODE + + +def save_thread_sort_order(sort_order: str, config_path: Path | None = None) -> bool: + """Save the sort order preference for the thread selector. + + Args: + sort_order: `"updated_at"` or `"created_at"`. + config_path: Path to config file. + + Returns: + True if save succeeded, False on I/O error. + + Raises: + ValueError: If `sort_order` is not a recognised value. + """ + if sort_order not in {"updated_at", "created_at"}: + msg = ( + f"Invalid sort_order {sort_order!r}; expected 'updated_at' or 'created_at'" + ) + raise ValueError(msg) + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + try: + with _config_write_lock: + config_path.parent.mkdir(parents=True, exist_ok=True) + if config_path.exists(): + with config_path.open("rb") as f: + data = tomllib.load(f) + else: + data = {} + if "threads" not in data: + data["threads"] = {} + data["threads"]["sort_order"] = sort_order + fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + tomli_w.dump(data, f) + Path(tmp_path).replace(config_path) + except Exception: + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except (OSError, tomllib.TOMLDecodeError): + logger.exception("Could not save thread sort_order preference") + return False + invalidate_thread_config_cache() + return True + + +def save_thread_scope(scope: str, config_path: Path | None = None) -> bool: + """Save the directory-scope preference for the thread selector. + + Args: + scope: `"cwd"` (current working directory) or `"all"` (all directories). + config_path: Path to config file. + + Returns: + True if save succeeded, False on I/O error. + + Raises: + ValueError: If `scope` is not a recognised value. + """ + if scope not in {"cwd", "all"}: + msg = f"Invalid scope {scope!r}; expected 'cwd' or 'all'" + raise ValueError(msg) + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + try: + with _config_write_lock: + config_path.parent.mkdir(parents=True, exist_ok=True) + if config_path.exists(): + with config_path.open("rb") as f: + data = tomllib.load(f) + else: + data = {} + if "threads" not in data: + data["threads"] = {} + data["threads"]["scope"] = scope + fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + tomli_w.dump(data, f) + Path(tmp_path).replace(config_path) + except BaseException: + # Clean up temp file on any failure, including interrupts. + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError): + # `TypeError`/`ValueError` cover `tomli_w.dump` rejecting a payload + # from a pre-existing config that does not round-trip; folding them in + # keeps the `bool` contract intact for `_persist_scope`'s failure toast. + logger.exception("Could not save thread scope preference") + return False + invalidate_thread_config_cache() + return True + + +def save_recent_model(model_spec: str, config_path: Path | None = None) -> bool: + """Update the recently used model in config file. + + Writes to `[models].recent` instead of `[models].default`, so that `/model` + switches do not overwrite the user's intentional default. + + Args: + model_spec: The model to save in `provider:model` format. + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + True if save succeeded, False if it failed due to I/O errors. + + Note: + This function does not preserve comments in the config file. + """ + return _save_model_field("recent", model_spec, config_path) + + +def _recent_models_path(state_dir: Path | None = None) -> Path: + """Resolve the JSON file path for the recent-models MRU cache. + + Args: + state_dir: Override for the state directory (test hook). + + Returns: + Absolute path to `recent_models.json` under the chosen state dir. + """ + return (state_dir or DEFAULT_STATE_DIR) / RECENT_MODELS_FILENAME + + +def load_recent_models(state_dir: Path | None = None) -> list[str]: + """Read the most-recent-first list of `provider:model` specs. + + Missing or malformed files yield an empty list rather than raising; the + recent section is a non-essential UI affordance and must not block the + selector from rendering. + + Args: + state_dir: Override for the state directory (test hook). + + Returns: + Ordered list of recent `provider:model` specs, most recent first. + Capped at `RECENT_MODELS_LIMIT` and de-duplicated. + """ + path = _recent_models_path(state_dir) + if not path.exists(): + return [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + logger.warning("Could not read recent models cache at %s", path, exc_info=True) + return [] + raw = data.get("models") if isinstance(data, dict) else None + if not isinstance(raw, list): + return [] + seen: set[str] = set() + out: list[str] = [] + for entry in raw: + if not isinstance(entry, str) or ":" not in entry or entry in seen: + continue + seen.add(entry) + out.append(entry) + if len(out) >= RECENT_MODELS_LIMIT: + break + return out + + +def touch_recent_model(model_spec: str, state_dir: Path | None = None) -> bool: + """Promote `model_spec` to the front of the recent-models MRU list. + + Existing entries for the same spec are moved (not duplicated); the list + is capped at `RECENT_MODELS_LIMIT`. Best-effort: returns `False` on I/O + error so callers can degrade silently — recents are a nice-to-have, not + a correctness requirement. + + Args: + model_spec: The `provider:model` string just selected. + state_dir: Override for the state directory (test hook). + + Returns: + `True` on success, `False` on I/O error or invalid spec. + """ + if not model_spec or ":" not in model_spec: + return False + existing = load_recent_models(state_dir) + deduped = [entry for entry in existing if entry != model_spec] + new_list = [model_spec, *deduped][:RECENT_MODELS_LIMIT] + path = _recent_models_path(state_dir) + try: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump({"models": new_list}, f) + Path(tmp_path).replace(path) + except BaseException: + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except OSError: + logger.warning( + "Could not update recent models cache at %s", path, exc_info=True + ) + return False + return True + + +def save_recent_agent(agent_name: str, config_path: Path | None = None) -> bool: + """Update the recently used agent in config file. + + Writes to `[agents].recent` so a later bare `deepagents` launch (no + `-a`) can bring the user back to their last agent instead of the + default. + + Args: + agent_name: The agent directory name (e.g., `'coder'`). + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + True if save succeeded, False if it failed due to I/O errors. + """ + return _save_toml_field("agents", "recent", agent_name, config_path) + + +def load_recent_agent(config_path: Path | None = None) -> str | None: + """Read `[agents].recent` from the config file. + + Args: + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + The saved agent name, or `None` if the file or key is missing or + the file is unreadable. + """ + return _load_agents_field("recent", config_path) + + +def save_default_agent(agent_name: str, config_path: Path | None = None) -> bool: + """Update the default agent in config file. + + Writes to `[agents].default`. This is the user's intentional sticky + default — set via `Ctrl+S` in the `/agents` picker — and takes + precedence over `[agents].recent` on bare-launch resolution. + + Args: + agent_name: The agent directory name (e.g., `'coder'`). + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + True if save succeeded, False if it failed due to I/O errors. + """ + return _save_toml_field("agents", "default", agent_name, config_path) + + +def clear_default_agent(config_path: Path | None = None) -> bool: + """Remove the default agent from the config file. + + Deletes the `[agents].default` key so that future launches fall back + to `[agents].recent` and then `DEFAULT_AGENT_NAME`. + + Args: + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + True if the key was removed (or was already absent), False on I/O error. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + + try: + with _config_write_lock: + if not config_path.exists(): + return True + + with config_path.open("rb") as f: + data = tomllib.load(f) + + agents_section = data.get("agents") + if not isinstance(agents_section, dict) or "default" not in agents_section: + return True + + del agents_section["default"] + + fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + tomli_w.dump(data, f) + Path(tmp_path).replace(config_path) + except BaseException: + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except (OSError, tomllib.TOMLDecodeError, TypeError, ValueError): + # See `_save_toml_field` for why `TypeError` / `ValueError` are + # folded into the bool return contract. + logger.exception("Could not clear default agent preference") + return False + else: + global _default_config_cache # noqa: PLW0603 # Module-level cache requires global statement + _default_config_cache = None + return True + + +def load_default_agent(config_path: Path | None = None) -> str | None: + """Read `[agents].default` from the config file. + + Args: + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + The saved agent name, or `None` if the file or key is missing or + the file is unreadable. + """ + return _load_agents_field("default", config_path) + + +def _load_agents_field(field: str, config_path: Path | None = None) -> str | None: + """Read `[agents].` from the config file. + + Args: + field: Key under the `[agents]` table (e.g., `'recent'`, `'default'`). + config_path: Path to config file. + + Defaults to `~/.deepagents/config.toml`. + + Returns: + The trimmed string value, or `None` if the file, section, or key + is missing or the file is unreadable. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + if not config_path.exists(): + return None + try: + with config_path.open("rb") as f: + data = tomllib.load(f) + except (OSError, tomllib.TOMLDecodeError): + logger.warning("Could not read agents.%s from config", field, exc_info=True) + return None + agents_section = data.get("agents", {}) + value = agents_section.get(field) + if isinstance(value, str) and value.strip(): + return value.strip() + return None diff --git a/libs/cli/deepagents_cli/notifications.py b/libs/code/deepagents_code/notifications.py similarity index 98% rename from libs/cli/deepagents_cli/notifications.py rename to libs/code/deepagents_code/notifications.py index e88b7953ea..8c1cf8d757 100644 --- a/libs/cli/deepagents_cli/notifications.py +++ b/libs/code/deepagents_code/notifications.py @@ -26,6 +26,9 @@ class ActionId(StrEnum): OPEN_WEBSITE = "open_website" """Open the associated URL in the user's browser.""" + ENTER_API_KEY = "enter_api_key" + """Open the `/auth` manager so the user can store an API key.""" + INSTALL = "install" """Run the upgrade command via `perform_upgrade`.""" diff --git a/libs/code/deepagents_code/offload.py b/libs/code/deepagents_code/offload.py new file mode 100644 index 0000000000..a42e02edc1 --- /dev/null +++ b/libs/code/deepagents_code/offload.py @@ -0,0 +1,305 @@ +"""Storage paths for offloaded conversation history.""" + +from __future__ import annotations + +import logging +import os +import stat +import tempfile +from dataclasses import dataclass +from pathlib import Path, PurePath + +logger = logging.getLogger(__name__) + +_FALLBACK_ARTIFACTS_ROOT = "/dcode-artifacts-fallback" + +CONVERSATION_HISTORY_DIRNAME = "conversation_history" +"""Subdirectory of the offload root that holds per-thread conversation archives. + +Lives directly under `~/.deepagents/` in local mode. The `/agent` picker +excludes this reserved name in addition to requiring an `AGENTS.md` marker. +""" + + +@dataclass(frozen=True) +class _ArtifactsStorage: + """Agent-visible artifacts root and optional routed large-result directory.""" + + root: str + large_results_dir: Path | None = None + + +def _filesystem_tool_path(path: PurePath) -> str: + """Represent an absolute host path in the filesystem tool path format. + + Drive-qualified paths are rejected by the SDK's virtual path validation. The + Windows extended-length form keeps the drive while starting with the `/` + required by filesystem tools; `pathlib` and Windows APIs still resolve it to + the same host directory. + + Args: + path: Absolute host path to represent. + + Returns: + A forward-slash path accepted by the filesystem tool contract. + """ + normalized = path.as_posix() + if path.drive and not path.drive.startswith("\\\\"): + return f"//?/{normalized}" + return normalized + + +_EPHEMERAL_OFFLOAD_STORAGE = False +"""Whether the most recent `_offload_fallback_root` fell back to temp storage.""" + +_UNIQUE_OFFLOAD_FALLBACK_ROOT: Path | None = None +"""Private random fallback root that cannot be reconstructed on a later call.""" + + +def offload_storage_is_ephemeral() -> bool: + """Return whether offload history is routed to non-persistent storage. + + `True` when the persistent `~/.deepagents` location was unwritable and the + most recent `_offload_fallback_root` fell back to a temporary directory that + may not survive a restart. Only meaningful in local mode, where + `_offload_fallback_root` runs in the same process as the UI; in + server/sandbox mode persistence is owned by the server backend and this flag + stays `False` client-side. + + Returns: + `True` if the local offload root is a temporary, non-persistent + directory; `False` when it is the persistent per-user location (or was + never resolved in this process). + """ + return _EPHEMERAL_OFFLOAD_STORAGE + + +def _harden_dir(path: Path) -> None: + """Create `path` if needed and restrict it to the current user. + + Only ever call this on directories owned by this process's storage (a temp + dir or a dedicated subdirectory), never on the shared `~/.deepagents` config + root. + + Args: + path: Directory to create and harden to `0o700`. + + Raises: + OSError: If the path exists but is not a directory, or the directory + cannot be created or its mode changed (e.g. a read-only mount). + PermissionError: If the existing directory is owned by another local user. + """ + path.mkdir(mode=0o700, parents=True, exist_ok=True) + info = path.lstat() + if not stat.S_ISDIR(info.st_mode): + msg = f"Path is not a directory: {path}" + raise OSError(msg) + getuid = getattr(os, "getuid", None) + if getuid is not None and info.st_uid != getuid(): + msg = f"Directory is owned by another user: {path}" + raise PermissionError(msg) + # `mkdir(mode=...)` does not tighten an existing directory. These directories + # can hold conversation data and offloaded tool results, so they must remain + # inaccessible to other local accounts regardless of the process umask. + path.chmod(0o700) + + +def _probe_writable(path: Path) -> None: + """Confirm `path` accepts new files (catches read-only mounts). + + Creating the directory is insufficient when it already exists on a read-only + mount; a temporary file proves writes can succeed. + + Args: + path: Directory to probe. + """ + with tempfile.NamedTemporaryFile(dir=path, prefix=".write-test-"): + pass + + +def _artifacts_root() -> _ArtifactsStorage: + """Return storage configuration for offloaded artifacts. + + The normal path is a stable, hardened host directory that filesystem tools + and shell commands can use directly. If that predictable directory is + unusable, large results use a private unique directory behind a stable virtual + root. Keeping the virtual root stable lets conversation archive paths persisted + in thread state continue matching their dedicated route after a restart. + + Returns: + The agent-visible artifacts root and an optional directory to which large + results must be routed. + """ + getuid = getattr(os, "getuid", None) + suffix = str(getuid()) if getuid is not None else str(os.getpid()) + temp_root = Path(tempfile.gettempdir()) + root = temp_root / f"dcode-artifacts-{suffix}" + try: + _harden_dir(root) + _probe_writable(root) + except (OSError, RuntimeError): + logger.warning( + "Predictable per-user artifacts directory is unavailable; routing " + "large results from a stable virtual prefix to private temporary storage", + exc_info=True, + ) + unique = Path( + tempfile.mkdtemp(prefix=f"dcode-artifacts-{suffix}-", dir=temp_root) + ) + _harden_dir(unique) + _probe_writable(unique) + return _ArtifactsStorage( + root=_FALLBACK_ARTIFACTS_ROOT, + large_results_dir=unique, + ) + return _ArtifactsStorage(root=_filesystem_tool_path(root)) + + +def _offload_fallback_root() -> Path: + """Return a writable base directory for offloaded conversation history. + + Prefers the persistent per-user `~/.deepagents` directory so offloaded + history survives across sessions and is easy to locate; falls back to a + private temporary directory when the home directory cannot be resolved or + written. This is the live root for the local-mode `conversation_history` + backend in `agent.py`. + + Archives always live in the `conversation_history` subdirectory of the + returned root. The `0o700` hardening therefore targets that subdirectory, + never the shared `~/.deepagents` config root -- which also houses + `config.toml`, `hooks.json`, `.env`, and `.state/`, whose permissions this + must not disturb. A temporary fallback root is created solely for offload, + so the whole directory is hardened in that case. + + Note: the `S_ISDIR` check below (which uses `lstat`, deliberately not + following the link) guards the paths it is applied to -- the + `conversation_history` subdirectory and, in the fallback case, the temp + root -- not `~/.deepagents` itself, which is created with a plain `mkdir`. + So a `conversation_history` (or temp root) that is itself a symlink is + rejected, whereas a symlinked `~/.deepagents` pointing at a directory the + current user owns is followed transparently and archives persist normally. + (A dangling `~/.deepagents` symlink still falls through to temporary + storage, but via `mkdir` raising, not via this check.) + + Returns: + A directory whose `conversation_history` subdirectory is private and + writable. + """ + + def _prepare_user_dir() -> Path: + base = Path.home() / ".deepagents" + # Ensure the shared config root exists and is usable, but leave its + # permissions untouched -- hardening belongs on the archive subdir only. + base.mkdir(parents=True, exist_ok=True) + archive_dir = base / CONVERSATION_HISTORY_DIRNAME + _harden_dir(archive_dir) + _probe_writable(archive_dir) + return base + + def _prepare_temp_dir(path: Path) -> Path: + # A temp dir is created solely for offload and is not shared config, so + # hardening the whole directory (which protects its archive subdir) is + # both safe and necessary in world-writable temp locations. + _harden_dir(path) + _probe_writable(path) + return path + + global _EPHEMERAL_OFFLOAD_STORAGE, _UNIQUE_OFFLOAD_FALLBACK_ROOT # noqa: PLW0603 + if _UNIQUE_OFFLOAD_FALLBACK_ROOT is not None: + # Unlike the persistent and predictable temp paths, a directory created + # by `mkdtemp` cannot be derived again. Keep returning the root already + # used by the archive backend so cleanup reaches the same files. + _EPHEMERAL_OFFLOAD_STORAGE = True + return _UNIQUE_OFFLOAD_FALLBACK_ROOT + try: + root = _prepare_user_dir() + except (RuntimeError, OSError): + logger.warning( + "User data directory is not writable; falling back to temporary " + "offload storage, which may not persist across restarts", + exc_info=True, + ) + else: + _EPHEMERAL_OFFLOAD_STORAGE = False + return root + # Only reached on the fallback path: every root produced below is temporary + # and may not survive a restart. + _EPHEMERAL_OFFLOAD_STORAGE = True + getuid = getattr(os, "getuid", None) + suffix = str(getuid()) if getuid is not None else str(os.getpid()) + temp_root = Path(tempfile.gettempdir()) + path = temp_root / f"deepagents-{suffix}" + try: + return _prepare_temp_dir(path) + except (OSError, RuntimeError): + logger.warning( + "Per-user temporary offload directory is unavailable; creating " + "a private unique directory", + exc_info=True, + ) + unique = Path(tempfile.mkdtemp(prefix=f"deepagents-{suffix}-", dir=temp_root)) + _UNIQUE_OFFLOAD_FALLBACK_ROOT = _prepare_temp_dir(unique) + return _UNIQUE_OFFLOAD_FALLBACK_ROOT + + +def delete_offloaded_history(thread_id: str) -> bool: + """Remove a thread's offloaded conversation-history archive. + + Deletes the per-thread markdown file written by the local-mode + `conversation_history` backend (`{root}/conversation_history/{thread_id}.md`), + resolving `root` with `_offload_fallback_root` so the persistent + `~/.deepagents` location and any temporary fallback are both covered. + + Best-effort: filesystem failures are logged and swallowed rather than + raised, so a failed cleanup never blocks thread deletion. Resolving the + offload root is not side-effect-free -- it creates (and hardens) the + `conversation_history` directory and writes a short-lived probe file -- so a + call for a thread that has no archive still touches the filesystem before + returning `False`. + + In server/sandbox mode the archive lives on the sandbox backend rather than + the local `~/.deepagents` directory, so there is no local archive to remove. + + Args: + thread_id: Thread whose offloaded history should be removed. + + Returns: + `True` only if an archive file was removed. `False` in every other case: + an empty or rejected `thread_id`, an unresolvable offload root, a missing + archive, or an `unlink` failure. + """ + if not thread_id: + return False + try: + archive_dir = _offload_fallback_root() / CONVERSATION_HISTORY_DIRNAME + except (OSError, RuntimeError): + logger.warning( + "Could not resolve offload root to clean history for thread %s", + thread_id, + exc_info=True, + ) + return False + archive_path = archive_dir / f"{thread_id}.md" + # Guard against a crafted thread id escaping the archive directory. Thread + # ids are system-generated UUID7 strings, so a rejection here means either a + # crafted input or a bug emitting malformed ids -- both worth a trace, and + # both distinct from the benign "no archive exists" path below. + if archive_path.parent != archive_dir: + logger.warning( + "Refusing to delete offloaded history for suspicious thread id %r", + thread_id, + ) + return False + try: + archive_path.unlink() + except FileNotFoundError: + return False + except OSError: + logger.warning( + "Failed to delete offloaded conversation history for thread %s", + thread_id, + exc_info=True, + ) + return False + logger.debug("Deleted offloaded conversation history for thread %s", thread_id) + return True diff --git a/libs/code/deepagents_code/offload_middleware.py b/libs/code/deepagents_code/offload_middleware.py new file mode 100644 index 0000000000..c9279c09bc --- /dev/null +++ b/libs/code/deepagents_code/offload_middleware.py @@ -0,0 +1,814 @@ +"""CLI-specific conversation compaction middleware.""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +from functools import partial +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, Any, NamedTuple, cast + +from deepagents.backends.protocol import FILE_NOT_FOUND +from deepagents.middleware.summarization import ( + SummarizationToolMiddleware, + create_summarization_middleware, + create_summarization_tool_middleware, +) +from langchain.tools import ( + ToolRuntime, # noqa: TC002 # inspected for runtime injection +) +from langchain_core.exceptions import ContextOverflowError +from langchain_core.messages import ToolMessage +from langchain_core.tools import InjectedToolArg, StructuredTool +from langgraph.types import Command + +from deepagents_code._cli_context import CLIContextSchema +from deepagents_code.hooks.models.domain import ( + CompactTrigger, + HookEvent, + PreCompactDecision, + PreCompactEvent, +) +from deepagents_code.hooks.server_middleware import ( + _DEFAULT_DEADLINE, + _event_enabled, + _hook_context, + _invoke_hook, + _require_decision, + _session_gate, +) + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from deepagents.backends.protocol import ( + BackendProtocol, + EditResult, + FileDownloadResponse, + WriteResult, + ) + from deepagents.middleware.summarization import SummarizationMiddleware + from langchain.agents.middleware.types import ( + ExtendedModelResponse, + ModelRequest, + ModelResponse, + ) + from langchain.chat_models import BaseChatModel + from langgraph.prebuilt.tool_node import ToolCallRequest + +logger = logging.getLogger(__name__) + + +COMPACTION_FAILURE_PREFIX = "Compaction failed" +"""Stable prefix for forced-compaction failure tool messages. + +`/offload` drives the tool server-side and can only observe the resulting +`ToolMessage` text across the LangGraph server boundary, so it keys failure +detection on this prefix. Owning the literal here means the producer +(`_forced_compact_error`) and both consumers (`app._drive_server_side_compaction` +live-stream detection and `app._find_compaction_failure` committed-state scan) +reference one constant instead of re-hardcoding the wording independently. + +Note: this value is deliberately identical to the leading text of the SDK's own +model-initiated compaction-failure message, so a failure emitted by either path +is recognized. Because the scan is bounded to messages produced by the current +`/offload` attempt, a stale failure from an unrelated prior turn is not matched. +Only the *prefix position* is load-bearing; wording after it is free to change. +""" + +_OFFLOAD_SEED_ID_PREFIX = "offload-seed-" + + +class _AutoCompactionBlockedError(Exception): + """Carry a blocked provider overflow past the SDK fallback handler.""" + + def __init__(self, overflow: ContextOverflowError) -> None: + super().__init__(str(overflow)) + self.overflow = overflow + + +def _offload_seed_message_id(tool_call_id: str) -> str: + """Return the stable message ID for a forced `/offload` tool call. + + Args: + tool_call_id: The seeded `compact_conversation` tool call ID. + + Returns: + The synthetic assistant message ID associated with the tool call. + """ + return f"{_OFFLOAD_SEED_ID_PREFIX}{tool_call_id}" + + +def _without_offload_seed(messages: list[Any], tool_call_id: str) -> list[Any]: + """Exclude the synthetic `/offload` seed from retention calculations. + + Args: + messages: Effective conversation messages including the forced tool call. + tool_call_id: The seeded `compact_conversation` tool call ID. + + Returns: + Conversation messages without the matching synthetic assistant message. + """ + if not tool_call_id: + return messages + seed_id = _offload_seed_message_id(tool_call_id) + return [ + message + for message in messages + if ( + message.get("id") + if isinstance(message, dict) + else getattr(message, "id", None) + ) + != seed_id + ] + + +class RuntimeModelConfig(NamedTuple): + """Active model configuration read from a tool runtime. + + A named tuple rather than a bare 4-tuple so the two structurally identical + `dict` slots (`model_params`, `profile_overrides`) are addressed by name at + both the construction sites (keyword args) and the read site (attribute + access) — a silent positional transposition the type checker would not catch + is thereby avoided. Positional construction/unpacking is still possible and + would defeat this, so call sites must keep using names. + """ + + model_spec: str | None + model_params: dict[str, Any] + profile_overrides: dict[str, Any] + context_limit: int | None + + +def _runtime_model_config(runtime: ToolRuntime) -> RuntimeModelConfig: + """Read the active model configuration from a tool runtime. + + Args: + runtime: Runtime injected into the compaction tool. + + Returns: + The active model specification, invocation parameters, profile + overrides, and effective context-window limit. + """ + context = runtime.context + if isinstance(context, CLIContextSchema): + return RuntimeModelConfig( + model_spec=context.model, + model_params=context.model_params, + profile_overrides=context.profile_overrides, + context_limit=context.model_context_limit, + ) + if isinstance(context, dict): + model = context.get("model") + params = context.get("model_params") + profile_overrides = context.get("profile_overrides") + context_limit = context.get("model_context_limit") + return RuntimeModelConfig( + model_spec=model if isinstance(model, str) else None, + model_params=dict(params) if isinstance(params, dict) else {}, + profile_overrides=( + dict(profile_overrides) if isinstance(profile_overrides, dict) else {} + ), + context_limit=context_limit if isinstance(context_limit, int) else None, + ) + return RuntimeModelConfig( + model_spec=None, model_params={}, profile_overrides={}, context_limit=None + ) + + +def _offload_tool_call_id(context: object) -> str | None: + """Read the sole tool-call ID authorized for an `/offload` run. + + Args: + context: Runtime context supplied to the agent graph. + + Returns: + The authorized tool-call ID, or `None` during an ordinary agent run. + """ + value = ( + context.offload_tool_call_id + if isinstance(context, CLIContextSchema) + else context.get("offload_tool_call_id") + if isinstance(context, dict) + else None + ) + return value if isinstance(value, str) and value else None + + +class _ArchiveReadGuard: + """Prevent an archive write after its prerequisite read fails. + + The SDK archive helper treats any unsuccessful read like a missing file and + follows it with a truncating `write`. This narrow backend adapter preserves + the SDK formatting and append behavior while making that fallback fail closed. + """ + + def __init__(self, backend: BackendProtocol) -> None: + self._backend = backend + self._read_failed = False + + def _record_response_errors( + self, responses: list[FileDownloadResponse] + ) -> list[FileDownloadResponse]: + """Record read errors other than an expected missing archive. + + Args: + responses: Backend download responses to inspect. + + Returns: + The unchanged backend download responses. + """ + if any( + response.error is not None and response.error != FILE_NOT_FOUND + for response in responses + ): + self._read_failed = True + return responses + + def _ensure_read_succeeded(self) -> None: + """Raise when a prior archive read failed in this operation. + + Raises: + RuntimeError: If the prerequisite archive read failed. + """ + if self._read_failed: + msg = "archive read failed; refusing to overwrite existing history" + raise RuntimeError(msg) + + def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: + """Delegate a synchronous read while recording failures. + + Args: + paths: Backend paths to read. + + Returns: + The backend download responses. + """ + try: + responses = self._backend.download_files(paths) + except Exception: + self._read_failed = True + raise + return self._record_response_errors(responses) + + async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]: + """Delegate an asynchronous read while recording failures. + + Args: + paths: Backend paths to read. + + Returns: + The backend download responses. + """ + try: + responses = await self._backend.adownload_files(paths) + except Exception: + self._read_failed = True + raise + return self._record_response_errors(responses) + + def write(self, file_path: str, content: str) -> WriteResult: + """Write only when the prerequisite archive read succeeded. + + Args: + file_path: Backend path to write. + content: Complete archive content. + + Returns: + The backend write result. + """ + self._ensure_read_succeeded() + return self._backend.write(file_path, content) + + async def awrite(self, file_path: str, content: str) -> WriteResult: + """Asynchronously write only after a successful archive read. + + Args: + file_path: Backend path to write. + content: Complete archive content. + + Returns: + The backend write result. + """ + self._ensure_read_succeeded() + return await self._backend.awrite(file_path, content) + + def edit( + self, + file_path: str, + old_string: str, + new_string: str, + replace_all: bool = False, + ) -> EditResult: + """Edit only when the prerequisite archive read did not raise. + + Args: + file_path: Backend path to edit. + old_string: Existing archive content. + new_string: Archive content with the new section appended. + replace_all: Whether to replace every match. + + Returns: + The backend edit result. + """ + self._ensure_read_succeeded() + return self._backend.edit( + file_path, old_string, new_string, replace_all=replace_all + ) + + async def aedit( + self, + file_path: str, + old_string: str, + new_string: str, + replace_all: bool = False, + ) -> EditResult: + """Asynchronously edit only after a successful archive read. + + Args: + file_path: Backend path to edit. + old_string: Existing archive content. + new_string: Archive content with the new section appended. + replace_all: Whether to replace every match. + + Returns: + The backend edit result. + """ + self._ensure_read_succeeded() + return await self._backend.aedit( + file_path, old_string, new_string, replace_all=replace_all + ) + + +class CLICompactionMiddleware(SummarizationToolMiddleware): + """Add hook-aware automatic and explicit forced compaction for dcode. + + The SDK tool's normal, model-initiated behavior remains unchanged. The + private `force` input is used only by the user-initiated `/offload` path, + which must compact whenever messages exceed the retention window even when + the conversation has not reached the SDK's proactive eligibility gate. + """ + + @property + def name(self) -> str: + """Replace the SDK auto-summarizer while retaining the compact tool.""" + return self._summarization.name + + @staticmethod + def _auto_compaction_id(request: ModelRequest) -> str: + """Return a stable identity for one model-input snapshot.""" + messages = request.messages + last = messages[-1] + identity = ( + last.id or hashlib.sha256(last.model_dump_json().encode()).hexdigest() + ) + return f"{len(messages)}:{identity}" + + def _pre_auto_compact(self, request: ModelRequest) -> bool: + """Run `PreCompact` before automatic summarization. + + Returns: + Whether summarization may continue. + """ + from langgraph.config import get_config + + runtime = request.runtime + gate = _session_gate(runtime.context) + if not _event_enabled(gate, HookEvent.PRE_COMPACT): + return True + try: + config = get_config() + except RuntimeError: + config = None + decision = _invoke_hook( + _hook_context(runtime.context, config, Path.cwd()), + PreCompactEvent(event=HookEvent.PRE_COMPACT, trigger=CompactTrigger.AUTO), + gate=gate, + config=config, + deadline=_DEFAULT_DEADLINE, + logical_event_id=self._auto_compaction_id(request), + ) + return _require_decision(decision, PreCompactDecision).continue_processing + + def _auto_compaction_request(self, request: ModelRequest) -> ModelRequest | None: + """Return the prepared request when threshold compaction will run.""" + summarization = self._summarization + messages = summarization._get_effective_messages(request) + tokens = summarization._count_tokens( + messages, request.system_message, request.tools + ) + messages, modified = summarization._truncate_args(messages, tokens) + if modified: + tokens = summarization._count_tokens( + messages, request.system_message, request.tools + ) + if ( + not summarization._should_summarize(messages, tokens) + or summarization._determine_cutoff_index(messages) <= 0 + ): + return None + return request.override(messages=messages) + + def _pre_overflow_compact( + self, + request: ModelRequest, + overflow: ContextOverflowError, + ) -> None: + """Gate a provider-overflow fallback before it compacts. + + Raises: + _AutoCompactionBlockedError: If the hook blocks compaction. + """ + if self._summarization._determine_cutoff_index( + request.messages + ) > 0 and not self._pre_auto_compact(request): + raise _AutoCompactionBlockedError(overflow) from overflow + + def wrap_model_call( # ty: ignore[invalid-method-override] # delegates auto summarizer + self, + request: ModelRequest, + handler: Callable[[ModelRequest], ModelResponse], + ) -> ModelResponse | ExtendedModelResponse: + """Run `PreCompact` before synchronous automatic summarization. + + Returns: + The model response. + """ + call_model = partial(super().wrap_model_call, handler=handler) + prepared = self._auto_compaction_request(request) + if prepared is not None: + if not self._pre_auto_compact(prepared): + return call_model(prepared) + return self._summarization.wrap_model_call(request, call_model) + + overflow_gated = False + + def gated_handler(next_request: ModelRequest) -> ModelResponse: + nonlocal overflow_gated + try: + return call_model(next_request) + except ContextOverflowError as overflow: + if not overflow_gated: + overflow_gated = True + self._pre_overflow_compact(next_request, overflow) + raise + + try: + return self._summarization.wrap_model_call(request, gated_handler) + except _AutoCompactionBlockedError as blocked: + raise blocked.overflow from None + + async def awrap_model_call( # ty: ignore[invalid-method-override] # delegates auto summarizer + self, + request: ModelRequest, + handler: Callable[[ModelRequest], Awaitable[ModelResponse]], + ) -> ModelResponse | ExtendedModelResponse: + """Run `PreCompact` before asynchronous automatic summarization. + + Returns: + The model response. + """ + call_model = partial(super().awrap_model_call, handler=handler) + prepared = self._auto_compaction_request(request) + if prepared is not None: + if not self._pre_auto_compact(prepared): + return await call_model(prepared) + return await self._summarization.awrap_model_call(request, call_model) + + overflow_gated = False + + async def gated_handler(next_request: ModelRequest) -> ModelResponse: + nonlocal overflow_gated + try: + return await call_model(next_request) + except ContextOverflowError as overflow: + if not overflow_gated: + overflow_gated = True + self._pre_overflow_compact(next_request, overflow) + raise + + try: + return await self._summarization.awrap_model_call(request, gated_handler) + except _AutoCompactionBlockedError as blocked: + raise blocked.overflow from None + + @staticmethod + def _offload_rejection(request: ToolCallRequest) -> ToolMessage | None: + """Reject every tool except the exact call seeded by `/offload`. + + Args: + request: Tool call about to be executed by the graph's tool node. + + Returns: + An error result for an unauthorized `/offload` tool call, otherwise + `None` for an ordinary run or the exact seeded compaction call. + """ + expected_id = _offload_tool_call_id(request.runtime.context) + if expected_id is None: + return None + + tool_call = request.tool_call + args = tool_call.get("args") + messages = request.state.get("messages", []) + last_message = messages[-1] if messages else None + last_message_id = ( + last_message.get("id") + if isinstance(last_message, dict) + else getattr(last_message, "id", None) + ) + is_seeded_compaction = ( + tool_call.get("id") == expected_id + and tool_call.get("name") == "compact_conversation" + and isinstance(args, dict) + and args.get("force") is True + and last_message_id == _offload_seed_message_id(expected_id) + ) + if is_seeded_compaction: + return None + + return ToolMessage( + content=( + "Not executed: /offload only authorizes its seeded " + "conversation compaction call." + ), + name=tool_call.get("name"), + tool_call_id=tool_call["id"], + status="error", + ) + + def wrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]], + ) -> ToolMessage | Command[Any]: + """Apply the `/offload` per-run tool guard before synchronous tools. + + Args: + request: Tool call about to be executed. + handler: The remaining middleware/tool execution chain. + + Returns: + The guarded rejection or the downstream tool result. + """ + if (rejection := self._offload_rejection(request)) is not None: + return rejection + return handler(request) + + async def awrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]], + ) -> ToolMessage | Command[Any]: + """Apply the `/offload` per-run tool guard before asynchronous tools. + + Args: + request: Tool call about to be executed. + handler: The remaining middleware/tool execution chain. + + Returns: + The guarded rejection or the downstream tool result. + """ + if (rejection := self._offload_rejection(request)) is not None: + return rejection + return await handler(request) + + def _create_compact_tool(self) -> StructuredTool: + """Create the CLI variant of `compact_conversation`. + + Returns: + A tool that accepts the `/offload`-only `force` flag. + """ + middleware = self + + # `force` is annotated `InjectedToolArg` so it is stripped from the + # schema the model sees. ToolNode also strips the seeded value before + # invocation, so forced mode is selected from the trusted runtime + # context after `_offload_rejection` validates the raw tool call. + def sync_compact( + runtime: ToolRuntime[Any, Any], + force: Annotated[bool, InjectedToolArg] = False, + ) -> Command: + del force + if _offload_tool_call_id(runtime.context) != runtime.tool_call_id: + return middleware._run_compact(runtime) + return middleware._run_forced_compact(runtime) + + async def async_compact( + runtime: ToolRuntime[Any, Any], + force: Annotated[bool, InjectedToolArg] = False, + ) -> Command: + del force + if _offload_tool_call_id(runtime.context) != runtime.tool_call_id: + return await middleware._arun_compact(runtime) + return await middleware._arun_forced_compact(runtime) + + return StructuredTool.from_function( + name="compact_conversation", + description=( + "Compact the conversation by summarizing older messages into " + "a concise summary. Use this proactively when the conversation " + "is getting long to free up context window space." + ), + func=sync_compact, + coroutine=async_compact, + ) + + def _guarded_backend(self) -> BackendProtocol: + """Wrap the configured backend with fail-closed archive append behavior. + + Returns: + A backend adapter that refuses writes after raised archive reads. + """ + return cast("BackendProtocol", _ArchiveReadGuard(self._summarization._backend)) + + def _summarization_for_runtime( + self, runtime: ToolRuntime + ) -> SummarizationMiddleware: + """Build a summarizer for the active runtime model when overridden. + + Args: + runtime: Runtime carrying the current `CLIContext`. + + Returns: + The startup summarizer when no runtime model is selected, otherwise + a model-aware summarizer using the same configured backend. + """ + config = _runtime_model_config(runtime) + if not config.model_spec: + return self._summarization + + from deepagents_code.config import create_model + + model = create_model( + config.model_spec, + extra_kwargs=config.model_params or None, + profile_overrides=config.profile_overrides or None, + ).model + context_limit = config.context_limit + if context_limit is not None: + profile = getattr(model, "profile", None) + native = ( + profile.get("max_input_tokens") if isinstance(profile, dict) else None + ) + if native != context_limit: + merged = ( + {**profile, "max_input_tokens": context_limit} + if isinstance(profile, dict) + else {"max_input_tokens": context_limit} + ) + try: + model.profile = merged # ty: ignore[invalid-assignment] + except (AttributeError, TypeError, ValueError): + logger.warning( + "Could not apply runtime context limit %d to the offload " + "model profile; using its resolved profile", + context_limit, + exc_info=True, + ) + backend = self._summarization._backend + summarization = create_summarization_middleware(model, backend) + summarization._backend = self._guarded_backend() + return summarization + + def _run_forced_compact(self, runtime: ToolRuntime) -> Command: + """Synchronously compact without the SDK eligibility gate. + + This deliberately mirrors the SDK's own `_run_compact` step sequence + (apply prior event, determine cutoff, partition, summarize, offload, + build result) minus the eligibility gate. Because it is a fork rather + than an override, it must be kept in parity when the SDK's compaction + flow changes; the closest-fitting SDK-side fix (a `force=` seam on + `_run_compact`) is out of scope for this PR, which is confined to + Deep Agents Code. `test_forced_compact_matches_sdk_summarizer_calls` + guards the summarizer-method call set against drift, but only by + *existence*: it catches a renamed or removed dependency, not a changed + signature nor a new step added to `_run_compact` (e.g. if the SDK later + moved inline-media offload into the gated path). Two known consequences + of that today: this fork does not call `_offload_inline_media` (only the + auto `wrap_model_call` path does), so inline base64 media in compacted + messages is not offloaded to referenceable paths and is dropped from the + XML archive -- pre-existing SDK tool-path behavior, not introduced here. + + Returns: + The compaction state update or an error tool message. + """ + tool_call_id = runtime.tool_call_id or "" + try: + summarization = self._summarization_for_runtime(runtime) + messages = runtime.state.get("messages", []) + event = runtime.state.get("_summarization_event") + effective = summarization._apply_event_to_messages(messages, event) + effective = _without_offload_seed(effective, tool_call_id) + cutoff = summarization._determine_cutoff_index(effective) + if cutoff == 0: + return self._nothing_to_compact(tool_call_id) + + to_summarize, _ = summarization._partition_messages(effective, cutoff) + summary = summarization._create_summary(to_summarize) + backend = self._guarded_backend() + file_path = summarization._offload_to_backend(backend, to_summarize) + # The inherited `_build_compact_result` produces the same event and + # tool message as the SDK's gated path via model-independent helpers + # (string formatting + a staticmethod), so the runtime-selected + # summarizer is not needed to build it. Kept inside the `try` so a + # failure here still returns a ToolMessage rather than raising. + return self._build_compact_result( + runtime, to_summarize, summary, file_path, event, cutoff + ) + except Exception as exc: # tool errors must surface as ToolMessages + logger.exception("forced compact_conversation failed") + return self._forced_compact_error(tool_call_id, exc) + + async def _arun_forced_compact(self, runtime: ToolRuntime) -> Command: + """Asynchronously compact without the SDK eligibility gate. + + Returns: + The compaction state update or an error tool message. + """ + tool_call_id = runtime.tool_call_id or "" + try: + summarization = await asyncio.to_thread( + self._summarization_for_runtime, runtime + ) + messages = runtime.state.get("messages", []) + event = runtime.state.get("_summarization_event") + effective = summarization._apply_event_to_messages(messages, event) + effective = _without_offload_seed(effective, tool_call_id) + cutoff = summarization._determine_cutoff_index(effective) + if cutoff == 0: + return self._nothing_to_compact(tool_call_id) + + to_summarize, _ = summarization._partition_messages(effective, cutoff) + summary = await summarization._acreate_summary(to_summarize) + backend = self._guarded_backend() + file_path = await summarization._aoffload_to_backend(backend, to_summarize) + # See `_run_forced_compact` for why the inherited builder is reused + # and why it stays inside the `try`. + return self._build_compact_result( + runtime, to_summarize, summary, file_path, event, cutoff + ) + except Exception as exc: # tool errors must surface as ToolMessages + logger.exception("forced compact_conversation failed") + return self._forced_compact_error(tool_call_id, exc) + + @staticmethod + def _forced_compact_error(tool_call_id: str, exc: Exception) -> Command: + """Build a forced-compaction failure result with a stable prefix. + + Owned by dcode so the `/offload` client can detect failures via + `COMPACTION_FAILURE_PREFIX`. The tool must return a `ToolMessage` rather + than raise, so the model (and the client) see the failure as ordinary + tool output. + + The message is intentionally generic about *where* the failure occurred: + the guarded body spans cutoff determination, summary generation, the + archive write, and result building, so it does not assert a specific + stage (and does not claim nothing was written — an archive may have been + persisted before a later step failed). It states only what is always + true on this path: the summarization event was not committed, so the + effective conversation is unchanged. + + Args: + tool_call_id: The originating tool call ID. + exc: The exception raised while compacting. + + Returns: + A `Command` whose `ToolMessage` content starts with + `COMPACTION_FAILURE_PREFIX`. + """ + return Command( + update={ + "messages": [ + ToolMessage( + content=( + f"{COMPACTION_FAILURE_PREFIX}: an error occurred " + f"during compaction ({type(exc).__name__}: {exc}). " + "Your conversation is unchanged." + ), + tool_call_id=tool_call_id, + ) + ], + } + ) + + +def _create_cli_compaction_middleware( + model: str | BaseChatModel, + backend: BackendProtocol, +) -> CLICompactionMiddleware: + """Create the dcode compaction middleware from the SDK configuration. + + Args: + model: Startup model or model specification. + backend: Agent backend used for archive persistence. + + Returns: + CLI compaction middleware with the SDK's model-aware defaults. + """ + sdk_middleware = create_summarization_tool_middleware(model, backend) + return CLICompactionMiddleware( + sdk_middleware._summarization, + system_prompt=sdk_middleware.system_prompt, + ) diff --git a/libs/code/deepagents_code/onboarding.py b/libs/code/deepagents_code/onboarding.py new file mode 100644 index 0000000000..93d7f6550b --- /dev/null +++ b/libs/code/deepagents_code/onboarding.py @@ -0,0 +1,290 @@ +"""First-run onboarding state for the interactive TUI.""" + +from __future__ import annotations + +import json +import logging +import os +from typing import TYPE_CHECKING + +from deepagents_code._env_vars import ONBOARDING, classify_env_bool +from deepagents_code.model_config import DEFAULT_STATE_DIR + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + +ONBOARDING_MARKER_FILENAME = "onboarding_complete" +"""Marker filename under `~/.deepagents/.state` after onboarding has completed.""" + +GOAL_AUTO_ACCEPT_PROMPT_MARKER_FILENAME = "goal_auto_accept_criteria_prompted_v1" +"""Marker written after the first goal-criteria preference prompt is answered.""" + +ONBOARDING_NAME_MEMORY_START = "" +"""Start marker for the managed onboarding name memory block.""" + +ONBOARDING_NAME_MEMORY_END = "" +"""End marker for the managed onboarding name memory block.""" + + +def onboarding_marker_path(state_dir: Path | None = None) -> Path: + """Return the first-run onboarding marker path. + + Args: + state_dir: Optional state directory override for tests. + + Returns: + Path to the onboarding completion marker. + """ + return (state_dir or DEFAULT_STATE_DIR) / ONBOARDING_MARKER_FILENAME + + +def has_completed_onboarding(state_dir: Path | None = None) -> bool: + """Return whether the user has completed onboarding. + + Args: + state_dir: Optional state directory override for tests. + + Returns: + `True` when the onboarding marker exists, otherwise `False`. + """ + try: + return onboarding_marker_path(state_dir).exists() + except OSError: + logger.warning("Could not inspect onboarding marker", exc_info=True) + return False + + +def mark_onboarding_complete(state_dir: Path | None = None) -> bool: + """Persist that onboarding has completed. + + Args: + state_dir: Optional state directory override for tests. + + Returns: + `True` when the marker was written, otherwise `False`. + """ + path = onboarding_marker_path(state_dir) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("1\n", encoding="utf-8") + except OSError: + logger.warning("Could not write onboarding marker at %s", path, exc_info=True) + return False + return True + + +def goal_auto_accept_prompt_marker_path(state_dir: Path | None = None) -> Path: + """Return the goal criteria preference prompt marker path. + + Args: + state_dir: Optional state directory override for tests. + + Returns: + Path to the versioned one-time prompt marker. + """ + return (state_dir or DEFAULT_STATE_DIR) / GOAL_AUTO_ACCEPT_PROMPT_MARKER_FILENAME + + +def has_shown_goal_auto_accept_prompt(state_dir: Path | None = None) -> bool: + """Return whether the goal criteria preference prompt was answered. + + Args: + state_dir: Optional state directory override for tests. + + Returns: + `True` when the prompt marker exists, otherwise `False`. + """ + try: + return goal_auto_accept_prompt_marker_path(state_dir).exists() + except OSError: + logger.warning("Could not inspect goal preference prompt marker", exc_info=True) + return False + + +def mark_goal_auto_accept_prompt_shown(state_dir: Path | None = None) -> bool: + """Persist that the goal criteria preference prompt was answered. + + Args: + state_dir: Optional state directory override for tests. + + Returns: + `True` when the marker was written, otherwise `False`. + """ + path = goal_auto_accept_prompt_marker_path(state_dir) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("1\n", encoding="utf-8") + except OSError: + logger.warning( + "Could not write goal preference prompt marker at %s", + path, + exc_info=True, + ) + return False + return True + + +def write_onboarding_name_memory( + name: str, + assistant_id: str, + *, + memory_path: Path | None = None, +) -> bool: + """Persist the optional onboarding name into user agent memory. + + Empty or whitespace-only names are skipped (no file is written). + + Args: + name: Submitted user name. + assistant_id: Agent identifier whose user memory should be updated. + memory_path: Optional memory file override for tests. + + Returns: + `True` when memory was written, otherwise `False`. + """ + clean = _normalize_memory_name(name) + if not clean: + return False + + if memory_path is None: + from deepagents_code.config import settings + + path = settings.get_user_agent_md_path(assistant_id) + else: + path = memory_path + + block = _onboarding_name_memory_block(clean) + try: + path.parent.mkdir(parents=True, exist_ok=True) + try: + existing = path.read_text(encoding="utf-8") + except FileNotFoundError: + existing = "" + except UnicodeDecodeError: + # Existing memory file is not valid UTF-8. Overwriting would clobber + # whatever the user has there, so abort and let them resolve it. + logger.warning( + "Existing memory file %s is not valid UTF-8; skipping onboarding " + "name memory write to avoid clobbering user content", + path, + exc_info=True, + ) + return False + path.write_text( + _upsert_onboarding_name_memory(existing, block), + encoding="utf-8", + ) + except OSError: + logger.warning( + "Could not write onboarding name memory at %s", + path, + exc_info=True, + ) + return False + return True + + +def _normalize_memory_name(name: str) -> str: + """Normalize whitespace in a name before writing it to memory. + + Returns: + Name with leading/trailing whitespace stripped and internal runs + collapsed to single spaces. + """ + return " ".join(name.split()) + + +def _onboarding_name_memory_block(name: str) -> str: + """Return the managed memory block for an onboarding name.""" + quoted = json.dumps(name) + return ( + f"{ONBOARDING_NAME_MEMORY_START}\n" + f"- The user's preferred name is {quoted}.\n" + f"{ONBOARDING_NAME_MEMORY_END}" + ) + + +def _upsert_onboarding_name_memory(existing: str, block: str) -> str: + """Insert or replace the managed onboarding name memory block. + + Returns: + Updated memory file content. + """ + start = existing.find(ONBOARDING_NAME_MEMORY_START) + end = existing.find(ONBOARDING_NAME_MEMORY_END) + if start != -1 and end != -1 and start < end: + end += len(ONBOARDING_NAME_MEMORY_END) + prefix = existing[:start].rstrip() + suffix = existing[end:].strip() + parts = [part for part in (prefix, block, suffix) if part] + return "\n\n".join(parts).rstrip() + "\n" + + base = existing.rstrip() + if not base: + return f"## User Preferences\n\n{block}\n" + if "## User Preferences" in base: + return f"{base}\n\n{block}\n" + return f"{base}\n\n## User Preferences\n\n{block}\n" + + +def extract_onboarding_name_block(text: str) -> str | None: + """Return the managed onboarding name block (markers included) if present. + + Args: + text: Memory file content to inspect. + + Returns: + The substring from the start marker through the end marker, or `None` + when a well-formed block is absent. + """ + start = text.find(ONBOARDING_NAME_MEMORY_START) + end = text.find(ONBOARDING_NAME_MEMORY_END) + if start == -1 or end == -1 or start >= end: + return None + return text[start : end + len(ONBOARDING_NAME_MEMORY_END)] + + +def strip_onboarding_name_markers(text: str) -> str: + """Remove every onboarding-name marker occurrence from `text`. + + A partial edit can leave a lone start or end marker behind. Stripping all + marker strings before re-inserting the managed block keeps re-insertion from + producing orphaned markers that would confuse `extract_onboarding_name_block`. + + Args: + text: Memory file content to sanitize. + + Returns: + `text` with all start and end marker strings removed. + """ + return text.replace(ONBOARDING_NAME_MEMORY_START, "").replace( + ONBOARDING_NAME_MEMORY_END, "" + ) + + +def should_run_onboarding(state_dir: Path | None = None) -> bool: + """Return whether onboarding should open at interactive startup. + + `DEEPAGENTS_CODE_ONBOARDING` overrides the marker in both directions: a + truthy value forces the flow open on every startup, and a falsy value keeps + it closed even on a fresh install. An unset or unrecognized value leaves the + marker in charge, so the override never has to be unset to get first-run + behavior back. + + Args: + state_dir: Optional state directory override for tests. + + Returns: + `True` when the env override is truthy, or when it does not apply and no + completion marker exists. + """ + raw = os.environ.get(ONBOARDING) + if raw is not None: + override = classify_env_bool(raw) + if override is None: + logger.warning("Ignoring %s=%r (expected bool)", ONBOARDING, raw) + else: + return override + return not has_completed_onboarding(state_dir) diff --git a/libs/cli/deepagents_cli/output.py b/libs/code/deepagents_code/output.py similarity index 100% rename from libs/cli/deepagents_cli/output.py rename to libs/code/deepagents_code/output.py diff --git a/libs/code/deepagents_code/paste_collapse.py b/libs/code/deepagents_code/paste_collapse.py new file mode 100644 index 0000000000..ddb7f3512a --- /dev/null +++ b/libs/code/deepagents_code/paste_collapse.py @@ -0,0 +1,103 @@ +r"""Large paste collapsing for the chat input. + +When the user pastes text exceeding a size or line threshold, the full text +is stored off-screen and a compact `[Pasted text #N +M lines]` placeholder +is inserted into the input box instead. At submission time the placeholder +is expanded back to the original content so the agent receives the full text. + +This mirrors the behavior of Claude Code's paste-collapsing system. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +PASTE_THRESHOLD_CHARS = 800 +"""Minimum character count for a paste to be collapsed into a placeholder.""" + +PASTE_THRESHOLD_LINES = 2 +"""Minimum line count (newline-separated) for a paste to be collapsed.""" + +PASTE_PLACEHOLDER_PATTERN = re.compile(r"\[Pasted text #(\d+)(?: \+(\d+) lines)?\]") +"""Regex matching `[Pasted text #N]` or `[Pasted text #N +M lines]`.""" + + +@dataclass(frozen=True) +class PastedContent: + """Stored content for a collapsed paste. + + The paste's numeric identifier is the key under which this record is + stored in the input's paste map, so it is not duplicated on the record. + + Attributes: + content: The full pasted text. + """ + + content: str + + +def count_lines(text: str) -> int: + r"""Return the number of newline characters in `text`. + + Args: + text: The text to count newlines in. + + Returns: + The number of `\n` occurrences (0 for single-line text). + """ + return text.count("\n") + + +def should_collapse_paste(text: str) -> bool: + """Return whether `text` should be collapsed into a placeholder. + + Collapses when the text exceeds the character threshold or the line + threshold. + + Args: + text: The pasted text to evaluate. + + Returns: + `True` if the paste should be collapsed. + """ + return ( + len(text) > PASTE_THRESHOLD_CHARS or count_lines(text) > PASTE_THRESHOLD_LINES + ) + + +def format_paste_ref(paste_id: int, num_lines: int) -> str: + """Format a paste placeholder reference string. + + Args: + paste_id: The numeric paste identifier. + num_lines: The number of extra lines (newlines) in the pasted content. + + Returns: + `[Pasted text #N]` when `num_lines` is 0, otherwise + `[Pasted text #N +M lines]`. + """ + if num_lines == 0: + return f"[Pasted text #{paste_id}]" + return f"[Pasted text #{paste_id} +{num_lines} lines]" + + +def expand_paste_refs(text: str, pasted_contents: dict[int, PastedContent]) -> str: + """Replace all paste placeholders in `text` with their full content. + + Placeholders whose IDs are not in `pasted_contents` are left unchanged. + + Args: + text: The text containing placeholders. + pasted_contents: Mapping of paste IDs to stored content. + + Returns: + The text with all known placeholders expanded. + """ + + def _replace(match: re.Match[str]) -> str: + content = pasted_contents.get(int(match.group(1))) + # Return the stored text literally; unknown IDs keep their placeholder. + return content.content if content is not None else match.group(0) + + return PASTE_PLACEHOLDER_PATTERN.sub(_replace, text) diff --git a/libs/code/deepagents_code/plugins/__init__.py b/libs/code/deepagents_code/plugins/__init__.py new file mode 100644 index 0000000000..3be6e04c7a --- /dev/null +++ b/libs/code/deepagents_code/plugins/__init__.py @@ -0,0 +1,28 @@ +"""Plugin support for dcode.""" + +from deepagents_code.plugins.discovery import ( + add_local_marketplace, + add_marketplace_source, + discover_plugins, + install_plugin, + list_available_plugins, + list_installed_plugin_ids, + remove_marketplace, + set_installed_plugin_enabled, + uninstall_plugin, +) +from deepagents_code.plugins.models import PluginDiscoveryResult, PluginInstance + +__all__ = [ + "PluginDiscoveryResult", + "PluginInstance", + "add_local_marketplace", + "add_marketplace_source", + "discover_plugins", + "install_plugin", + "list_available_plugins", + "list_installed_plugin_ids", + "remove_marketplace", + "set_installed_plugin_enabled", + "uninstall_plugin", +] diff --git a/libs/code/deepagents_code/plugins/_json.py b/libs/code/deepagents_code/plugins/_json.py new file mode 100644 index 0000000000..a6e15cc2b8 --- /dev/null +++ b/libs/code/deepagents_code/plugins/_json.py @@ -0,0 +1,45 @@ +"""Internal JSON normalization helpers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from deepagents_code.plugins.models import JsonObject, JsonValue + + +def json_value(value: object) -> JsonValue | None: + """Normalize a decoded value to the supported JSON type. + + Returns: + The normalized value, or `None` for an unsupported value. + """ + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, list): + normalized: list[JsonValue] = [] + for item in value: + converted = json_value(item) + if converted is not None or item is None: + normalized.append(converted) + return normalized + if isinstance(value, dict): + normalized_object: JsonObject = {} + for key, item in value.items(): + if not isinstance(key, str): + continue + converted = json_value(item) + if converted is not None or item is None: + normalized_object[key] = converted + return normalized_object + return None + + +def json_object(value: object) -> JsonObject: + """Normalize a decoded value to a JSON object. + + Returns: + The normalized object, or an empty object for a non-object value. + """ + converted = json_value(value) + return converted if isinstance(converted, dict) else {} diff --git a/libs/code/deepagents_code/plugins/adapters/__init__.py b/libs/code/deepagents_code/plugins/adapters/__init__.py new file mode 100644 index 0000000000..6dbc783ead --- /dev/null +++ b/libs/code/deepagents_code/plugins/adapters/__init__.py @@ -0,0 +1 @@ +"""Plugin subsystem adapters.""" diff --git a/libs/code/deepagents_code/plugins/adapters/hooks.py b/libs/code/deepagents_code/plugins/adapters/hooks.py new file mode 100644 index 0000000000..7ca45ef235 --- /dev/null +++ b/libs/code/deepagents_code/plugins/adapters/hooks.py @@ -0,0 +1,157 @@ +"""Adapter from plugin hook declarations to Hooks v2 configuration sources.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from deepagents_code.hooks.loading import PluginHooksSource, read_hooks_json +from deepagents_code.hooks.models.domain import HookDiagnostic, HookEvent +from deepagents_code.plugins.manifest import find_manifest_path +from deepagents_code.plugins.substitution import plugin_environment + +if TYPE_CHECKING: + from pathlib import Path + + from deepagents_code.plugins.models import JsonValue, PluginInstance + +logger = logging.getLogger(__name__) + +_KNOWN_EVENTS = frozenset(event.value for event in HookEvent) + +PluginHooksDocument = tuple[PluginHooksSource, "JsonValue"] +PluginHookSources = tuple[tuple[PluginHooksDocument, ...], tuple[HookDiagnostic, ...]] + + +def _diagnostic( + message: str, *, field: str | None = None, exc_info: bool = False +) -> HookDiagnostic: + logger.warning(message, exc_info=exc_info) + return HookDiagnostic( + code="plugin_hooks_failed", + severity="warning", + message=message, + field=field, + ) + + +def _plugin_documents( + plugin: PluginInstance, +) -> tuple[list[tuple[Path, JsonValue]], list[HookDiagnostic]]: + """Collect decoded file and inline hook documents in declaration order. + + Returns: + Documents with diagnostic locations, plus read diagnostics. + """ + documents: list[tuple[Path, JsonValue]] = [] + diagnostics: list[HookDiagnostic] = [] + for path in plugin.inventory.hook_files: + decoded, document, read_diagnostics, _fingerprint = read_hooks_json(path) + diagnostics.extend(read_diagnostics) + if decoded: + documents.append((path, document)) + manifest = plugin.manifest + if manifest and manifest.inline_hooks: + manifest_path = find_manifest_path(plugin.root) or plugin.root + documents.append((manifest_path, manifest.inline_hooks)) + return documents, diagnostics + + +def discover_plugin_hook_sources( + *, + project_dir: Path | None = None, + plugins: tuple[PluginInstance, ...] | None = None, +) -> PluginHookSources: + """Build hook sources from enabled or already-discovered plugins. + + Args: + project_dir: Project directory exposed as `${CLAUDE_PROJECT_DIR}`. + plugins: Already-discovered plugins, or `None` to discover them here. + + Returns: + Sourced hook documents and collection diagnostics. + """ + diagnostics: list[HookDiagnostic] = [] + if plugins is None: + try: + from deepagents_code.plugins import discover_plugins + + result = discover_plugins() + # Discovery failure must not take user and project hooks down with it. + except Exception as exc: # noqa: BLE001 + return (), ( + _diagnostic(f"Could not discover plugin hooks: {exc}", exc_info=True), + ) + plugins = result.plugins + diagnostics.extend( + _diagnostic(f"Plugin discovery warning: {warning}") + for warning in result.warnings + ) + documents: list[PluginHooksDocument] = [] + for plugin in plugins: + try: + plugin_documents, plugin_diagnostics = _plugin_documents(plugin) + if not plugin_documents: + diagnostics.extend(plugin_diagnostics) + continue + try: + plugin.data_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + plugin_diagnostics.append( + _diagnostic( + f"Could not create the data directory for plugin " + f"{plugin.plugin_id}: {exc}", + field=str(plugin.data_dir), + ) + ) + env = plugin_environment( + plugin_root=plugin.root, + plugin_data=plugin.data_dir, + project_dir=project_dir, + ) + sources = tuple( + ( + PluginHooksSource( + location=str(path), plugin_id=plugin.plugin_id, env=env + ), + document, + ) + for path, document in plugin_documents + ) + # A broken plugin must not withhold every other plugin's hooks. + except Exception as exc: # noqa: BLE001 + diagnostics.append( + _diagnostic( + f"Could not load hooks for plugin {plugin.plugin_id}: {exc}", + field=str(plugin.root), + exc_info=True, + ) + ) + continue + documents.extend(sources) + diagnostics.extend(plugin_diagnostics) + return tuple(documents), tuple(diagnostics) + + +def plugin_hook_event_names(plugin: PluginInstance) -> tuple[str, ...]: + """List the hook events a plugin declares, for display before it loads. + + Only events Hooks v2 recognizes are returned, so the plugin manager never + advertises a hook that the loader will later reject. + + Args: + plugin: Plugin whose declarations should be inspected. + + Returns: + Declared event names in declaration order, deduplicated. + """ + events: list[str] = [] + documents, _diagnostics = _plugin_documents(plugin) + for _path, document in documents: + if not isinstance(document, dict): + continue + hooks = document.get("hooks") + if not isinstance(hooks, dict): + continue + events.extend(name for name in hooks if name in _KNOWN_EVENTS) + return tuple(dict.fromkeys(events)) diff --git a/libs/code/deepagents_code/plugins/adapters/mcp.py b/libs/code/deepagents_code/plugins/adapters/mcp.py new file mode 100644 index 0000000000..f2845c9c7d --- /dev/null +++ b/libs/code/deepagents_code/plugins/adapters/mcp.py @@ -0,0 +1,260 @@ +"""Adapter from plugin MCP declarations to dcode MCP config dictionaries.""" + +from __future__ import annotations + +import json +import logging +import re +from hashlib import sha256 +from pathlib import Path +from typing import TYPE_CHECKING + +from deepagents_code.plugins._json import json_object, json_value +from deepagents_code.plugins.substitution import plugin_environment, substitute_json + +if TYPE_CHECKING: + from deepagents_code.plugins.models import JsonObject, JsonValue, PluginInstance + +logger = logging.getLogger(__name__) +# For example, `tools@example.com` becomes `tools_example_com_`. +_MCP_NAME_PART_RE = re.compile(r"[^A-Za-z0-9_-]+") +_MCP_NAME_PART_LENGTH = 48 + + +def _safe_mcp_name_part(value: str) -> str: + sanitized = _MCP_NAME_PART_RE.sub("_", value).strip("_") + if sanitized == value and sanitized and len(sanitized) <= _MCP_NAME_PART_LENGTH: + return sanitized + digest = sha256(value.encode()).hexdigest()[:8] + prefix = sanitized[:_MCP_NAME_PART_LENGTH] or "unnamed" + return f"{prefix}_{digest}" + + +def scoped_mcp_server_name(plugin_id: str, server_name: str) -> str: + """Namespace a plugin-declared MCP server's name under its plugin id. + + Plugin identifiers may contain characters rejected by dcode's MCP loader. + Use `__` as the namespace separator so names stay unique and valid. + + Args: + plugin_id: Full plugin id in `name@marketplace` form. + server_name: Unscoped server name from the plugin config. + + Returns: + Scoped server name safe for `_SERVER_NAME_RE`. + """ + plugin_part = _safe_mcp_name_part(plugin_id) + server_part = _safe_mcp_name_part(server_name) + return f"plugin__{plugin_part}__{server_part}" + + +def _mcp_server_needs_login(server: object) -> bool: + """Return whether an MCP server config typically requires interactive login.""" + if not isinstance(server, dict): + return False + server_type = server.get("type") + if server_type in {"http", "sse"}: + return True + return isinstance(server.get("url"), str) + + +def plugin_mcp_server_entries( + plugin: PluginInstance, +) -> tuple[tuple[str, str, bool], ...]: + """List plugin MCP servers as `(label, scoped_name, needs_login)` tuples. + + `label` is the unscoped name from the plugin config (for UI). `scoped_name` + is what dcode registers after namespacing. + + Args: + plugin: Plugin whose MCP declarations should be listed. + + Returns: + Deduplicated server entries in declaration order. + """ + servers: dict[str, object] = {} + for path in plugin.inventory.mcp_files: + if path.suffix in {".mcpb", ".dxt"}: + continue + servers.update(_load_mcp_server_map(path)) + if plugin.manifest and plugin.manifest.inline_mcp: + servers.update(_server_map(plugin.manifest.inline_mcp)) + entries: list[tuple[str, str, bool]] = [] + seen: set[str] = set() + for name, server in servers.items(): + if not isinstance(name, str) or name in seen: + continue + seen.add(name) + entries.append( + ( + name, + scoped_mcp_server_name(plugin.plugin_id, name), + _mcp_server_needs_login(server), + ) + ) + return tuple(entries) + + +def _server_map(raw: object) -> JsonObject: + """Extract the server-name to config map from a decoded MCP document. + + Accepts Claude's `{"mcpServers": {...}}` wrapper, Codex's + `{"mcp_servers": {...}}` wrapper, or a bare server map. + + Returns: + The extracted server map, or an empty map for non-object input. + """ + if not isinstance(raw, dict): + return {} + wrapped = raw.get("mcpServers") + if isinstance(wrapped, dict): + return json_object(wrapped) + codex_wrapped = raw.get("mcp_servers") + if isinstance(codex_wrapped, dict): + return json_object(codex_wrapped) + return json_object(raw) + + +def _load_mcp_server_map(path: Path) -> JsonObject: + """Load an MCP config file and extract its server-name to config map. + + Returns: + The extracted server map, or an empty map when the file cannot be read. + """ + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Skipping plugin MCP config %s: %s", path, exc) + return {} + return _server_map(raw) + + +def _plugin_mcp_server_map(plugin: PluginInstance) -> JsonObject: + """Load a plugin's declared MCP servers without creating runtime state. + + Returns: + The unscoped server configuration keyed by declared server name. + """ + servers: JsonObject = {} + for path in plugin.inventory.mcp_files: + if path.suffix in {".mcpb", ".dxt"}: + logger.warning( + "Skipping unsupported MCP bundle for plugin %s: %s", + plugin.plugin_id, + path, + ) + continue + servers.update(_load_mcp_server_map(path)) + if plugin.manifest and plugin.manifest.inline_mcp: + servers.update(_server_map(plugin.manifest.inline_mcp)) + return servers + + +def plugin_mcp_server_names(plugin: PluginInstance) -> tuple[str, ...]: + """Return scoped MCP server names without preparing plugin runtime state. + + Args: + plugin: Plugin instance whose declarations should be inspected. + + Returns: + Scoped MCP server names in declaration order. + """ + return tuple( + scoped_mcp_server_name(plugin.plugin_id, name) + for name in _plugin_mcp_server_map(plugin) + if isinstance(name, str) + ) + + +def _normalize_server( + server: object, *, plugin: PluginInstance, project_dir: Path | None +) -> JsonValue: + normalized_server = json_value(server) + substituted = substitute_json( + normalized_server, + plugin_root=plugin.root, + plugin_data=plugin.data_dir, + project_dir=project_dir, + ) + if isinstance(substituted, dict): + cwd = substituted.get("cwd") + if isinstance(cwd, str) and cwd and not Path(cwd).is_absolute(): + substituted = {**substituted, "cwd": str((plugin.root / cwd).resolve())} + env = substituted.get("env") + plugin_env = plugin_environment( + plugin_root=plugin.root, + plugin_data=plugin.data_dir, + project_dir=project_dir, + ) + if isinstance(env, dict): + substituted = {**substituted, "env": {**plugin_env, **env}} + else: + substituted = {**substituted, "env": plugin_env} + return json_value(substituted) + + +def discover_plugin_mcp_configs( + *, project_dir: Path | None = None +) -> tuple[JsonObject, ...]: + """Discover enabled plugins and compose their MCP config layers. + + Args: + project_dir: Project directory for variable substitution. + + Returns: + Plugin MCP config layers, or an empty tuple when discovery fails. + """ + try: + from deepagents_code.plugins import discover_plugins + + result = discover_plugins() + except (OSError, RuntimeError): + logger.warning("Could not discover plugin MCP configs", exc_info=True) + return () + if result.warnings: + logger.warning( + "Plugin discovery warnings while loading MCP: %s", result.warnings + ) + return tuple(plugin_mcp_configs(result.plugins, project_dir=project_dir)) + + +def plugin_mcp_configs( + plugins: tuple[PluginInstance, ...], *, project_dir: Path | None = None +) -> list[JsonObject]: + """Build MCP config layers for enabled plugins. + + Default `.mcp.json` files are loaded before manifest `mcpServers`, so manifest + entries win on server-name conflicts. + + Args: + plugins: Enabled plugin instances. + project_dir: Project directory for `${CLAUDE_PROJECT_DIR}` substitution. + + Returns: + MCP config layers ready for dcode's merge path. + """ + configs: list[JsonObject] = [] + for plugin in plugins: + # Create the writable data dir when MCP configs need it. Discovery itself + # only computes the path so it stays safe for blockbuster-guarded callers. + try: + plugin.data_dir.mkdir(parents=True, exist_ok=True) + except OSError: + logger.warning( + "Could not create plugin data dir for %s: %s", + plugin.plugin_id, + plugin.data_dir, + exc_info=True, + ) + servers = _plugin_mcp_server_map(plugin) + scoped: JsonObject = {} + for name, server in servers.items(): + if not isinstance(name, str): + continue + scoped_name = scoped_mcp_server_name(plugin.plugin_id, name) + scoped[scoped_name] = _normalize_server( + server, plugin=plugin, project_dir=project_dir + ) + if scoped: + configs.append({"mcpServers": scoped}) + return configs diff --git a/libs/code/deepagents_code/plugins/adapters/skills.py b/libs/code/deepagents_code/plugins/adapters/skills.py new file mode 100644 index 0000000000..ccc62412c0 --- /dev/null +++ b/libs/code/deepagents_code/plugins/adapters/skills.py @@ -0,0 +1,133 @@ +"""Adapter from discovered plugins to `SkillsMiddleware` sources.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, TypeAlias + +if TYPE_CHECKING: + from deepagents_code.plugins.models import PluginInstance + +logger = logging.getLogger(__name__) + +SkillPath: TypeAlias = str +SkillLabel: TypeAlias = str +SkillNamespace: TypeAlias = str +DirectorySkillSource: TypeAlias = tuple[SkillPath, SkillLabel] +PluginSkillSource: TypeAlias = tuple[SkillPath, SkillLabel, SkillNamespace] +CodeSkillSource: TypeAlias = DirectorySkillSource | PluginSkillSource + + +def namespaced_skill_name( + namespace: SkillNamespace, + name: str, + subfolders: tuple[str, ...] = (), +) -> str: + """Qualify a skill name under its plugin namespace. + + Nested skill directories contribute intermediate `:`-joined segments + between the plugin namespace and the skill name, matching the plugin skill + naming convention (e.g. `plugin:sub:review`). + + Args: + namespace: Plugin namespace (its `plugin_id`). + name: Skill name from the skill's frontmatter. + subfolders: Directory names between the plugin skills root and the + skill directory, in path order. + + Returns: + The qualified skill name. + """ + return ":".join((namespace, *subfolders, name)).lower() + + +def plugin_skill_sources( + plugins: tuple[PluginInstance, ...], +) -> list[PluginSkillSource]: + """Return skill source tuples for plugin skills. + + Args: + plugins: Plugin instances. + + Returns: + Source tuples containing path, label, and plugin namespace. + """ + sources: list[PluginSkillSource] = [] + for plugin in plugins: + for path in plugin.inventory.skills: + source_path = path.parent if path.name == "SKILL.md" else path + try: + if not source_path.exists(): + continue + except OSError: + logger.warning("Could not inspect plugin skill path %s", source_path) + continue + sources.append( + ( + str(source_path), + f"Plugin: {plugin.plugin_id}", + plugin.plugin_id, + ) + ) + return sources + + +def plugin_skill_roots(plugins: tuple[PluginInstance, ...]) -> list[Path]: + """Return plugin skill roots for skill-content containment checks. + + Args: + plugins: Discovered plugin instances. + + Returns: + Skill root directories. + """ + roots: list[Path] = [] + for plugin in plugins: + roots.extend( + path.parent if path.name == "SKILL.md" else path + for path in plugin.inventory.skills + ) + return roots + + +def discover_plugin_skill_state() -> tuple[ + tuple[tuple[Path, str], ...], tuple[Path, ...], frozenset[str] +]: + """Discover plugin skill sources, containment roots, and loaded ids. + + Returns: + Plugin skill sources, roots, and ids, or empty values when discovery + fails. + """ + plugin_sources: tuple[tuple[Path, str], ...] = () + plugin_roots: tuple[Path, ...] = () + plugin_ids: frozenset[str] = frozenset() + try: + from deepagents_code.plugins import discover_plugins + + plugins = discover_plugins().plugins + plugin_sources = tuple( + (Path(path), namespace) + for path, _label, namespace in plugin_skill_sources(plugins) + ) + plugin_roots = tuple(plugin_skill_roots(plugins)) + plugin_ids = frozenset(plugin.plugin_id for plugin in plugins) + except (OSError, RuntimeError): + logger.warning("Could not discover plugin skills", exc_info=True) + return (), (), frozenset() + + return plugin_sources, plugin_roots, plugin_ids + + +def discover_plugin_skill_sources_and_roots() -> tuple[ + tuple[tuple[Path, str], ...], tuple[Path, ...] +]: + """Discover plugin skill sources and containment roots. + + Returns: + Plugin skill sources and roots, or empty tuples when discovery fails. + """ + plugin_sources, plugin_roots, _plugin_ids = discover_plugin_skill_state() + + return plugin_sources, plugin_roots diff --git a/libs/code/deepagents_code/plugins/adapters/skills_middleware.py b/libs/code/deepagents_code/plugins/adapters/skills_middleware.py new file mode 100644 index 0000000000..e29b1e36ee --- /dev/null +++ b/libs/code/deepagents_code/plugins/adapters/skills_middleware.py @@ -0,0 +1,372 @@ +"""Code-local skills middleware adapter for plugin namespaces.""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, cast + +from deepagents.backends.protocol import FileInfo, LsResult +from deepagents.backends.utils import to_posix_path +from deepagents.middleware import skills as sdk_skills +from deepagents.middleware.skills import SkillsMiddleware + +from deepagents_code.plugins.adapters.skills import ( + CodeSkillSource, + SkillNamespace, + namespaced_skill_name, +) +from deepagents_code.skills.merge import merge_skill + +if TYPE_CHECKING: + from collections.abc import Sequence + + from deepagents.backends.protocol import BackendProtocol + from langchain_core.runnables import RunnableConfig + from langgraph.runtime import Runtime + +logger = logging.getLogger(__name__) + +_PLUGIN_SKILL_SOURCE_LENGTH = 3 +_SKILL_FILE = "SKILL.md" + + +def _entries(ls_result: object) -> list[FileInfo]: + """Normalize a backend `ls` result to a list of entry dicts. + + Returns: + The listing entries, or an empty list when the result is empty or an + unexpected shape. + """ + if isinstance(ls_result, LsResult): + return list(ls_result.entries or []) + if isinstance(ls_result, list): + return cast("list[FileInfo]", ls_result) + return [] + + +def _child_dirs(entries: list[FileInfo], root: str) -> list[tuple[str, str]]: + """Return `(name, path)` for each immediate subdirectory in `entries`. + + Returns: + Name/path pairs for each immediate subdirectory, excluding `root`. + """ + root_posix = PurePosixPath(to_posix_path(root)) + dirs: list[tuple[str, str]] = [] + for entry in entries: + if not entry.get("is_dir"): + continue + path = entry["path"] + name = PurePosixPath(to_posix_path(path)).name + # Skip the source dir itself if a backend echoes it back. + if PurePosixPath(to_posix_path(path)) == root_posix: + continue + dirs.append((name, path)) + return dirs + + +def _has_skill_file(entries: list[FileInfo], root: str) -> bool: + """Return whether `entries` contains a `SKILL.md` directly under `root`.""" + root_posix = PurePosixPath(to_posix_path(root)) + for entry in entries: + path = PurePosixPath(to_posix_path(entry["path"])) + if path.name == _SKILL_FILE and path.parent == root_posix: + return True + return False + + +def _skill_md_path(skill_dir: str) -> str: + """Return the `SKILL.md` path inside a skill directory.""" + return str(PurePosixPath(to_posix_path(skill_dir)) / _SKILL_FILE) + + +def _namespace_skill( + skill: sdk_skills.SkillMetadata, + namespace: SkillNamespace, + subfolders: tuple[str, ...], +) -> sdk_skills.SkillMetadata: + """Return a copy of `skill` with a namespace-qualified name.""" + return cast( + "sdk_skills.SkillMetadata", + { + **skill, + "name": namespaced_skill_name(namespace, skill["name"], subfolders), + }, + ) + + +def discover_skill_dirs( + backend: BackendProtocol, + source_path: str, +) -> list[tuple[str, tuple[str, ...]]]: + """Return `(skill_dir, subfolders)` pairs found under `source_path`. + + Walks the source tree, treating any directory that directly contains a + `SKILL.md` as a skill directory (a recursion leaf, like a plugin walker). + `subfolders` holds the directory names between the source + root and the skill directory, excluding the skill directory's own name. + + Returns: + Skill directories paired with their intermediate subfolder segments. + """ + found: list[tuple[str, tuple[str, ...]]] = [] + # `path_segments` accumulates directory names from the source root down to + # and including `current`. A skill directory's own name is dropped when + # naming, since the skill's terminal identifier is its frontmatter name; + # only the directories above it form the namespace segments. + source_root = Path(source_path).resolve() + visited: set[Path] = set() + stack: list[tuple[str, tuple[str, ...]]] = [(str(source_root), ())] + while stack: + current, path_segments = stack.pop() + try: + resolved = Path(current).resolve() + except (OSError, RuntimeError): + logger.warning("Could not resolve plugin skill directory %s", current) + continue + if not resolved.is_relative_to(source_root) or resolved in visited: + continue + visited.add(resolved) + resolved_path = str(resolved) + entries = _entries(backend.ls(resolved_path)) + if _has_skill_file(entries, resolved_path): + found.append((resolved_path, path_segments[:-1])) + continue + for name, path in _child_dirs(entries, resolved_path): + stack.append((path, (*path_segments, name))) + return found + + +async def adiscover_skill_dirs( + backend: BackendProtocol, + source_path: str, +) -> list[tuple[str, tuple[str, ...]]]: + """Async counterpart of `discover_skill_dirs`. + + Returns: + Skill directories paired with their intermediate subfolder segments. + """ + found: list[tuple[str, tuple[str, ...]]] = [] + source_root = await asyncio.to_thread(Path(source_path).resolve) + visited: set[Path] = set() + stack: list[tuple[str, tuple[str, ...]]] = [(str(source_root), ())] + while stack: + current, path_segments = stack.pop() + try: + resolved = await asyncio.to_thread(Path(current).resolve) + except (OSError, RuntimeError): + logger.warning("Could not resolve plugin skill directory %s", current) + continue + if not resolved.is_relative_to(source_root) or resolved in visited: + continue + visited.add(resolved) + resolved_path = str(resolved) + entries = _entries(await backend.als(resolved_path)) + if _has_skill_file(entries, resolved_path): + found.append((resolved_path, path_segments[:-1])) + continue + for name, path in _child_dirs(entries, resolved_path): + stack.append((path, (*path_segments, name))) + return found + + +def load_namespaced_skills( + backend: BackendProtocol, + source_path: str, + namespace: SkillNamespace, +) -> list[sdk_skills.SkillMetadata]: + """Load and namespace every skill found under a plugin source. + + Reads each discovered skill directory's `SKILL.md` directly, since the SDK + loader only scans one level below a source and would not read a leaf + directory's own `SKILL.md`. Nested directories become `:`-joined namespace + segments (e.g. `plugin:foo:bar:review`). + + Returns: + Namespace-qualified skill metadata for the source. + """ + skill_dirs = discover_skill_dirs(backend, source_path) + if not skill_dirs: + return [] + paths = [_skill_md_path(skill_dir) for skill_dir, _ in skill_dirs] + responses = backend.download_files(paths) + skills: list[sdk_skills.SkillMetadata] = [] + for (skill_dir, segments), path, response in zip( + skill_dirs, paths, responses, strict=True + ): + skill = sdk_skills._skill_metadata_from_response(response, skill_dir, path) + if skill is not None: + skills.append(_namespace_skill(skill, namespace, segments)) + return skills + + +async def aload_namespaced_skills( + backend: BackendProtocol, + source_path: str, + namespace: SkillNamespace, +) -> list[sdk_skills.SkillMetadata]: + """Async counterpart of `load_namespaced_skills`. + + Returns: + Namespace-qualified skill metadata for the source. + """ + skill_dirs = await adiscover_skill_dirs(backend, source_path) + if not skill_dirs: + return [] + paths = [_skill_md_path(skill_dir) for skill_dir, _ in skill_dirs] + responses = await backend.adownload_files(paths) + skills: list[sdk_skills.SkillMetadata] = [] + for (skill_dir, segments), path, response in zip( + skill_dirs, paths, responses, strict=True + ): + skill = sdk_skills._skill_metadata_from_response(response, skill_dir, path) + if skill is not None: + skills.append(_namespace_skill(skill, namespace, segments)) + return skills + + +class PluginSkillsMiddleware(SkillsMiddleware): + """Load namespaced plugin skills without extending the SDK source API. + + Wraps the SDK `SkillsMiddleware`. Sources without a namespace load exactly + as the SDK loads them. Sources carrying a plugin namespace are walked + recursively so nested skill directories (`skills/foo/bar/review/SKILL.md`) + are discovered, and each skill's name is qualified as + `plugin_id:foo:bar:review` before the last-one-wins merge — matching + the plugin skill naming convention. + """ + + def __init__( + self, + *, + backend: BackendProtocol, + sources: Sequence[CodeSkillSource], + system_prompt: str | None = sdk_skills.SKILLS_SYSTEM_PROMPT, + ) -> None: + """Initialize the middleware with Code-local plugin source tuples. + + Args: + backend: Backend used to load skill files. + sources: Ordered Code skill sources, optionally including a plugin + namespace as the third tuple item. + system_prompt: Skills prompt template passed to the SDK middleware. + """ + sdk_sources = [(source[0], source[1]) for source in sources] + super().__init__( + backend=backend, + sources=sdk_sources, + system_prompt=system_prompt, + ) + self._namespaces = tuple( + source[2] if len(source) == _PLUGIN_SKILL_SOURCE_LENGTH else None + for source in sources + ) + + @staticmethod + def _state_update( + all_skills: dict[str, sdk_skills.SkillMetadata], + errors: list[str], + ) -> sdk_skills.SkillsStateUpdate: + """Build the middleware state update, logging any load errors. + + Returns: + The state update carrying merged skill metadata and any errors. + """ + update = sdk_skills.SkillsStateUpdate(skills_metadata=list(all_skills.values())) + if errors: + logger.warning("Skills load errors: %s", errors) + update["skills_load_errors"] = errors + return update + + def before_agent( + self, + state: sdk_skills.SkillsState, + runtime: Runtime, # noqa: ARG002 + config: RunnableConfig, # noqa: ARG002 + ) -> sdk_skills.SkillsStateUpdate | None: + """Load and namespace plugin skills before collision resolution. + + Returns: + A state update containing collision-safe skill metadata, or `None` + when skills are already loaded. + """ + if "skills_metadata" in state: + return None + + backend = self._backend + all_skills: dict[str, sdk_skills.SkillMetadata] = {} + merged_source_labels: dict[str, str | None] = {} + errors: list[str] = [] + + # `self.sources`, `self.source_labels`, and `self._namespaces` are all + # built from the same source sequence at the same indices (see + # `__init__` and the SDK base), so this zip is aligned by construction. + # `strict=True` turns future *length* drift into a loud error; it does + # not catch a same-length reorder, which would still mispair silently. + for source_path, source_label, namespace in zip( + self.sources, self.source_labels, self._namespaces, strict=True + ): + if namespace is None: + source_skills, source_error = sdk_skills._list_skills_with_errors( + backend, source_path + ) + if source_error is not None: + errors.append(source_error) + else: + source_skills = load_namespaced_skills(backend, source_path, namespace) + for skill in source_skills: + merge_skill( + all_skills, + merged_source_labels, + skill, + source_label=source_label, + ) + + return self._state_update(all_skills, errors) + + async def abefore_agent( + self, + state: sdk_skills.SkillsState, + runtime: Runtime, # noqa: ARG002 + config: RunnableConfig, # noqa: ARG002 + ) -> sdk_skills.SkillsStateUpdate | None: + """Asynchronously load and namespace skills before collision resolution. + + Returns: + A state update containing collision-safe skill metadata, or `None` + when skills are already loaded. + """ + if "skills_metadata" in state: + return None + + backend = self._backend + all_skills: dict[str, sdk_skills.SkillMetadata] = {} + merged_source_labels: dict[str, str | None] = {} + errors: list[str] = [] + + # See `before_agent`: the three sequences are index-aligned by + # construction, and `strict=True` guards against future length drift. + for source_path, source_label, namespace in zip( + self.sources, self.source_labels, self._namespaces, strict=True + ): + if namespace is None: + ( + source_skills, + source_error, + ) = await sdk_skills._alist_skills_with_errors(backend, source_path) + if source_error is not None: + errors.append(source_error) + else: + source_skills = await aload_namespaced_skills( + backend, source_path, namespace + ) + for skill in source_skills: + merge_skill( + all_skills, + merged_source_labels, + skill, + source_label=source_label, + ) + + return self._state_update(all_skills, errors) diff --git a/libs/code/deepagents_code/plugins/commands_cli.py b/libs/code/deepagents_code/plugins/commands_cli.py new file mode 100644 index 0000000000..0d7463c6cc --- /dev/null +++ b/libs/code/deepagents_code/plugins/commands_cli.py @@ -0,0 +1,225 @@ +"""CLI helpers for plugin management.""" + +from __future__ import annotations + +import argparse +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + +from deepagents_code.plugins import ( + add_marketplace_source, + install_plugin, + list_available_plugins, + remove_marketplace, + set_installed_plugin_enabled, + uninstall_plugin, +) +from deepagents_code.plugins.marketplace import ( + MarketplaceError, + redact_marketplace_source, + redact_urls_in_text, +) +from deepagents_code.plugins.store import load_marketplace_records + + +def setup_plugin_parser( + subparsers: Any, # noqa: ANN401 # argparse subparsers uses dynamic typing + *, + make_help_action: Callable[[Callable[[], None]], type[argparse.Action]], + add_output_args: Callable[[argparse.ArgumentParser], None] | None = None, +) -> argparse.ArgumentParser: + """Set up the `plugin` CLI parser. + + Args: + subparsers: Parent argparse subparsers object. + make_help_action: Factory for parser-specific help actions. + add_output_args: Optional callback that adds output-format flags. + + Returns: + Plugin command parser. + """ + + def _help() -> None: + from deepagents_code.ui import show_plugins_help + + show_plugins_help() + + parent = argparse.ArgumentParser(add_help=False) + parent.add_argument("-h", "--help", action=make_help_action(_help)) + parser = subparsers.add_parser( + "plugin", + aliases=["plugins"], + help="Manage plugins", + add_help=False, + parents=[parent], + ) + if add_output_args is not None: + add_output_args(parser) + plugin_sub = parser.add_subparsers(dest="plugin_command") + + list_parser = plugin_sub.add_parser("list", aliases=["ls"], help="List plugins") + if add_output_args is not None: + add_output_args(list_parser) + + install_parser = plugin_sub.add_parser("install", help="Install a plugin") + install_parser.add_argument("plugin_id") + uninstall_parser = plugin_sub.add_parser("uninstall", help="Uninstall a plugin") + uninstall_parser.add_argument("plugin_id") + + enable_parser = plugin_sub.add_parser("enable", help="Enable a plugin") + enable_parser.add_argument("plugin_id") + disable_parser = plugin_sub.add_parser("disable", help="Disable a plugin") + disable_parser.add_argument("plugin_id") + + marketplace_parser = plugin_sub.add_parser( + "marketplace", help="Manage plugin marketplaces" + ) + marketplace_sub = marketplace_parser.add_subparsers(dest="marketplace_command") + marketplace_list = marketplace_sub.add_parser( + "list", aliases=["ls"], help="List marketplaces" + ) + if add_output_args is not None: + add_output_args(marketplace_list) + marketplace_add = marketplace_sub.add_parser("add", help="Add a marketplace") + marketplace_add.add_argument("source") + marketplace_remove = marketplace_sub.add_parser( + "remove", help="Remove a marketplace and uninstall its plugins" + ) + marketplace_remove.add_argument("name") + return parser + + +def _plugin_list_rows() -> list[dict[str, object]]: + return [ + {"id": plugin_id, "description": description, "enabled": enabled} + for plugin_id, description, enabled in list_available_plugins() + ] + + +def execute_plugin_command(args: argparse.Namespace) -> str | None: + """Execute a plugin management command. + + Args: + args: Parsed argparse namespace. + + Returns: + Text output for slash-command callers, or `None` when output was written. + + Raises: + SystemExit: With status 1 when a mutating command fails. + """ + output_format = getattr(args, "output_format", "text") + command = getattr(args, "plugin_command", None) + if command is None: + from deepagents_code.ui import show_plugins_help + + show_plugins_help() + return None + if command in {"list", "ls"}: + rows = _plugin_list_rows() + if output_format == "json": + from deepagents_code.output import write_json + + write_json("plugin list", rows) + return None + if not rows: + text = "No plugin marketplaces configured." + else: + lines = [] + for row in rows: + status = "enabled" if row["enabled"] else "disabled" + lines.append(f"{status} {row['id']} {row['description']}".rstrip()) + text = "\n".join(lines) + print(text) # noqa: T201 + return text + if command == "install": + try: + instance = install_plugin(args.plugin_id) + except (MarketplaceError, FileNotFoundError, OSError, ValueError) as exc: + text = f"Failed to install {args.plugin_id}: {exc}" + print(text) # noqa: T201 + raise SystemExit(1) from exc + details = "" + if instance.version is not None: + details = f" (version: {instance.version})" + text = ( + f"Installed plugin {instance.plugin_id}{details}. Run /reload to activate." + ) + print(text) # noqa: T201 + return text + if command == "uninstall": + uninstall_plugin(args.plugin_id) + text = f"Uninstalled plugin {args.plugin_id}." + print(text) # noqa: T201 + return text + if command in {"enable", "disable"}: + enabled = command == "enable" + try: + set_installed_plugin_enabled(args.plugin_id, enabled=enabled) + except (MarketplaceError, OSError, ValueError) as exc: + text = f"Failed to {command} {args.plugin_id}: {exc}" + print(text) # noqa: T201 + raise SystemExit(1) from exc + text = f"{command.title()}d plugin {args.plugin_id}." + print(text) # noqa: T201 + return text + if command == "marketplace": + marketplace_command = getattr(args, "marketplace_command", None) + if marketplace_command in {"list", "ls"}: + records = load_marketplace_records() + rows = [ + { + "name": record.name, + "source_type": record.source_type, + "source": redact_marketplace_source(record.source), + "install_location": ( + record.install_location + if record.source_type in {"directory", "file"} + else "" + ), + } + for record in records.values() + ] + if output_format == "json": + from deepagents_code.output import write_json + + write_json("plugin marketplace list", rows) + return None + text = ( + "No plugin marketplaces configured." + if not rows + else "\n".join(f"{row['name']} {row['source']}" for row in rows) + ) + print(text) # noqa: T201 + return text + if marketplace_command == "add": + try: + marketplace = add_marketplace_source(args.source) + except (MarketplaceError, FileNotFoundError, OSError, ValueError) as exc: + source = redact_marketplace_source(args.source) + text = ( + f"Failed to add marketplace {source}: " + f"{redact_urls_in_text(str(exc))}" + ) + print(text) # noqa: T201 + raise SystemExit(1) from exc + text = ( + f"Added marketplace {marketplace.name} " + f"({len(marketplace.plugins)} plugin(s))." + ) + print(text) # noqa: T201 + return text + if marketplace_command == "remove": + removed = remove_marketplace(args.name) + text = ( + f"Removed marketplace {args.name} and its installed plugins." + if removed + else f"Marketplace {args.name} is not configured." + ) + print(text) # noqa: T201 + return text + text = "Usage: plugin {list,install,uninstall,enable,disable,marketplace}" + print(text) # noqa: T201 + return text diff --git a/libs/code/deepagents_code/plugins/discovery.py b/libs/code/deepagents_code/plugins/discovery.py new file mode 100644 index 0000000000..2716ae1125 --- /dev/null +++ b/libs/code/deepagents_code/plugins/discovery.py @@ -0,0 +1,540 @@ +"""Plugin discovery, install, and enablement helpers.""" + +from __future__ import annotations + +import logging +import shutil +from functools import partial +from pathlib import Path + +from deepagents_code.plugins.manifest import ( + PluginManifestError, + build_inventory, + load_manifest, +) +from deepagents_code.plugins.marketplace import ( + MarketplaceError, + load_marketplace, + load_marketplace_location, + materialize_marketplace_source, + materialize_plugin_source, + parse_marketplace_source, + redact_urls_in_text, +) +from deepagents_code.plugins.models import ( + MarketplacePluginEntry, + MarketplaceRecord, + PluginDiscoveryResult, + PluginInstance, + PluginMarketplace, + RepositoryMarketplaceSource, + UrlMarketplaceSource, + split_plugin_id, +) +from deepagents_code.plugins.store import ( + cache_and_register_plugin, + ensure_marketplace_cache_dir, + ensure_plugin_data_dir, + get_primary_install_entry, + load_enabled_plugin_ids, + load_installed_plugins, + load_marketplace_records, + plugin_data_dir, + plugin_mutation_lock, + remove_marketplace_record, + save_marketplace_record, + set_plugin_enabled, + uninstall_plugin as uninstall_plugin_record, +) + +logger = logging.getLogger(__name__) + + +@plugin_mutation_lock() +def add_local_marketplace(path: str | Path) -> PluginMarketplace: + """Add a local marketplace to dcode state. + + Args: + path: Marketplace root directory. + + Returns: + Parsed marketplace. + """ + marketplace = load_marketplace(Path(path)) + save_marketplace_record( + MarketplaceRecord( + name=marketplace.name, + source_type="directory", + source=str(marketplace.root), + install_location=str(marketplace.root), + ) + ) + return marketplace + + +@plugin_mutation_lock() +def add_marketplace_source(raw: str) -> PluginMarketplace: + """Add a marketplace from a pasted source string. + + Args: + raw: GitHub shorthand, Git URL, marketplace JSON URL, file, or directory. + + Returns: + Parsed marketplace. + """ + source = parse_marketplace_source(raw) + marketplace, location = materialize_marketplace_source(source) + save_marketplace_record( + MarketplaceRecord( + name=marketplace.name, + source_type=source.source_type, + source=source.value, + install_location=str(location), + ref=source.ref if isinstance(source, RepositoryMarketplaceSource) else None, + ) + ) + return marketplace + + +@plugin_mutation_lock() +def remove_marketplace(name: str) -> bool: + """Remove a marketplace and every plugin installed from it. + + Local marketplace source directories are never deleted. Managed marketplace + clones and installed plugin caches are removed. + + Args: + name: Marketplace name. + + Returns: + `True` when a configured marketplace was removed. + """ + record = load_marketplace_records().get(name) + if record is None: + return False + + installed = load_installed_plugins(strict=True) + enabled = load_enabled_plugin_ids(strict=True) + plugin_ids = set(installed) | set(enabled) + for plugin_id in plugin_ids: + try: + _plugin_name, marketplace_name = split_plugin_id(plugin_id) + except ValueError: + continue + if marketplace_name == name: + uninstall_plugin(plugin_id) + + removed = remove_marketplace_record(name) + location = Path(record.install_location) + try: + resolved = location.resolve() + cache_root = ensure_marketplace_cache_dir().resolve() + except OSError: + return removed + if record.source_type in {"github", "git", "url"} and resolved.is_relative_to( + cache_root + ): + if resolved.is_dir(): + shutil.rmtree(resolved, ignore_errors=True) + elif resolved.is_file(): + resolved.unlink(missing_ok=True) + return removed + + +def _require_installed_plugin(plugin_id: str) -> None: + """Raise when `plugin_id` does not identify an installed plugin. + + Raises: + MarketplaceError: If the plugin is not installed. + """ + if plugin_id not in load_installed_plugins(strict=True): + msg = f"Plugin {plugin_id!r} is not installed" + raise MarketplaceError(msg) + + +@plugin_mutation_lock() +def set_installed_plugin_enabled(plugin_id: str, *, enabled: bool) -> None: + """Set the enabled state of an installed plugin. + + Args: + plugin_id: Plugin id in `{name}@{marketplace}` form. + enabled: Whether to enable the plugin. + """ + _require_installed_plugin(plugin_id) + set_plugin_enabled(plugin_id, enabled) + if enabled: + ensure_plugin_data_dir(plugin_id) + + +@plugin_mutation_lock() +def uninstall_plugin(plugin_id: str) -> None: + """Uninstall a plugin (disable, clear records, delete orphaned cache). + + Args: + plugin_id: Plugin id in `{name}@{marketplace}` form. + """ + uninstall_plugin_record(plugin_id) + + +def _resolve_marketplace_and_entry( + plugin_id: str, +) -> tuple[PluginMarketplace, MarketplacePluginEntry]: + try: + plugin_name, marketplace_name = split_plugin_id(plugin_id) + except ValueError as exc: + raise MarketplaceError(str(exc)) from exc + records = load_marketplace_records() + record = records.get(marketplace_name) + if record is None: + msg = f"Marketplace {marketplace_name!r} is not configured" + raise MarketplaceError(msg) + marketplace = load_marketplace_location(Path(record.install_location)) + entry = next( + (plugin for plugin in marketplace.plugins if plugin.name == plugin_name), + None, + ) + if entry is None: + msg = f"Plugin {plugin_id!r} not found in marketplace {marketplace_name}" + raise MarketplaceError(msg) + return marketplace, entry + + +@plugin_mutation_lock() +def install_plugin(plugin_id: str) -> PluginInstance: + """Install a marketplace plugin into the versioned cache and enable it. + + Copies the plugin source into `plugins/cache/{marketplace}/{plugin}/{version}/`, + writes `installed_plugins.json`, and enables the plugin. + + Args: + plugin_id: Plugin id in `{name}@{marketplace}` form. + + Returns: + Discovered plugin instance loaded from the cache path. + + Raises: + MarketplaceError: If the marketplace/plugin cannot be resolved, the + source is unsupported, or the cached plugin fails to load. + """ + load_installed_plugins(strict=True) + load_enabled_plugin_ids(strict=True) + marketplace, entry = _resolve_marketplace_and_entry(plugin_id) + source_root = materialize_plugin_source(marketplace, entry) + if source_root is None: + msg = ( + f"Plugin {plugin_id} has unsupported source " + f"{redact_urls_in_text(repr(entry.source))}; " + "use a local path, GitHub repository, or Git repository source" + ) + raise MarketplaceError(msg) + + try: + manifest, _manifest_path, manifest_warnings = load_manifest( + source_root, fallback_name=entry.name + ) + except PluginManifestError as exc: + msg = f"Cannot install {plugin_id}: {exc}" + raise MarketplaceError(msg) from exc + + for warning in manifest_warnings: + logger.debug("Plugin install warning for %s: %s", plugin_id, warning) + + version = manifest.version if manifest is not None else None + cache_path = cache_and_register_plugin( + plugin_id, + source_root, + version=version, + validate=partial( + _validate_plugin_copy, + plugin_id=plugin_id, + fallback_name=entry.name, + ), + ) + + set_plugin_enabled(plugin_id, True) + ensure_plugin_data_dir(plugin_id) + + instance, warnings = _plugin_from_install_path( + plugin_id=plugin_id, + root=cache_path, + marketplace_name=marketplace.name, + fallback_name=entry.name, + ) + if instance is None: + detail = "; ".join(warnings) + uninstall_plugin_record(plugin_id) + msg = f"Installed {plugin_id} but failed to load from cache: {detail}" + raise MarketplaceError(msg) + return instance + + +def _validate_plugin_copy( + root: Path, + *, + plugin_id: str, + fallback_name: str, +) -> None: + try: + manifest, _manifest_path, warnings = load_manifest( + root, fallback_name=fallback_name + ) + except PluginManifestError as exc: + msg = f"Cannot install {plugin_id}: {exc}" + raise MarketplaceError(msg) from exc + build_inventory(root, manifest, warnings) + + +def _plugin_from_install_path( + *, + plugin_id: str, + root: Path, + marketplace_name: str, + fallback_name: str, +) -> tuple[PluginInstance | None, tuple[str, ...]]: + warnings: list[str] = [] + try: + manifest, _manifest_path, manifest_warnings = load_manifest( + root, fallback_name=fallback_name + ) + except PluginManifestError as exc: + return None, (f"Skipping plugin {plugin_id}: {exc}",) + warnings.extend(manifest_warnings) + name = manifest.name if manifest and manifest.name else fallback_name + inventory = build_inventory(root, manifest, tuple(warnings)) + try: + instance = PluginInstance( + plugin_id=plugin_id, + name=name, + marketplace=marketplace_name, + version=manifest.version if manifest is not None else None, + root=root, + data_dir=plugin_data_dir(plugin_id), + manifest=manifest, + inventory=inventory, + ) + except ValueError as exc: + return None, (f"Skipping plugin {plugin_id}: {exc}",) + return instance, inventory.warnings + + +def plugin_auto_update_setting() -> tuple[bool, str]: + """Resolve whether plugin auto-updates are enabled and from which source. + + Returns: + The enabled state and its configuration source. + """ + from deepagents_code.config_manifest import ( + get_option, + load_config_toml, + resolve_scalar, + ) + + option = get_option("plugins.auto_update") + if option is None: + return True, "default" + enabled, source = resolve_scalar(option, toml_data=load_config_toml()) + return bool(enabled), source + + +def auto_update_plugins() -> tuple[str, ...]: + """Stage updated versions of enabled remote marketplace plugins. + + Unversioned plugins are skipped so the running session's shared cache is not + replaced. + + Returns: + Plugin ids whose installed cache path changed. + """ # noqa: DOC501 # Marketplace errors are isolated per source/plugin. + from filelock import Timeout + + from deepagents_code._env_vars import OFFLINE, is_env_truthy + + if is_env_truthy(OFFLINE) or not plugin_auto_update_setting()[0]: + return () + + try: + with plugin_mutation_lock(timeout=0): + records = load_marketplace_records(strict=True) + installed = load_installed_plugins(strict=True) + enabled = load_enabled_plugin_ids(strict=True) + updated: list[str] = [] + + for marketplace_name, record in sorted(records.items()): + match record.source_type: + case "github" | "git": + source = RepositoryMarketplaceSource( + source_type=record.source_type, + value=record.source, + ref=record.ref, + ) + case "url": + source = UrlMarketplaceSource( + source_type="url", value=record.source + ) + case _: + continue + + try: + marketplace, _ = materialize_marketplace_source(source) + if marketplace.name != record.name: + msg = ( + f"Marketplace {record.name!r} now declares the name " + f"{marketplace.name!r}" + ) + raise MarketplaceError(msg) + except (OSError, RuntimeError, ValueError) as exc: + logger.warning( + "Could not refresh plugin marketplace %s: %s", + marketplace_name, + redact_urls_in_text(str(exc)), + ) + continue + + for plugin_id, installed_entry in sorted(installed.items()): + if plugin_id not in enabled or installed_entry.version is None: + continue + try: + plugin_name, plugin_marketplace = split_plugin_id(plugin_id) + except ValueError: + continue + if plugin_marketplace != marketplace_name: + continue + + try: + entry = next( + ( + plugin + for plugin in marketplace.plugins + if plugin.name == plugin_name + ), + None, + ) + if entry is None: + msg = ( + f"Plugin {plugin_id!r} not found in marketplace " + f"{marketplace_name}" + ) + raise MarketplaceError(msg) + source_root = materialize_plugin_source(marketplace, entry) + if source_root is None: + msg = f"Plugin {plugin_id} has an unsupported source" + raise MarketplaceError(msg) + manifest, _manifest_path, _warnings = load_manifest( + source_root, fallback_name=entry.name + ) + if ( + manifest is None + or manifest.name != plugin_name + or not manifest.auto_update + or not manifest.version + or manifest.version == installed_entry.version + ): + continue + + cache_and_register_plugin( + plugin_id, + source_root, + version=manifest.version, + validate=partial( + _validate_plugin_copy, + plugin_id=plugin_id, + fallback_name=entry.name, + ), + ) + updated.append(plugin_id) + except (OSError, RuntimeError, ValueError) as exc: + logger.warning( + "Could not update plugin %s: %s", + plugin_id, + redact_urls_in_text(str(exc)), + ) + + return tuple(updated) + except Timeout: + logger.debug( + "Skipping plugin auto-update because another mutation holds the lock" + ) + return () + + +def discover_plugins() -> PluginDiscoveryResult: + """Discover enabled marketplace plugins from their install cache paths. + + Returns: + Discovery result. Broken marketplaces/plugins are returned as warnings and + never abort sibling plugin loading. + """ + enabled = load_enabled_plugin_ids() + plugins: list[PluginInstance] = [] + warnings: list[str] = [] + + for plugin_id in sorted(enabled): + try: + plugin_name, marketplace_name = split_plugin_id(plugin_id) + except ValueError: + warnings.append(f"Ignoring invalid plugin id {plugin_id!r}") + continue + entry = get_primary_install_entry(plugin_id) + if entry is None: + warnings.append( + f"Plugin {plugin_id} is enabled but not installed " + "(missing installed_plugins.json entry); run install to fix this" + ) + continue + root = Path(entry.install_path) + try: + root_exists = root.is_dir() + except (OSError, RuntimeError) as exc: + warnings.append(f"Plugin {plugin_id} cache could not be inspected: {exc}") + continue + if not root_exists: + warnings.append( + f"Plugin {plugin_id} cache miss at {entry.install_path}; " + "re-run install to refresh" + ) + continue + try: + plugin, plugin_warnings = _plugin_from_install_path( + plugin_id=plugin_id, + root=root, + marketplace_name=marketplace_name, + fallback_name=plugin_name, + ) + except (OSError, RuntimeError) as exc: + warnings.append(f"Skipping plugin {plugin_id}: {exc}") + continue + warnings.extend(plugin_warnings) + if plugin is not None: + plugins.append(plugin) + + return PluginDiscoveryResult(plugins=tuple(plugins), warnings=tuple(warnings)) + + +def list_available_plugins() -> tuple[tuple[str, str, bool], ...]: + """List plugins from configured marketplaces. + + Returns: + Tuples of `(plugin_id, description, enabled)`. + """ + records = load_marketplace_records() + enabled = load_enabled_plugin_ids() + rows: list[tuple[str, str, bool]] = [] + for name, record in sorted(records.items()): + try: + marketplace = load_marketplace_location(Path(record.install_location)) + except MarketplaceError as exc: + rows.append((f"", str(exc), False)) + continue + for plugin in marketplace.plugins: + plugin_id = f"{plugin.name}@{marketplace.name}" + rows.append((plugin_id, plugin.description or "", plugin_id in enabled)) + return tuple(rows) + + +def list_installed_plugin_ids() -> frozenset[str]: + """Return plugin ids that have install records. + + Returns: + Set of installed plugin ids. + """ + return frozenset(load_installed_plugins()) diff --git a/libs/code/deepagents_code/plugins/manifest.py b/libs/code/deepagents_code/plugins/manifest.py new file mode 100644 index 0000000000..9e8c11aefb --- /dev/null +++ b/libs/code/deepagents_code/plugins/manifest.py @@ -0,0 +1,349 @@ +"""Plugin manifest parsing for plugins.""" + +from __future__ import annotations + +import json +import logging +import re +from pathlib import Path, PureWindowsPath + +from deepagents_code.plugins._json import json_object +from deepagents_code.plugins.models import ( + ComponentInventory, + JsonObject, + PluginManifest, + UnsupportedComponent, +) + +logger = logging.getLogger(__name__) + +_MANIFEST_RELATIVE_PATHS = ( + Path("plugin.json"), + Path(".claude-plugin") / "plugin.json", + Path(".codex-plugin") / "plugin.json", +) +_PATH_COMPONENT_FIELDS = {"skills", "mcpServers", "hooks"} +_UNSUPPORTED_COMPONENT_DIRS: tuple[UnsupportedComponent, ...] = ( + "agents", + "commands", +) +_NAME_RE = re.compile(r"^[^\s]+$") + + +class PluginManifestError(ValueError): + """Raised when a plugin manifest is malformed enough to skip the plugin.""" + + +def find_manifest_path(root: Path) -> Path | None: + """Return the first supported manifest path under `root`, if present. + + Args: + root: Plugin root directory. + + Returns: + Manifest path or `None`. + """ + for rel in _MANIFEST_RELATIVE_PATHS: + path = root / rel + try: + if path.is_file(): + return path + except OSError: + logger.warning("Could not inspect plugin manifest path %s", path) + return None + + +def _validate_name( + name: object, *, fallback: str | None = None, allow_at: bool = True +) -> str: + """Validate a nonempty plugin name with no whitespace. + + Names such as `code-review` and `review@team` are valid; `code review` and + the empty string are not. + + Returns: + The validated name or fallback. + + Raises: + PluginManifestError: If neither value is a valid name. + """ + if ( + isinstance(name, str) + and name + and _NAME_RE.fullmatch(name) + and (allow_at or "@" not in name) + ): + return name + if fallback and _NAME_RE.fullmatch(fallback) and (allow_at or "@" not in fallback): + return fallback + msg = f"Invalid plugin name: {name!r}" + raise PluginManifestError(msg) + + +def _is_windows_absolute(path: str) -> bool: + return bool(PureWindowsPath(path).drive or PureWindowsPath(path).root) + + +def _resolve_component_path( + declaration: str, + plugin_root: Path, + field_name: str, + warnings: list[str], +) -> Path | None: + if not declaration.startswith("./"): + warnings.append( + f"ignoring {field_name}: path must start with './' relative to plugin root" + ) + return None + relative = declaration[2:] + if not relative: + warnings.append(f"ignoring {field_name}: path must not be './'") + return None + path = Path(relative) + if any(part == ".." for part in path.parts): + warnings.append(f"ignoring {field_name}: path must not contain '..'") + return None + if path.is_absolute() or _is_windows_absolute(relative): + warnings.append(f"ignoring {field_name}: path must stay within the plugin root") + return None + try: + root_resolved = plugin_root.resolve() + resolved = (plugin_root / path).resolve() + except OSError as exc: + warnings.append( + f"ignoring {field_name}: could not resolve {declaration!r}: {exc}" + ) + return None + if not resolved.is_relative_to(root_resolved): + warnings.append(f"ignoring {field_name}: path escapes plugin root") + return None + return resolved + + +def _resolve_component_paths( + declaration: object, + plugin_root: Path, + field_name: str, + warnings: list[str], +) -> tuple[Path, ...]: + """Resolve one or more plugin-relative component paths. + + For example, `"./skills"` and `["./skills", "./extra-skills"]` are + accepted. Absolute paths and paths containing `..` are rejected. + + Returns: + Validated paths contained by the plugin root. + """ + raw_paths: list[str] + if isinstance(declaration, str): + raw_paths = [declaration] + elif isinstance(declaration, list): + raw_paths = [item for item in declaration if isinstance(item, str)] + warnings.extend( + f"ignoring {field_name}: expected path string, got {type(item).__name__}" + for item in declaration + if not isinstance(item, str) + ) + else: + warnings.append( + f"ignoring {field_name}: expected path string or list of strings" + ) + return () + paths: list[Path] = [] + for raw_path in raw_paths: + resolved = _resolve_component_path(raw_path, plugin_root, field_name, warnings) + if resolved is not None: + paths.append(resolved) + return tuple(paths) + + +def _inline_mcp(value: object) -> JsonObject: + if isinstance(value, dict): + return json_object(value) + if isinstance(value, list): + merged: JsonObject = {} + for item in value: + if isinstance(item, dict): + merged.update(json_object(item)) + return merged + return {} + + +def _inline_hooks(value: object) -> JsonObject: + """Normalize inline hooks to `hooks.json` document form. + + Returns: + A wrapped hooks document, or an empty object. + """ + if not isinstance(value, dict): + return {} + normalized = json_object(value) + if not normalized: + return {} + wrapped = normalized.get("hooks") + if isinstance(wrapped, dict): + return {"hooks": wrapped} + return {"hooks": normalized} + + +def load_manifest( + root: Path, *, fallback_name: str | None = None +) -> tuple[PluginManifest | None, Path | None, tuple[str, ...]]: + """Load an Agent Plugins, Claude, or Codex plugin manifest. + + Args: + root: Plugin root directory. + fallback_name: Name to use only when deriving a manifest-less plugin. + + Returns: + `(manifest, manifest_path, warnings)`. + + Raises: + PluginManifestError: If the manifest exists but is invalid. + """ + manifest_path = find_manifest_path(root) + if manifest_path is None: + return None, None, () + try: + decoded = json.loads(manifest_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + msg = f"Invalid JSON syntax in {manifest_path}: {exc}" + raise PluginManifestError(msg) from exc + except OSError as exc: + msg = f"Could not read plugin manifest {manifest_path}: {exc}" + raise PluginManifestError(msg) from exc + if not isinstance(decoded, dict): + msg = f"Plugin manifest {manifest_path} must be a JSON object" + raise PluginManifestError(msg) + raw = json_object(decoded) + + warnings: list[str] = [] + name = _validate_name(raw.get("name"), fallback=fallback_name) + component_paths: dict[str, tuple[Path, ...]] = {} + for field_name in _PATH_COMPONENT_FIELDS: + declaration = raw.get(field_name) + if declaration is None: + continue + if field_name in {"mcpServers", "hooks"} and isinstance(declaration, dict): + continue + paths = _resolve_component_paths(declaration, root, field_name, warnings) + if paths: + component_paths[field_name] = paths + + version_value = raw.get("version") + version = version_value if isinstance(version_value, str) else None + display_name_value = raw.get("displayName") + auto_update_settings = raw.get("extensions") + if isinstance(auto_update_settings, dict): + auto_update_settings = auto_update_settings.get("com.langchain.deepagents.code") + manifest = PluginManifest( + name=name, + version=version, + component_paths=component_paths, + inline_mcp=_inline_mcp(raw.get("mcpServers")), + inline_hooks=_inline_hooks(raw.get("hooks")), + display_name=( + display_name_value if isinstance(display_name_value, str) else None + ), + auto_update=( + isinstance(auto_update_settings, dict) + and auto_update_settings.get("autoUpdate") is True + ), + ) + return manifest, manifest_path, tuple(warnings) + + +def _existing_component_path(path: Path, plugin_root: Path) -> tuple[Path, ...]: + try: + if not path.exists(): + return () + resolved = path.resolve() + if not resolved.is_relative_to(plugin_root.resolve()): + logger.warning("Ignoring plugin component outside plugin root: %s", path) + return () + except OSError: + logger.warning("Could not inspect plugin component path %s", path) + return () + else: + return (resolved,) + + +def _hooks_document_paths(path: Path, plugin_root: Path) -> tuple[Path, ...]: + """Resolve a declared hooks file or directory. + + Returns: + Existing hook document paths inside the plugin root. + """ + try: + target = path / "hooks.json" if path.is_dir() else path + except OSError: + logger.warning("Could not inspect plugin hooks path %s", path) + return () + return _existing_component_path(target, plugin_root) + + +def _unsupported_component_dirs( + plugin_root: Path, +) -> tuple[UnsupportedComponent, ...]: + """Return present component dirs that deepagents-code does not load.""" + found: list[UnsupportedComponent] = [] + for name in _UNSUPPORTED_COMPONENT_DIRS: + path = plugin_root / name + try: + if path.is_dir(): + found.append(name) + except OSError: + logger.warning("Could not inspect plugin component path %s", path) + return tuple(found) + + +def build_inventory( + plugin_root: Path, + manifest: PluginManifest | None, + manifest_warnings: tuple[str, ...] = (), +) -> ComponentInventory: + """Build component inventory for a plugin. + + Args: + plugin_root: Plugin root directory. + manifest: Parsed manifest or `None`. + manifest_warnings: Warnings emitted during manifest parsing. + + Returns: + Component inventory. + """ + plugin_root = plugin_root.resolve() + warnings = list(manifest_warnings) + metadata_paths = manifest.component_paths if manifest else {} + + default_skills = _existing_component_path(plugin_root / "skills", plugin_root) + root_skill = ( + () + if default_skills or (manifest and "skills" in manifest.component_paths) + else _existing_component_path(plugin_root / "SKILL.md", plugin_root) + ) + skills = (*default_skills, *metadata_paths.get("skills", ()), *root_skill) + + mcp_files = ( + *_existing_component_path(plugin_root / ".mcp.json", plugin_root), + *metadata_paths.get("mcpServers", ()), + ) + + hook_files = ( + *_hooks_document_paths(plugin_root / "hooks", plugin_root), + *( + document + for path in metadata_paths.get("hooks", ()) + for document in _hooks_document_paths(path, plugin_root) + ), + ) + + unsupported = _unsupported_component_dirs(plugin_root) + + return ComponentInventory( + skills=tuple(dict.fromkeys(skills)), + mcp_files=tuple(dict.fromkeys(mcp_files)), + hook_files=tuple(dict.fromkeys(hook_files)), + unsupported=unsupported, + warnings=tuple(warnings), + ) diff --git a/libs/code/deepagents_code/plugins/marketplace.py b/libs/code/deepagents_code/plugins/marketplace.py new file mode 100644 index 0000000000..5e6088fea2 --- /dev/null +++ b/libs/code/deepagents_code/plugins/marketplace.py @@ -0,0 +1,809 @@ +"""Marketplace parsing for plugins.""" + +from __future__ import annotations + +import json +import logging +import os +import re +import shutil +import subprocess # noqa: S404 # Git is invoked with fixed argv and no shell. +import tempfile +import urllib.error +import urllib.request +from pathlib import Path +from typing import TYPE_CHECKING +from urllib.parse import parse_qsl, unquote, urlencode, urlparse, urlunparse + +from deepagents_code.plugins._json import json_object +from deepagents_code.plugins.manifest import _resolve_component_path, _validate_name +from deepagents_code.plugins.models import ( + ExternalPluginRepositorySourceType, + GithubPluginSource, + GitSubdirectoryPluginSource, + JsonObject, + JsonValue, + LocalMarketplaceSource, + LocalPluginSource, + MarketplacePluginEntry, + MarketplaceSource, + PluginMarketplace, + PluginSource, + RepositoryMarketplaceSource, + UrlMarketplaceSource, + UrlPluginSource, +) +from deepagents_code.plugins.store import ( + ensure_marketplace_cache_dir, + opaque_cache_key, +) + +if TYPE_CHECKING: + from collections.abc import Callable + from http.client import HTTPMessage + from typing import IO + +logger = logging.getLogger(__name__) + +_MARKETPLACE_RELATIVE_PATHS = ( + Path(".claude-plugin") / "marketplace.json", + Path(".agents") / "plugins" / "marketplace.json", + Path(".agents") / "plugins" / "api_marketplace.json", +) +# SCP-style Git source, optionally with a ref: `git@github.com:owner/repo.git#main`. +_SSH_GIT_RE = re.compile(r"^([A-Za-z0-9._-]+@[^:]+:.+?(?:\.git)?)(?:#(.+))?$") +# GitHub shorthand: `owner/repo`. +_GITHUB_REPO_RE = re.compile(r"^[^/\s]+/[^/\s]+$") +_GIT_TIMEOUT_SECONDS = 120 +_GITHUB_REPO_PART_COUNT = 2 +_SENSITIVE_QUERY_TERMS = ( + "credential", + "key", + "password", + "secret", + "signature", + "token", +) +_SENSITIVE_PATH_KEY_RE = re.compile( + r"^(?:access[-_.]?token|api[-_.]?key|credential|key|password|secret|signature|token)s?$", + re.IGNORECASE, +) +_HTTP_URL_RE = re.compile(r"https?://\S+") + + +class MarketplaceError(ValueError): + """Raised when a marketplace cannot be loaded.""" + + +def _redact_url_credentials(value: str) -> str: + """Redact HTTP credentials while preserving a useful URL for logs. + + For example, `https://user:pass@example.com/?token=x` becomes + `https://***@example.com/?token=%2A%2A%2A`. Non-HTTP values pass through. + A malformed HTTP URL is reduced to its scheme so error logging cannot leak it. + + Returns: + The redacted URL, or a scheme-only placeholder when parsing fails. + """ + try: + parsed = urlparse(value) + except ValueError: + return "https://***" if value.startswith("https://") else "http://***" + if parsed.scheme not in {"http", "https"}: + return value + netloc = parsed.netloc + if "@" in netloc: + try: + host = parsed.hostname or "" + if parsed.port is not None: + host = f"{host}:{parsed.port}" + except ValueError: + return f"{parsed.scheme}://***" + netloc = f"***@{host}" + query = urlencode( + [ + ( + key, + "***" + if any(term in key.lower() for term in _SENSITIVE_QUERY_TERMS) + else item, + ) + for key, item in parse_qsl(parsed.query, keep_blank_values=True) + ] + ) + path_parts = parsed.path.split("/") + redact_next = False + for index, part in enumerate(path_parts): + if redact_next and part: + path_parts[index] = "***" + redact_next = False + elif part: + redact_next = _SENSITIVE_PATH_KEY_RE.fullmatch(unquote(part)) is not None + path = "/".join(path_parts) + return urlunparse(parsed._replace(netloc=netloc, path=path, query=query)) + + +def redact_marketplace_source(value: str) -> str: + """Return a marketplace source safe for display.""" + return _redact_url_credentials(value) + + +def redact_urls_in_text(value: str) -> str: + """Redact credentials from every HTTP URL embedded in text. + + Returns: + Text with URL credentials replaced. + """ + return _HTTP_URL_RE.sub( + lambda match: _redact_url_credentials(match.group(0)), value + ) + + +def parse_marketplace_source(raw: str) -> MarketplaceSource: + """Parse a user-provided marketplace source. + + Args: + raw: GitHub shorthand, Git URL, marketplace JSON URL, file, or directory. + + Returns: + Parsed marketplace source. + + Raises: + MarketplaceError: If the source string is empty or unsupported. + """ + value = raw.strip() + if not value: + msg = "Please enter a marketplace source" + raise MarketplaceError(msg) + + ssh_match = _SSH_GIT_RE.match(value) + if ssh_match: + return RepositoryMarketplaceSource( + source_type="git", value=ssh_match.group(1), ref=ssh_match.group(2) + ) + + if value.startswith("http://"): + msg = "Remote marketplace sources must use https" + raise MarketplaceError(msg) + if value.startswith("https://"): + url, _, ref = value.partition("#") + try: + parsed = urlparse(url) + except ValueError as exc: + msg = "Invalid marketplace URL" + raise MarketplaceError(msg) from exc + path = parsed.path + if path.endswith(".git") or "/_git/" in path: + return RepositoryMarketplaceSource( + source_type="git", value=url, ref=ref or None + ) + if parsed.hostname in {"github.com", "www.github.com"}: + parts = [part for part in path.split("/") if part] + if len(parts) == _GITHUB_REPO_PART_COUNT: + repo_path = "/".join(parts) + git_url = urlunparse(parsed._replace(path=f"/{repo_path}.git")) + return RepositoryMarketplaceSource( + source_type="git", value=git_url, ref=ref or None + ) + if len(parts) > _GITHUB_REPO_PART_COUNT: + msg = "GitHub marketplace URLs must contain exactly owner/repo" + raise MarketplaceError(msg) + return UrlMarketplaceSource(source_type="url", value=url) + + if value.startswith(("./", "../", "/", "~")): + return _marketplace_source_from_path(value) + + # Bare relative paths such as `marketplace` (no ./ prefix) are accepted when + # they exist on disk, before GitHub-shorthand parsing. + candidate = Path(value).expanduser() + if candidate.exists(): + return _marketplace_source_from_path(value) + + repo, sep, ref = value.replace("#", "@", 1).partition("@") + if ( + "/" in value + and ":" not in value + and not value.startswith("@") + and _GITHUB_REPO_RE.match(repo) + ): + return RepositoryMarketplaceSource( + source_type="github", value=repo, ref=ref if sep else None + ) + + msg = "Invalid marketplace source format. Try: owner/repo, https://..., or ./path" + raise MarketplaceError(msg) + + +def _marketplace_source_from_path(value: str) -> MarketplaceSource: + path = Path(value).expanduser().resolve() + if not path.exists(): + msg = f"Path does not exist: {path}" + raise MarketplaceError(msg) + if path.is_file(): + if path.suffix != ".json": + msg = f"File path must point to a .json marketplace file: {path}" + raise MarketplaceError(msg) + return LocalMarketplaceSource(source_type="file", value=str(path)) + if path.is_dir(): + return LocalMarketplaceSource(source_type="directory", value=str(path)) + msg = f"Path is neither a file nor a directory: {path}" + raise MarketplaceError(msg) + + +def _root_for_marketplace_file(path: Path) -> Path: + for relative in _MARKETPLACE_RELATIVE_PATHS: + if ( + len(path.parts) >= len(relative.parts) + and path.parts[-len(relative.parts) :] == relative.parts + ): + return path.parents[len(relative.parts) - 1] + return path.parent + + +def _load_marketplace_file(path: Path) -> PluginMarketplace: + root = _root_for_marketplace_file(path.expanduser().resolve()) + return _load_marketplace_from_path(root, path.expanduser().resolve()) + + +def _run_git(args: list[str]) -> None: + git_path = shutil.which("git") + if git_path is None: + msg = "Git is required to add repository-backed plugin marketplaces" + raise MarketplaceError(msg) + # Inherit normal Git configuration, but disable credential prompts because + # this subprocess has no interactive input. + env = { + **os.environ, + "GIT_TERMINAL_PROMPT": "0", + "GIT_ASKPASS": "", + } + try: + result = subprocess.run( # noqa: S603 # Fixed git executable, no shell. + [git_path, *args], + check=False, + capture_output=True, + env=env, + text=True, + timeout=_GIT_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + msg = f"Failed to run git: {redact_urls_in_text(str(exc))}" + raise MarketplaceError(msg) from exc + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "unknown git error" + msg = f"Git command failed: {redact_urls_in_text(detail)}" + raise MarketplaceError(msg) + + +def _clone_repository_to_cache( + source: RepositoryMarketplaceSource, + git_url: str, + *, + cache_key: str, + validate: Callable[[Path], None] | None = None, +) -> Path: + cache_path = ensure_marketplace_cache_dir() / ( + f"repository-{opaque_cache_key(cache_key)}" + ) + temp_path = Path( + tempfile.mkdtemp(prefix=f".{cache_path.name}.", dir=cache_path.parent) + ) + args = ["clone", "--depth", "1", "--recurse-submodules", "--shallow-submodules"] + if source.ref: + args.extend(["--branch", source.ref]) + args.extend([git_url, str(temp_path)]) + try: + _run_git(args) + if validate is not None: + validate(temp_path) + backup_path = cache_path.with_name(f".{cache_path.name}.backup") + if backup_path.exists(): + shutil.rmtree(backup_path, ignore_errors=True) + if cache_path.exists(): + cache_path.replace(backup_path) + try: + temp_path.replace(cache_path) + except OSError: + if backup_path.exists() and not cache_path.exists(): + backup_path.replace(cache_path) + raise + if backup_path.exists(): + shutil.rmtree(backup_path, ignore_errors=True) + except Exception: + shutil.rmtree(temp_path, ignore_errors=True) + raise + return cache_path + + +def _materialize_marketplace_repository( + source: RepositoryMarketplaceSource, git_url: str +) -> Path: + return _clone_repository_to_cache( + source, + git_url, + cache_key=f"marketplace-{source.source_type}-{source.value}", + validate=_validate_marketplace_repository, + ) + + +def _validate_marketplace_repository(root: Path) -> None: + load_marketplace(root) + + +def _materialize_plugin_repository( + source: RepositoryMarketplaceSource, + git_url: str, + *, + cache_key: str, +) -> Path: + return _clone_repository_to_cache(source, git_url, cache_key=cache_key) + + +class _HttpsOnlyRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request( + self, + req: urllib.request.Request, + fp: IO[bytes], + code: int, + msg: str, + headers: HTTPMessage, + newurl: str, + ) -> urllib.request.Request | None: + if urlparse(newurl).scheme != "https": + detail = _redact_url_credentials(newurl) + error = f"Marketplace redirect must use https: {detail}" + raise MarketplaceError(error) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def _download_marketplace(url: str) -> Path: + parsed = urlparse(url) + if parsed.scheme != "https": + msg = f"Marketplace URL must use https: {_redact_url_credentials(url)}" + raise MarketplaceError(msg) + cache_path = ( + ensure_marketplace_cache_dir() / f"marketplace-url-{opaque_cache_key(url)}.json" + ) + request = urllib.request.Request( # noqa: S310 # Scheme is restricted above. + url, headers={"User-Agent": "dcode-plugin-manager"} + ) + opener = urllib.request.build_opener(_HttpsOnlyRedirectHandler()) + try: + with opener.open(request, timeout=10) as response: + final_url = response.geturl() + if urlparse(final_url).scheme != "https": + detail = _redact_url_credentials(final_url) + msg = f"Marketplace response must use https: {detail}" + raise MarketplaceError(msg) + data = json.load(response) + except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc: + msg = ( + "Failed to download marketplace from " + f"{_redact_url_credentials(url)}: {redact_urls_in_text(str(exc))}" + ) + raise MarketplaceError(msg) from exc + if not isinstance(data, dict): + msg = ( + f"Marketplace URL must return a JSON object: {_redact_url_credentials(url)}" + ) + raise MarketplaceError(msg) + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text( + json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return cache_path + + +def materialize_marketplace_source( + source: MarketplaceSource, +) -> tuple[PluginMarketplace, Path]: + """Load a marketplace source and return its local install location. + + Args: + source: Parsed marketplace source. + + Returns: + Parsed marketplace and its local install location. + + Raises: + MarketplaceError: If loading, cloning, downloading, or parsing fails. + """ + if source.source_type == "directory": + root = Path(source.value).expanduser().resolve() + return load_marketplace(root), root + if source.source_type == "file": + path = Path(source.value).expanduser().resolve() + return _load_marketplace_file(path), path + if source.source_type == "url": + path = _download_marketplace(source.value) + marketplace = _load_marketplace_file(path) + _reject_url_marketplace_with_local_plugins(marketplace, source.value) + return marketplace, path + if source.source_type == "github": + if not isinstance(source, RepositoryMarketplaceSource): + msg = "GitHub marketplace source is missing repository metadata" + raise MarketplaceError(msg) + root = _materialize_marketplace_repository( + source, f"https://github.com/{source.value}.git" + ) + return load_marketplace(root), root + if source.source_type == "git": + if not isinstance(source, RepositoryMarketplaceSource): + msg = "Git marketplace source is missing repository metadata" + raise MarketplaceError(msg) + root = _materialize_marketplace_repository(source, source.value) + return load_marketplace(root), root + msg = f"Unsupported marketplace source type: {source.source_type}" + raise MarketplaceError(msg) + + +def _reject_url_marketplace_with_local_plugins( + marketplace: PluginMarketplace, url: str +) -> None: + """Reject URL marketplaces whose plugins need a sibling filesystem tree. + + Direct marketplace JSON URLs only cache the catalog file. Relative plugin + sources such as `./plugins/foo` cannot be resolved from that cache alone. + + Raises: + MarketplaceError: If any plugin entry uses a local relative source. + """ + local_plugins = [ + plugin.name + for plugin in marketplace.plugins + if _source_path(plugin.source) is not None + ] + if not local_plugins: + unsupported = [ + plugin.name + for plugin in marketplace.plugins + if _plugin_repository_source(plugin) is None + ] + if not unsupported: + return + names = ", ".join(sorted(unsupported)) + msg = ( + f"Marketplace URL {_redact_url_credentials(url)} contains plugins " + f"with unsupported remote sources: [{names}]" + ) + raise MarketplaceError(msg) + names = ", ".join(sorted(local_plugins)) + msg = ( + f"Marketplace URL {_redact_url_credentials(url)} only downloads the " + f"catalog JSON, but plugins [{names}] use local relative sources. " + "Use a git repository or local directory for this marketplace." + ) + raise MarketplaceError(msg) + + +def load_marketplace_location(path: Path) -> PluginMarketplace: + """Load a marketplace from either a cached directory or JSON file. + + Args: + path: Directory or marketplace JSON path. + + Returns: + Parsed marketplace. + """ + resolved = path.expanduser().resolve() + if resolved.is_file(): + return _load_marketplace_file(resolved) + return load_marketplace(resolved) + + +def find_marketplace_manifest(root: Path) -> Path | None: + """Return a marketplace manifest path under `root`, if present.""" + for rel in _MARKETPLACE_RELATIVE_PATHS: + path = root / rel + try: + if path.is_file(): + return path + except OSError: + logger.warning("Could not inspect marketplace manifest path %s", path) + return None + + +def _source_path(source: PluginSource) -> str | None: + return source.path if isinstance(source, LocalPluginSource) else None + + +def _external_plugin_repository_source_type( + value: JsonValue, +) -> ExternalPluginRepositorySourceType | None: + if value == "github": + return "github" + if value == "git-subdir": + return "git-subdir" + if value == "url": + return "url" + return None + + +def _plugin_repository_source( + plugin: MarketplacePluginEntry, +) -> tuple[RepositoryMarketplaceSource, str, str | None] | None: + """Parse an external plugin source into clone metadata. + + Supported objects use `github`, `url`, or `git-subdir`; + git-subdir identifies a plugin within a repository through its optional `path`. + + Returns: + `(source, clone_url, subpath)` or `None` for unsupported metadata. + """ + if not isinstance( + plugin.source, + (GithubPluginSource, GitSubdirectoryPluginSource, UrlPluginSource), + ): + return None + kind = plugin.source.source_type + ref_value = plugin.source.ref + subpath_value = plugin.source.path + if kind == "github": + if not isinstance(plugin.source, GithubPluginSource): + return None + repo = plugin.source.repo + parsed = parse_marketplace_source(f"{repo}#{ref_value}" if ref_value else repo) + if not isinstance(parsed, RepositoryMarketplaceSource): + return None + return parsed, f"https://github.com/{parsed.value}.git", subpath_value + if kind not in {"git-subdir", "url"}: + return None + if not isinstance(plugin.source, (GitSubdirectoryPluginSource, UrlPluginSource)): + return None + raw_url = plugin.source.url + parsed = parse_marketplace_source( + f"{raw_url}#{ref_value}" if ref_value else raw_url + ) + if parsed.source_type == "github": + git_url = f"https://github.com/{parsed.value}.git" + elif parsed.source_type == "git": + git_url = parsed.value + else: + return None + if not isinstance(parsed, RepositoryMarketplaceSource): + return None + return parsed, git_url, subpath_value + + +def materialize_plugin_source( + marketplace: PluginMarketplace, plugin: MarketplacePluginEntry +) -> Path | None: + """Resolve or materialize a marketplace plugin entry to a plugin root. + + Args: + marketplace: Marketplace containing the plugin. + plugin: Plugin entry. + + Returns: + Resolved plugin root, or `None` for unsupported sources. + """ + raw = _source_path(plugin.source) + if raw is not None: + metadata_root = marketplace.metadata.get("pluginRoot") + warnings: list[str] = [] + base = marketplace.root + if isinstance(metadata_root, str) and raw.startswith("./"): + base_path = _resolve_component_path( + metadata_root, marketplace.root, "metadata.pluginRoot", warnings + ) + if base_path is not None: + base = base_path + resolved = _resolve_component_path( + raw, base, f"plugins.{plugin.name}.source", warnings + ) + for warning in warnings: + logger.warning("Marketplace %s: %s", marketplace.name, warning) + return resolved + + repository = _plugin_repository_source(plugin) + if repository is None: + return None + source, git_url, subpath = repository + root = _materialize_plugin_repository( + source, + git_url, + cache_key=(f"plugin-source-{marketplace.name}-{plugin.name}-{plugin.source!r}"), + ) + if subpath is None: + return root + warnings = [] + resolved = _resolve_component_path( + subpath, root, f"plugins.{plugin.name}.source.path", warnings + ) + for warning in warnings: + logger.warning("Marketplace %s: %s", marketplace.name, warning) + return resolved + + +def _optional_source_string( + source: JsonObject, + field: str, + *, + plugin_name: object, + warnings: list[str], +) -> tuple[str | None, bool]: + value = source.get(field) + if value is None: + return None, True + if isinstance(value, str): + return value, True + warnings.append( + f"Skipping marketplace plugin {plugin_name!r}: source.{field} must be a string" + ) + return None, False + + +def _parse_plugin_source( + value: object, *, plugin_name: object, warnings: list[str] +) -> PluginSource | None: + if isinstance(value, str): + return LocalPluginSource(source_type="local", path=value) + if not isinstance(value, dict): + warnings.append(f"Skipping marketplace plugin {plugin_name!r}: missing source") + return None + source = json_object(value) + kind = source.get("source") + path, path_valid = _optional_source_string( + source, "path", plugin_name=plugin_name, warnings=warnings + ) + ref, ref_valid = _optional_source_string( + source, "ref", plugin_name=plugin_name, warnings=warnings + ) + if not path_valid or not ref_valid: + return None + if kind == "local": + if path is None: + warnings.append( + f"Skipping marketplace plugin {plugin_name!r}: " + "local source requires path" + ) + return None + return LocalPluginSource(source_type="local", path=path) + source_type = _external_plugin_repository_source_type(kind) + if source_type is None: + warnings.append( + f"Skipping marketplace plugin {plugin_name!r}: unsupported source {kind!r}" + ) + return None + repo, repo_valid = _optional_source_string( + source, "repo", plugin_name=plugin_name, warnings=warnings + ) + url, url_valid = _optional_source_string( + source, "url", plugin_name=plugin_name, warnings=warnings + ) + if not repo_valid or not url_valid: + return None + if source_type == "github" and repo is None: + warnings.append( + f"Skipping marketplace plugin {plugin_name!r}: github source requires repo" + ) + return None + if source_type in {"git-subdir", "url"} and url is None: + warnings.append( + f"Skipping marketplace plugin {plugin_name!r}: " + f"{source_type} source requires url" + ) + return None + if source_type == "github": + if repo is None: + return None + return GithubPluginSource( + source_type="github", + repo=repo, + ref=ref, + path=path, + ) + if url is None: + return None + if source_type == "git-subdir": + return GitSubdirectoryPluginSource( + source_type="git-subdir", + url=url, + ref=ref, + path=path, + ) + return UrlPluginSource( + source_type="url", + url=url, + ref=ref, + path=path, + ) + + +def _parse_entry( + entry: object, *, warnings: list[str] +) -> MarketplacePluginEntry | None: + if not isinstance(entry, dict): + warnings.append( + "Skipping marketplace plugin entry: " + f"expected object, got {type(entry).__name__}" + ) + return None + source = _parse_plugin_source( + entry.get("source"), plugin_name=entry.get("name"), warnings=warnings + ) + if source is None: + return None + try: + name = _validate_name(entry.get("name")) + except ValueError as exc: + warnings.append(f"Skipping marketplace plugin with invalid name: {exc}") + return None + description_value = entry.get("description") + author_value = entry.get("author") + author = ( + json_object(author_value) + if isinstance(author_value, dict) + else author_value + if isinstance(author_value, str) + else None + ) + display_name_value = entry.get("displayName") + return MarketplacePluginEntry( + name=name, + source=source, + description=description_value if isinstance(description_value, str) else None, + author=author, + display_name=( + display_name_value if isinstance(display_name_value, str) else None + ), + ) + + +def _load_marketplace_from_path(root: Path, manifest_path: Path) -> PluginMarketplace: + try: + raw = json.loads(manifest_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + msg = f"Invalid JSON syntax in {manifest_path}: {exc}" + raise MarketplaceError(msg) from exc + except OSError as exc: + msg = f"Could not read marketplace manifest {manifest_path}: {exc}" + raise MarketplaceError(msg) from exc + if not isinstance(raw, dict): + msg = f"Marketplace manifest {manifest_path} must be a JSON object" + raise MarketplaceError(msg) + try: + name = _validate_name(raw.get("name"), allow_at=False) + except ValueError as exc: + raise MarketplaceError(str(exc)) from exc + plugins_raw = raw.get("plugins") + if not isinstance(plugins_raw, list): + msg = f"Marketplace {name} must contain a plugins array" + raise MarketplaceError(msg) + warnings: list[str] = [] + plugins = tuple( + plugin + for entry in plugins_raw + if (plugin := _parse_entry(entry, warnings=warnings)) is not None + ) + for warning in warnings: + logger.warning("%s", warning) + metadata = json_object(raw.get("metadata")) + return PluginMarketplace( + name=name, + root=root, + manifest_path=manifest_path, + metadata=metadata, + plugins=plugins, + warnings=tuple(warnings), + ) + + +def load_marketplace(root: Path) -> PluginMarketplace: + """Load a marketplace manifest from a root directory. + + Args: + root: Marketplace root directory. + + Returns: + Parsed marketplace. + + Raises: + MarketplaceError: If no marketplace manifest exists or it is invalid. + """ + root = root.expanduser().resolve() + manifest_path = find_marketplace_manifest(root) + if manifest_path is None: + msg = f"No marketplace manifest found under {root}" + raise MarketplaceError(msg) + return _load_marketplace_from_path(root, manifest_path) diff --git a/libs/code/deepagents_code/plugins/models.py b/libs/code/deepagents_code/plugins/models.py new file mode 100644 index 0000000000..b3ecd92674 --- /dev/null +++ b/libs/code/deepagents_code/plugins/models.py @@ -0,0 +1,244 @@ +"""Data models for plugins.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal + +from deepagents_code.json_types import JsonObject, JsonValue # noqa: TC001, F401 + +if TYPE_CHECKING: + from pathlib import Path + +MarketplaceSourceType = Literal["directory", "file", "github", "git", "url"] +ExternalPluginRepositorySourceType = Literal["github", "git-subdir", "url"] +UnsupportedComponent = Literal["agents", "commands"] +"""Plugin component directory that `deepagents-code` does not load.""" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class LocalMarketplaceSource: + """Local directory or JSON file used as a marketplace source.""" + + source_type: Literal["directory", "file"] + value: str + + +@dataclass(frozen=True, slots=True, kw_only=True) +class RepositoryMarketplaceSource: + """GitHub or Git repository used as a marketplace source. + + `ref` selects an optional branch or tag. Commit SHA checkout is not part of + the shallow-clone flow. + """ + + source_type: Literal["github", "git"] + value: str + ref: str | None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class UrlMarketplaceSource: + """Marketplace manifest downloaded from an HTTP URL.""" + + source_type: Literal["url"] + value: str + + +MarketplaceSource = ( + LocalMarketplaceSource | RepositoryMarketplaceSource | UrlMarketplaceSource +) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PluginManifest: + """Parsed plugin manifest. + + Attributes: + name: Plugin name from the manifest, or `None` for manifest-less plugins. + display_name: Optional human-readable label from `displayName`. + version: Version string from the plugin manifest. + component_paths: Validated skill, MCP, and hook paths keyed by component + name. + inline_mcp: Inline MCP servers declared in the manifest. + inline_hooks: Inline hook configuration declared in the manifest, in + `hooks.json` document form. + auto_update: Whether this plugin permits automatic updates. + """ + + name: str | None + version: str | None + component_paths: dict[str, tuple[Path, ...]] + inline_mcp: JsonObject + inline_hooks: JsonObject = field(default_factory=dict) + display_name: str | None = None + auto_update: bool = False + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ComponentInventory: + """Inventory of supported plugin components. + + `unsupported` lists plugin component directories that `deepagents-code` does + not load (e.g. `agents/`, `commands/`). + """ + + skills: tuple[Path, ...] = () + mcp_files: tuple[Path, ...] = () + hook_files: tuple[Path, ...] = () + unsupported: tuple[UnsupportedComponent, ...] = () + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PluginInstance: + """A discovered plugin ready to feed dcode adapters. + + Attributes: + plugin_id: Stable id in `{name}@{marketplace}` form. + name: Plugin namespace name. + marketplace: Parent marketplace used for identity and namespacing. + version: Version declared by the plugin manifest, if any. + root: Plugin root directory. + data_dir: Writable data directory for this plugin. + manifest: Parsed manifest, if any. + inventory: Component inventory. + """ + + plugin_id: str + name: str + marketplace: str + version: str | None + root: Path + data_dir: Path + manifest: PluginManifest | None + inventory: ComponentInventory + + def __post_init__(self) -> None: + """Validate the canonical plugin identity. + + Raises: + ValueError: If `plugin_id` disagrees with `name` and `marketplace`. + """ + expected = f"{self.name}@{self.marketplace}" + if self.plugin_id != expected: + msg = f"Plugin id {self.plugin_id!r} does not match {expected!r}" + raise ValueError(msg) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class LocalPluginSource: + """A plugin stored relative to its marketplace.""" + + source_type: Literal["local"] + path: str + + +@dataclass(frozen=True, slots=True, kw_only=True) +class GithubPluginSource: + """A plugin sourced from a GitHub repository.""" + + source_type: Literal["github"] + repo: str + ref: str | None = None + path: str | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class GitSubdirectoryPluginSource: + """A plugin sourced from a subdirectory in a Git repository.""" + + source_type: Literal["git-subdir"] + url: str + ref: str | None = None + path: str | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class UrlPluginSource: + """A plugin sourced from a Git repository URL.""" + + source_type: Literal["url"] + url: str + ref: str | None = None + path: str | None = None + + +PluginSource = ( + LocalPluginSource + | GithubPluginSource + | GitSubdirectoryPluginSource + | UrlPluginSource +) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class MarketplacePluginEntry: + """A catalog entry from a marketplace manifest.""" + + name: str + source: PluginSource + description: str | None = None + author: str | JsonObject | None = None + display_name: str | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PluginMarketplace: + """A parsed marketplace manifest.""" + + name: str + root: Path + manifest_path: Path + metadata: JsonObject + plugins: tuple[MarketplacePluginEntry, ...] + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True, kw_only=True) +class MarketplaceRecord: + """Persisted marketplace source record.""" + + name: str + source_type: MarketplaceSourceType + source: str + install_location: str + ref: str | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class InstalledPluginEntry: + """Install record for a plugin. + + `version` is the value declared by the plugin manifest, if any. + """ + + install_path: str + version: str | None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PluginDiscoveryResult: + """Result from plugin discovery.""" + + plugins: tuple[PluginInstance, ...] + warnings: tuple[str, ...] = () + + +def split_plugin_id(plugin_id: str) -> tuple[str, str]: + """Split a plugin id in `{plugin}@{marketplace}` form. + + Returns: + Plugin and marketplace names. + + Raises: + ValueError: If either part is missing. + """ + if "@" not in plugin_id: + msg = f"Invalid plugin id {plugin_id!r}; expected name@marketplace" + raise ValueError(msg) + plugin, marketplace = plugin_id.rsplit("@", 1) + if not plugin or not marketplace: + msg = f"Invalid plugin id {plugin_id!r}; expected name@marketplace" + raise ValueError(msg) + return plugin, marketplace diff --git a/libs/code/deepagents_code/plugins/store.py b/libs/code/deepagents_code/plugins/store.py new file mode 100644 index 0000000000..ed1f41584c --- /dev/null +++ b/libs/code/deepagents_code/plugins/store.py @@ -0,0 +1,585 @@ +"""State storage for dcode plugin marketplaces, installs, and enablement.""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import tempfile +from contextlib import contextmanager, suppress +from hashlib import sha256 +from pathlib import Path +from typing import TYPE_CHECKING, Any, Never + +from deepagents_code.plugins.models import ( + InstalledPluginEntry, + MarketplaceRecord, + MarketplaceSourceType, + split_plugin_id, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + +logger = logging.getLogger(__name__) +_STORAGE_VERSION = 1 +_INSTALLED_STORAGE_VERSION = 2 +_UNVERSIONED_CACHE_KEY = "unversioned" +_CACHE_SLUG_LENGTH = 48 +_CACHE_DIGEST_LENGTH = 32 +SUPPORTED_MARKETPLACE_SOURCE_TYPES: frozenset[MarketplaceSourceType] = frozenset( + {"directory", "file", "github", "git", "url"} +) + +DEFAULT_PLUGIN_DIRNAME = "plugins" +"""Default directory name for plugin storage under `~/.deepagents/`. + +Not an agent profile. The `/agent` picker reserves this name in addition to +requiring an `AGENTS.md` marker, so it is never listed as a selectable agent. +""" + + +class PluginStateError(OSError): + """Raised when existing plugin state cannot be safely modified.""" + + +def plugin_storage_root() -> Path: + """Return the plugin storage root directory.""" + from deepagents_code._env_vars import PLUGIN_CACHE_DIR + from deepagents_code.model_config import DEFAULT_CONFIG_DIR + + raw = os.environ.get(PLUGIN_CACHE_DIR) + if raw: + return Path(raw).expanduser() + return DEFAULT_CONFIG_DIR / DEFAULT_PLUGIN_DIRNAME + + +@contextmanager +def plugin_mutation_lock(*, timeout: float = -1) -> Iterator[None]: + """Serialize plugin mutations across threads and dcode processes. + + The lock is reentrant within one thread so compound operations such as + marketplace removal can call the normal uninstall path while holding it. + + Args: + timeout: Seconds to wait for another mutation, or `-1` indefinitely. + + Yields: + Control while plugin state and managed caches may be mutated. + """ + from filelock import FileLock + + root = plugin_storage_root() + root.mkdir(parents=True, exist_ok=True) + lock = FileLock(str(root / ".mutation.lock"), is_singleton=True) + with lock.acquire(timeout=timeout): + yield + + +def plugin_data_dir(plugin_id: str) -> Path: + """Return the data directory path for a plugin id without creating it. + + Args: + plugin_id: Plugin id in `{name}@{marketplace}` form. + + Returns: + Path under the plugin storage root's `data/` directory. + """ + return plugin_storage_root() / "data" / sanitize_plugin_id(plugin_id) + + +def ensure_plugin_data_dir(plugin_id: str) -> Path: + """Return the lazily-created data directory for a plugin id.""" + data_dir = plugin_data_dir(plugin_id) + data_dir.mkdir(parents=True, exist_ok=True) + return data_dir + + +def sanitize_plugin_id(value: str) -> str: + """Return a bounded, collision-resistant filesystem key. + + Args: + value: Identity string to encode. + + Returns: + Filesystem-safe plugin id. + """ + slug = "".join( + ch if ch.isascii() and (ch.isalnum() or ch in {"_", "-"}) else "-" + for ch in value + ) + slug = slug.strip("-")[:_CACHE_SLUG_LENGTH] or "plugin" + digest = sha256(value.encode()).hexdigest()[:_CACHE_DIGEST_LENGTH] + return f"{slug}-{digest}" + + +def opaque_cache_key(value: str) -> str: + """Return a cache key that cannot disclose source credentials.""" + return sha256(value.encode()).hexdigest() + + +def ensure_marketplace_cache_dir() -> Path: + """Return the marketplace cache directory.""" + path = plugin_storage_root() / "marketplaces" + path.mkdir(parents=True, exist_ok=True) + return path + + +def ensure_plugin_install_cache_dir() -> Path: + """Return the versioned plugin install cache root.""" + path = plugin_storage_root() / "cache" + path.mkdir(parents=True, exist_ok=True) + return path + + +def versioned_cache_path(plugin_id: str, version: str | None) -> Path: + """Return the versioned cache path for a plugin id. + + Args: + plugin_id: Plugin id in `{name}@{marketplace}` form. + version: Plugin version string, or `None` when unversioned. + + Returns: + Cache directory `cache/{marketplace}/{plugin}/{version}/`, relative to + the plugin storage root. + + """ + plugin_name, marketplace = split_plugin_id(plugin_id) + safe_version = sanitize_plugin_id(version or _UNVERSIONED_CACHE_KEY) + return ( + ensure_plugin_install_cache_dir() + / sanitize_plugin_id(marketplace) + / sanitize_plugin_id(plugin_name) + / safe_version + ) + + +def _state_dir() -> Path: + from deepagents_code.model_config import DEFAULT_STATE_DIR + + return DEFAULT_STATE_DIR + + +def _marketplaces_path() -> Path: + return _state_dir() / "plugin_marketplaces.json" + + +def _plugin_state_path() -> Path: + return _state_dir() / "plugin_state.json" + + +def _installed_plugins_path() -> Path: + return _state_dir() / "installed_plugins.json" + + +def _invalid_state( + path: Path, detail: str, *, strict: bool, cause: Exception | None = None +) -> dict[str, Any]: + msg = f"Plugin state file {path} {detail}" + if strict: + raise PluginStateError(msg) from cause + logger.warning("%s", msg) + return {} + + +def _load_json( + path: Path, + *, + max_version: int = _STORAGE_VERSION, + strict: bool = False, +) -> dict[str, Any]: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + return _invalid_state( + path, f"could not be read: {exc}", strict=strict, cause=exc + ) + if not isinstance(data, dict): + return _invalid_state(path, "is not a JSON object", strict=strict) + version = data.get("version") + if version is not None and ( + not isinstance(version, int) + or isinstance(version, bool) + or version > max_version + ): + return _invalid_state( + path, f"has unsupported version {version!r}", strict=strict + ) + return data + + +def _raise_state_shape(path: Path, detail: str) -> Never: + msg = f"Plugin state file {path} {detail}" + raise PluginStateError(msg) + + +def _atomic_write_json(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, sort_keys=True) + f.write("\n") + Path(tmp_name).replace(path) + except Exception: + with suppress(OSError): + Path(tmp_name).unlink() + raise + + +def load_marketplace_records(*, strict: bool = False) -> dict[str, MarketplaceRecord]: + """Load persisted marketplace records. + + Returns: + Marketplace records keyed by marketplace name. + """ + data = _load_json(_marketplaces_path(), strict=strict) + raw_records = data.get("marketplaces", {}) + if not isinstance(raw_records, dict): + if strict: + _raise_state_shape(_marketplaces_path(), "has invalid marketplaces data") + return {} + records: dict[str, MarketplaceRecord] = {} + for name, record in raw_records.items(): + if not isinstance(name, str) or not isinstance(record, dict): + continue + source_type = record.get("source_type") + source = record.get("source") + if ( + source_type not in SUPPORTED_MARKETPLACE_SOURCE_TYPES + or not isinstance(source, str) + or not isinstance(record.get("install_location", source), str) + ): + logger.debug("Skipping unsupported marketplace record %r", name) + continue + ref = record.get("ref") + records[name] = MarketplaceRecord( + name=name, + source_type=source_type, + source=source, + install_location=record.get("install_location", source), + ref=ref if isinstance(ref, str) else None, + ) + return records + + +def save_marketplace_record(record: MarketplaceRecord) -> None: + """Persist a marketplace record.""" + data = _load_json(_marketplaces_path(), strict=True) + marketplaces = data.get("marketplaces") + if marketplaces is None: + marketplaces = {} + elif not isinstance(marketplaces, dict): + _raise_state_shape(_marketplaces_path(), "has invalid marketplaces data") + marketplaces[record.name] = { + "install_location": record.install_location, + "source_type": record.source_type, + "source": record.source, + } + if record.ref: + marketplaces[record.name]["ref"] = record.ref + _atomic_write_json( + _marketplaces_path(), + {"version": _STORAGE_VERSION, "marketplaces": marketplaces}, + ) + + +def remove_marketplace_record(name: str) -> bool: + """Remove a marketplace record. + + Returns: + `True` when a record was removed. + """ + data = _load_json(_marketplaces_path(), strict=True) + marketplaces = data.get("marketplaces") + if marketplaces is None: + return False + if not isinstance(marketplaces, dict): + _raise_state_shape(_marketplaces_path(), "has invalid marketplaces data") + if name not in marketplaces: + return False + marketplaces.pop(name, None) + _atomic_write_json( + _marketplaces_path(), + {"version": _STORAGE_VERSION, "marketplaces": marketplaces}, + ) + return True + + +def load_enabled_plugin_ids(*, strict: bool = False) -> frozenset[str]: + """Load enabled plugin ids. + + Returns: + Enabled plugin ids. + """ + data = _load_json(_plugin_state_path(), strict=strict) + enabled = data.get("enabledPlugins", {}) + if not isinstance(enabled, dict): + if strict: + _raise_state_shape(_plugin_state_path(), "has invalid enabledPlugins data") + return frozenset() + if strict and any( + not isinstance(key, str) or not isinstance(value, bool) + for key, value in enabled.items() + ): + _raise_state_shape(_plugin_state_path(), "has malformed enabledPlugins entries") + return frozenset( + key for key, value in enabled.items() if isinstance(key, str) and value is True + ) + + +def _write_plugin_state(*, enabled_plugin_ids: set[str]) -> None: + _atomic_write_json( + _plugin_state_path(), + { + "version": _STORAGE_VERSION, + "enabledPlugins": dict.fromkeys(sorted(enabled_plugin_ids), True), + }, + ) + + +def set_plugin_enabled(plugin_id: str, enabled: bool) -> None: + """Persist a plugin enablement value.""" + enabled_plugin_ids = set(load_enabled_plugin_ids(strict=True)) + if enabled: + enabled_plugin_ids.add(plugin_id) + else: + enabled_plugin_ids.discard(plugin_id) + _write_plugin_state(enabled_plugin_ids=enabled_plugin_ids) + + +def _parse_installed_plugin_json_entry( + persisted_entry: object, +) -> InstalledPluginEntry | None: + if not isinstance(persisted_entry, dict): + return None + install_path = persisted_entry.get("installPath") or persisted_entry.get( + "install_path" + ) + version = persisted_entry.get("version") + if ( + not isinstance(install_path, str) + or not install_path + or (version is not None and (not isinstance(version, str) or not version)) + ): + return None + return InstalledPluginEntry( + install_path=install_path, + version=version if isinstance(version, str) else None, + ) + + +def load_installed_plugins(*, strict: bool = False) -> dict[str, InstalledPluginEntry]: + """Load installed plugin records. + + Returns: + Map of plugin id to its install entry. + """ + data = _load_json( + _installed_plugins_path(), + max_version=_INSTALLED_STORAGE_VERSION, + strict=strict, + ) + raw_plugins = data.get("plugins", {}) + if not isinstance(raw_plugins, dict): + if strict: + _raise_state_shape(_installed_plugins_path(), "has invalid plugins data") + return {} + result: dict[str, InstalledPluginEntry] = {} + for plugin_id, entries in raw_plugins.items(): + if not isinstance(plugin_id, str) or not isinstance(entries, list): + if strict: + _raise_state_shape( + _installed_plugins_path(), "has malformed plugin entries" + ) + continue + parsed = next( + ( + entry + for item in entries + if (entry := _parse_installed_plugin_json_entry(item)) + ), + None, + ) + if parsed is not None: + result[plugin_id] = parsed + elif strict: + _raise_state_shape( + _installed_plugins_path(), f"has malformed entry for {plugin_id!r}" + ) + return result + + +def _entry_to_json(entry: InstalledPluginEntry) -> dict[str, Any]: + payload: dict[str, Any] = { + "installPath": entry.install_path, + } + if entry.version is not None: + payload["version"] = entry.version + return payload + + +def _write_installed_plugins( + plugins: dict[str, InstalledPluginEntry], +) -> None: + _atomic_write_json( + _installed_plugins_path(), + { + "version": _INSTALLED_STORAGE_VERSION, + "plugins": { + plugin_id: [_entry_to_json(entry)] + for plugin_id, entry in sorted(plugins.items()) + }, + }, + ) + + +def add_installed_plugin( + plugin_id: str, + *, + install_path: str, + version: str | None, +) -> InstalledPluginEntry: + """Add or replace the record for `plugin_id` in `installed_plugins.json`. + + Args: + plugin_id: Plugin id in `{name}@{marketplace}` form. + install_path: Absolute path to the cached plugin root. + version: Version declared by the plugin manifest, if any. + + Returns: + The written install entry. + + """ + plugins = dict(load_installed_plugins(strict=True)) + entry = InstalledPluginEntry( + install_path=install_path, + version=version, + ) + plugins[plugin_id] = entry + _write_installed_plugins(plugins) + return entry + + +def get_primary_install_entry(plugin_id: str) -> InstalledPluginEntry | None: + """Return the install entry for a plugin id.""" + return load_installed_plugins().get(plugin_id) + + +def remove_installed_plugin( + plugin_id: str, +) -> InstalledPluginEntry | None: + """Remove the install record for a plugin. + + Args: + plugin_id: Plugin id. + + Returns: + Removed install entry, if present. + """ + plugins = dict(load_installed_plugins(strict=True)) + removed = plugins.pop(plugin_id, None) + _write_installed_plugins(plugins) + return removed + + +def cache_and_register_plugin( + plugin_id: str, + source_dir: Path, + *, + version: str | None, + validate: Callable[[Path], None] | None = None, +) -> Path: + """Copy a plugin into the versioned cache and register the install. + + Args: + plugin_id: Plugin id in `{name}@{marketplace}` form. + source_dir: Source plugin root to copy from. + version: Version declared by the plugin manifest, if any. + validate: Optional validation to run before registering the cache. + + Returns: + Absolute path to the cached plugin root. + + Raises: + FileNotFoundError: If `source_dir` is not an existing directory. + OSError: If the cache cannot be copied or atomically replaced. + """ + source = source_dir.resolve() + if not source.is_dir(): + msg = f"Plugin source directory not found: {source}" + raise FileNotFoundError(msg) + + cache_path = versioned_cache_path(plugin_id, version) + if cache_path.exists() and version is not None: + try: + if any(cache_path.iterdir()): + if validate is not None: + validate(cache_path) + add_installed_plugin( + plugin_id, + install_path=str(cache_path), + version=version, + ) + return cache_path + except OSError: + pass + + cache_path.parent.mkdir(parents=True, exist_ok=True) + temp_dir = cache_path.parent / f".{cache_path.name}.tmp-{os.getpid()}" + backup_dir = cache_path.parent / f".{cache_path.name}.backup-{os.getpid()}" + if temp_dir.exists(): + shutil.rmtree(temp_dir, ignore_errors=True) + if backup_dir.exists(): + shutil.rmtree(backup_dir, ignore_errors=True) + try: + shutil.copytree(source, temp_dir, symlinks=True, dirs_exist_ok=False) + git_dir = temp_dir / ".git" + if git_dir.exists(): + shutil.rmtree(git_dir, ignore_errors=True) + if validate is not None: + validate(temp_dir) + if cache_path.exists(): + cache_path.replace(backup_dir) + try: + temp_dir.replace(cache_path) + except OSError: + if backup_dir.exists() and not cache_path.exists(): + backup_dir.replace(cache_path) + raise + shutil.rmtree(backup_dir, ignore_errors=True) + except Exception: + shutil.rmtree(temp_dir, ignore_errors=True) + raise + + add_installed_plugin( + plugin_id, + install_path=str(cache_path.resolve()), + version=version, + ) + return cache_path.resolve() + + +def uninstall_plugin( + plugin_id: str, +) -> None: + """Disable a plugin, remove install records, and delete orphaned cache dirs. + + Args: + plugin_id: Plugin id in `{name}@{marketplace}` form. + """ + load_installed_plugins(strict=True) + load_enabled_plugin_ids(strict=True) + removed = remove_installed_plugin(plugin_id) + + set_plugin_enabled(plugin_id, False) + + if removed is not None: + path = Path(removed.install_path) + if path.is_dir(): + shutil.rmtree(path, ignore_errors=True) diff --git a/libs/code/deepagents_code/plugins/substitution.py b/libs/code/deepagents_code/plugins/substitution.py new file mode 100644 index 0000000000..fdf36a4af0 --- /dev/null +++ b/libs/code/deepagents_code/plugins/substitution.py @@ -0,0 +1,107 @@ +"""Variable substitution for plugin-provided configuration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + from deepagents_code.plugins.models import JsonValue + + +def plugin_environment( + *, plugin_root: Path, plugin_data: Path, project_dir: Path | None = None +) -> dict[str, str]: + """Build environment variables exposed to plugin subprocesses. + + Args: + plugin_root: Plugin root directory. + plugin_data: Plugin data directory. + project_dir: Optional project directory. + + Returns: + Environment variables for plugin subprocesses. + """ + root = str(plugin_root) + data = str(plugin_data) + env = { + "CLAUDE_PLUGIN_ROOT": root, + "CLAUDE_PLUGIN_DATA": data, + "PLUGIN_ROOT": root, + "PLUGIN_DATA": data, + } + if project_dir is not None: + env["CLAUDE_PROJECT_DIR"] = str(project_dir) + return env + + +def substitute_string( + value: str, *, plugin_root: Path, plugin_data: Path, project_dir: Path | None = None +) -> str: + """Substitute plugin path variables in a string. + + Args: + value: String to transform. + plugin_root: Plugin root directory. + plugin_data: Plugin data directory. + project_dir: Optional project directory. + + Returns: + String with supported plugin variables substituted. + """ + env = plugin_environment( + plugin_root=plugin_root, plugin_data=plugin_data, project_dir=project_dir + ) + result = value + for key, replacement in env.items(): + result = result.replace(f"${{{key}}}", replacement) + return result + + +def substitute_json( + value: JsonValue, + *, + plugin_root: Path, + plugin_data: Path, + project_dir: Path | None = None, +) -> JsonValue: + """Substitute plugin variables throughout a JSON-compatible value. + + Args: + value: JSON-compatible value to transform. + plugin_root: Plugin root directory. + plugin_data: Plugin data directory. + project_dir: Optional project directory. + + Returns: + Value with strings recursively substituted. + """ + if isinstance(value, str): + return substitute_string( + value, + plugin_root=plugin_root, + plugin_data=plugin_data, + project_dir=project_dir, + ) + if isinstance(value, list): + return [ + substitute_json( + item, + plugin_root=plugin_root, + plugin_data=plugin_data, + project_dir=project_dir, + ) + for item in value + ] + if isinstance(value, dict): + return { + key: substitute_json( + item, + plugin_root=plugin_root, + plugin_data=plugin_data, + project_dir=project_dir, + ) + for key, item in value.items() + } + return value diff --git a/libs/code/deepagents_code/project_utils.py b/libs/code/deepagents_code/project_utils.py new file mode 100644 index 0000000000..309f733080 --- /dev/null +++ b/libs/code/deepagents_code/project_utils.py @@ -0,0 +1,231 @@ +"""Utilities for project root detection and project-specific configuration.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from deepagents_code._env_vars import SERVER_ENV_PREFIX +from deepagents_code._git import find_git_root + +if TYPE_CHECKING: + from collections.abc import Mapping + +import logging + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ProjectContext: + """Explicit user/project path context for project-sensitive behavior. + + Attributes: + user_cwd: Authoritative working directory from the app invocation. + project_root: Resolved project root for `user_cwd`, if one exists. + """ + + user_cwd: Path + project_root: Path | None = None + + def __post_init__(self) -> None: + """Validate that path fields are absolute. + + Raises: + ValueError: If `user_cwd` or `project_root` is not absolute. + """ + if not self.user_cwd.is_absolute(): + msg = f"user_cwd must be absolute, got {self.user_cwd!r}" + raise ValueError(msg) + if self.project_root is not None and not self.project_root.is_absolute(): + msg = f"project_root must be absolute, got {self.project_root!r}" + raise ValueError(msg) + + @classmethod + def from_user_cwd(cls, user_cwd: str | Path) -> ProjectContext: + """Build a project context from an explicit user working directory. + + Args: + user_cwd: User invocation directory. + + Returns: + Resolved project context. + """ + resolved_cwd = Path(user_cwd).expanduser().resolve() + return cls( + user_cwd=resolved_cwd, + project_root=find_project_root(resolved_cwd), + ) + + def resolve_user_path(self, path: str | Path) -> Path: + """Resolve a path relative to the explicit user working directory. + + Args: + path: Absolute or relative user-facing path. + + Returns: + Absolute resolved path. + """ + candidate = Path(path).expanduser() + if candidate.is_absolute(): + return candidate.resolve() + return (self.user_cwd / candidate).resolve() + + def project_agent_md_paths(self) -> list[Path]: + """Return project-level `AGENTS.md` files for this context.""" + if self.project_root is None: + return [] + return find_project_agent_md(self.project_root) + + def project_skills_dir(self) -> Path | None: + """Return the project `.deepagents/skills` directory, if any.""" + if self.project_root is None: + return None + return self.project_root / ".deepagents" / "skills" + + def project_agents_dir(self) -> Path | None: + """Return the project `.deepagents/agents` directory, if any.""" + if self.project_root is None: + return None + return self.project_root / ".deepagents" / "agents" + + def project_agent_skills_dir(self) -> Path | None: + """Return the project `.agents/skills` directory, if any.""" + if self.project_root is None: + return None + return self.project_root / ".agents" / "skills" + + +def get_server_project_context( + env: Mapping[str, str] | None = None, +) -> ProjectContext | None: + """Read the server project context from environment transport data. + + Args: + env: Environment mapping to read from. + + Returns: + Reconstructed project context, or `None` if no server context exists. + """ + environment = os.environ if env is None else env + raw_cwd = environment.get(f"{SERVER_ENV_PREFIX}CWD") + if not raw_cwd: + return None + + try: + user_cwd = Path(raw_cwd).expanduser().resolve() + raw_project_root = environment.get(f"{SERVER_ENV_PREFIX}PROJECT_ROOT") + project_root = ( + Path(raw_project_root).expanduser().resolve() + if raw_project_root + else find_project_root(user_cwd) + ) + except OSError: + logger.warning( + "Could not resolve server project context from CWD=%s", + raw_cwd, + exc_info=True, + ) + return None + + return ProjectContext(user_cwd=user_cwd, project_root=project_root) + + +def find_project_root(start_path: str | Path | None = None) -> Path | None: + """Find the project root by looking for git metadata. + + Args: + start_path: Directory to start searching from. + Defaults to current working directory. + + Returns: + Path to the project root if found, None otherwise. + """ + current = Path(start_path or Path.cwd()).expanduser().resolve() + return find_git_root(current) + + +def find_project_agent_md(project_root: Path) -> list[Path]: + """Find project-specific AGENTS.md file(s). + + Checks two locations and returns ALL that exist: + 1. project_root/.deepagents/AGENTS.md + 2. project_root/AGENTS.md + + Both files will be loaded and combined if both exist. + + Candidates with symlinked path components are followed only when the + resolved target stays inside `project_root`. The returned `Path` is the + resolved target when any symlink component was traversed, so + `FilesystemBackend.download_files` opens a regular file rather than + tripping `O_NOFOLLOW` on the link itself. Symlinks pointing outside the + project root, symlink loops, and unreadable parents are skipped with a + warning. Broken symlinks are treated as missing files (no warning), + matching the pre-existing behavior for absent candidates. + + Why: project AGENTS.md is auto-discovered and loaded into the system + prompt before the first model call. Without the in-tree check, a + malicious clone could ship `AGENTS.md -> ~/.ssh/config` (or any other + locally-readable file) and have its contents injected as agent + instructions on first run. + + Args: + project_root: Path to the project root directory. + + Returns: + Existing AGENTS.md paths, with in-tree symlinked path components + pre-resolved to their targets. Empty if neither file exists, one + entry if only one is present, or two entries if both locations + have the file. + """ + # Resolve the root once so the candidate-equality check below works even + # when the caller passes a non-canonical `project_root` (e.g., macOS + # `/var` -> `/private/var`, or a path with symlinked ancestors). + project_root_resolved = project_root.resolve() + candidates = [ + project_root_resolved / ".deepagents" / "AGENTS.md", + project_root_resolved / "AGENTS.md", + ] + paths: list[Path] = [] + for candidate in candidates: + try: + # Single syscall handles existence, broken symlinks, loops, and + # canonicalization. `strict=True` raises rather than returning + # the unresolved path. + resolved = candidate.resolve(strict=True) + except FileNotFoundError: + # Absent file or broken symlink — matches the pre-existing + # silent-skip behavior for missing candidates. `Path.exists()` + # also returns False for symlink loops on some Python versions, + # so loops do NOT come through here; see the OSError branch. + continue + except (OSError, RuntimeError) as exc: + # `OSError(ELOOP)` on Python 3.13+, `RuntimeError("Symlink loop + # ...")` on 3.11-3.12; bare `OSError` for permission/unreadable + # parent. Security-relevant — warn and skip. + logger.warning( + "Skipping AGENTS.md candidate %s: %s", + candidate, + exc, + ) + continue + + try: + resolved.relative_to(project_root_resolved) + except ValueError: + logger.warning( + "Skipping AGENTS.md symlink %s: target %s is outside " + "the project root %s", + candidate, + resolved, + project_root_resolved, + ) + continue + + if candidate.absolute() == resolved: + paths.append(candidate) + else: + paths.append(resolved) + return paths diff --git a/libs/repl/tests/__init__.py b/libs/code/deepagents_code/py.typed similarity index 100% rename from libs/repl/tests/__init__.py rename to libs/code/deepagents_code/py.typed diff --git a/libs/code/deepagents_code/reasoning_effort.py b/libs/code/deepagents_code/reasoning_effort.py new file mode 100644 index 0000000000..0b85bf69ab --- /dev/null +++ b/libs/code/deepagents_code/reasoning_effort.py @@ -0,0 +1,332 @@ +"""Reasoning effort support for `/effort`. + +Supported levels and defaults come from LangChain model profiles. Provider +integrations translate the standard `reasoning_effort` constructor parameter +into their native request shapes. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import Any + +from deepagents_code.model_config import CODEX_PROVIDER, ModelSpec, get_model_profiles + +logger = logging.getLogger(__name__) + +_LEGACY_ANTHROPIC_THINKING = {"type": "adaptive", "display": "summarized"} + + +def _model_profile( + model_spec: str | None, *, cli_override: dict[str, Any] | None = None +) -> Mapping[str, Any] | None: + """Return the reasoning-capable profile for `model_spec`. + + Args: + model_spec: `provider:model` spec for the active model. + cli_override: Extra profile fields from `--profile-override`, if any. + + Returns: + The merged model profile when `reasoning_output` is `True`, otherwise + `None`. + """ + if not model_spec: + return None + entry = get_model_profiles(cli_override=cli_override).get(model_spec) + profile = cli_override if entry is None else entry.get("profile") + if profile is None: + return None + if not isinstance(profile, Mapping): + logger.warning( + "Ignoring model profile for %s with unexpected type %s", + model_spec, + type(profile).__name__, + ) + return None + reasoning_output = profile.get("reasoning_output") + if reasoning_output is not None and not isinstance(reasoning_output, bool): + logger.warning( + "Ignoring reasoning_output for %s with unexpected type %s", + model_spec, + type(reasoning_output).__name__, + ) + return None + if reasoning_output is not True: + return None + return profile + + +def supported_efforts_for_model( + model_spec: str | None, *, cli_override: dict[str, Any] | None = None +) -> tuple[str, ...]: + """Return the ordered reasoning effort levels supported by `model_spec`. + + Args: + model_spec: `provider:model` spec for the active model. + cli_override: Extra profile fields from `--profile-override`, if any. + + Returns: + Supported effort labels, or an empty tuple when effort is not + configurable or the profile is malformed. + """ + profile = _model_profile(model_spec, cli_override=cli_override) + if profile is None or "reasoning_effort_levels" not in profile: + return () + levels = profile["reasoning_effort_levels"] + if not isinstance(levels, list): + logger.warning( + "Ignoring reasoning_effort_levels for %s with unexpected type %s", + model_spec, + type(levels).__name__, + ) + return () + for level in levels: + if not isinstance(level, str): + logger.warning( + "Ignoring reasoning_effort_levels for %s containing type %s", + model_spec, + type(level).__name__, + ) + return () + return tuple(levels) + + +def default_effort_for_model( + model_spec: str | None, *, cli_override: dict[str, Any] | None = None +) -> str | None: + """Return the profile's reasoning effort default independently of its levels. + + Args: + model_spec: `provider:model` spec for the active model. + cli_override: Extra profile fields from `--profile-override`, if any. + + Returns: + The default effort label, or `None` when absent or malformed. + """ + profile = _model_profile(model_spec, cli_override=cli_override) + if profile is None or "reasoning_effort_default" not in profile: + return None + default = profile["reasoning_effort_default"] + if not isinstance(default, str): + logger.warning( + "Ignoring reasoning_effort_default for %s with unexpected type %s", + model_spec, + type(default).__name__, + ) + return None + return default + + +def is_effort_supported_for_model( + model_spec: str, effort: str, *, cli_override: dict[str, Any] | None = None +) -> bool: + """Return whether `effort` is a supported level for `model_spec`. + + Args: + model_spec: `provider:model` spec for the active model. + effort: Effort label to check. + cli_override: Extra profile fields from `--profile-override`, if any. + + Returns: + `True` when the active profile advertises `effort`. + """ + return effort in supported_efforts_for_model(model_spec, cli_override=cli_override) + + +def _str_or_none(value: object, *, key: str) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + logger.warning("Ignoring non-str %s of type %s", key, type(value).__name__) + return None + + +def _effort_value(model_params: Mapping[str, Any], key: str) -> tuple[bool, str | None]: + if key not in model_params or model_params[key] is None: + return False, None + return True, _str_or_none(model_params[key], key=key) + + +def _nested_effort_value( + model_params: Mapping[str, Any], container: str, key: str +) -> tuple[bool, str | None]: + nested = model_params.get(container) + if not isinstance(nested, Mapping) or key not in nested or nested[key] is None: + return False, None + return True, _str_or_none(nested[key], key=f"{container}.{key}") + + +def _first_effort_value( + model_params: Mapping[str, Any], *paths: tuple[str, ...] +) -> str | None: + for path in paths: + result = ( + _effort_value(model_params, path[0]) + if len(path) == 1 + else _nested_effort_value(model_params, path[0], path[1]) + ) + present, value = result + if present: + return value + return None + + +def _effort_paths(provider: str) -> tuple[tuple[str, ...], ...]: + if provider in {"openai", CODEX_PROVIDER}: + return (("reasoning", "effort"), ("reasoning_effort",)) + if provider == "anthropic": + return ( + ("effort",), + ("reasoning_effort",), + ("output_config", "effort"), + ) + if provider == "google_genai": + return ( + ("thinking_level",), + ("reasoning_effort",), + ("thinking_config", "thinking_level"), + ) + if provider == "fireworks": + return (("reasoning_effort",), ("model_kwargs", "reasoning_effort")) + if provider == "xai": + return (("reasoning_effort",), ("extra_body", "reasoning_effort")) + return (("reasoning_effort",),) + + +def _path_is_present(model_params: Mapping[str, Any], path: tuple[str, ...]) -> bool: + if len(path) == 1: + return path[0] in model_params + nested = model_params.get(path[0]) + return isinstance(nested, Mapping) and path[1] in nested + + +def has_explicit_effort_model_params( + model_spec: str | None, model_params: dict[str, Any] | None +) -> bool: + """Return whether canonical or native effort parameters are present. + + Args: + model_spec: `provider:model` spec for the active model. + model_params: Per-session model constructor parameters. + + Returns: + `True` when an explicit effort setting should block persisted restoration. + """ + if not model_spec or not model_params: + return False + parsed = ModelSpec.try_parse(model_spec) + provider = parsed.provider if parsed is not None else "" + return any(_path_is_present(model_params, path) for path in _effort_paths(provider)) + + +def current_effort_from_model_params( + model_spec: str | None, model_params: dict[str, Any] | None +) -> str | None: + """Read canonical or native effort settings using integration precedence. + + This compatibility reader does not modify the supplied parameters. It only + reports settings that may come from `--model-params`, `/model`, or a resumed + thread. + + Args: + model_spec: `provider:model` spec for the active model. + model_params: Per-session model constructor parameters. + + Returns: + The effective configured effort, or `None` when none is recognized. + """ + if not model_spec or not model_params: + return None + parsed = ModelSpec.try_parse(model_spec) + provider = parsed.provider if parsed is not None else "" + + paths = _effort_paths(provider) + if provider in {"openai", CODEX_PROVIDER}: + reasoning = model_params.get("reasoning") + if isinstance(reasoning, Mapping) and "effort" in reasoning: + return _str_or_none(reasoning["effort"], key="reasoning.effort") + elif provider == "anthropic" and "effort" in model_params: + effort = model_params["effort"] + if effort is not None: + return _str_or_none(effort, key="effort") + return _first_effort_value(model_params, ("output_config", "effort")) + elif provider == "google_genai" and "thinking_level" in model_params: + effort = model_params["thinking_level"] + if effort is not None: + return _str_or_none(effort, key="thinking_level") + return _first_effort_value(model_params, ("thinking_config", "thinking_level")) + elif provider == "fireworks" and all( + _path_is_present(model_params, path) for path in paths + ): + logger.warning("Ignoring conflicting Fireworks reasoning effort parameters") + return None + return _first_effort_value(model_params, *paths) + + +def _remove_nested_key(params: dict[str, Any], container: str, key: str) -> None: + nested = params.get(container) + if not isinstance(nested, Mapping): + return + remaining = dict(nested) + remaining.pop(key, None) + if remaining: + params[container] = remaining + else: + params.pop(container, None) + + +def without_effort_model_params( + model_spec: str, existing: dict[str, Any] | None +) -> dict[str, Any] | None: + """Remove canonical and native effort settings without changing siblings. + + Args: + model_spec: `provider:model` spec for the active model. + existing: Current per-session model constructor parameters. + + Returns: + Cleaned parameters, or `None` when no parameters remain. + """ + if not existing: + return None + cleaned = dict(existing) + cleaned.pop("reasoning_effort", None) + + parsed = ModelSpec.try_parse(model_spec) + provider = parsed.provider if parsed is not None else "" + if provider in {"openai", CODEX_PROVIDER}: + _remove_nested_key(cleaned, "reasoning", "effort") + elif provider == "anthropic": + cleaned.pop("effort", None) + _remove_nested_key(cleaned, "output_config", "effort") + if cleaned.get("thinking") == _LEGACY_ANTHROPIC_THINKING: + cleaned.pop("thinking") + elif provider == "google_genai": + cleaned.pop("thinking_level", None) + _remove_nested_key(cleaned, "thinking_config", "thinking_level") + elif provider == "fireworks": + _remove_nested_key(cleaned, "model_kwargs", "reasoning_effort") + elif provider == "xai": + _remove_nested_key(cleaned, "extra_body", "reasoning_effort") + return cleaned or None + + +def with_effort_model_params( + model_spec: str, existing: dict[str, Any] | None, effort: str +) -> dict[str, Any]: + """Replace existing effort settings with the standard flat parameter. + + Args: + model_spec: `provider:model` spec for the active model. + existing: Current per-session model constructor parameters. + effort: Profile-advertised effort label to apply. + + Returns: + New model parameters containing `reasoning_effort` and all unrelated + existing settings. + """ + updated = without_effort_model_params(model_spec, existing) or {} + updated["reasoning_effort"] = effort + return updated diff --git a/libs/code/deepagents_code/reliable_rubric.py b/libs/code/deepagents_code/reliable_rubric.py new file mode 100644 index 0000000000..f67006ef61 --- /dev/null +++ b/libs/code/deepagents_code/reliable_rubric.py @@ -0,0 +1,345 @@ +"""Rubric middleware retries for transient grader transport failures.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, NotRequired, cast + +import httpx +from deepagents.middleware.rubric import ( + RUBRIC_GRADER_MESSAGE_SOURCE, + GraderResponse, + RubricMiddleware, + RubricState, + _strategy_from_result, # noqa: PLC2701 +) +from langchain.agents.middleware.types import AgentMiddleware, AgentState, hook_config +from langchain_core.messages import HumanMessage +from langgraph.errors import GraphBubbleUp + +from deepagents_code.goal_state_notice import is_conversation_control_message + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Sequence + + from deepagents.middleware.rubric import RubricEvaluation + from langchain_core.language_models import BaseChatModel + from langchain_core.messages import AnyMessage + from langchain_core.tools import BaseTool + from langgraph.runtime import Runtime + +logger = logging.getLogger(__name__) + + +def _exception_chain(exc: BaseException) -> Iterator[BaseException]: + """Yield an exception, its explicit/implicit causes, and group members once. + + Descends into `BaseExceptionGroup` members as well as `__cause__` and + `__context__`, so a transient transport error wrapped in an async task group + is still discovered. Each exception is yielded at most once. + """ + pending = [exc] + seen: set[int] = set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + yield current + if isinstance(current, BaseExceptionGroup): + pending.extend(current.exceptions) + if current.__cause__ is not None: + pending.append(current.__cause__) + elif current.__context__ is not None: + pending.append(current.__context__) + + +def _is_transient_grader_transport_error(exc: BaseException) -> bool: + """Return whether a grader failure is a retryable transport/read error. + + Matches response-read faults (`httpx`/`httpcore` `ReadError`) and + response-framing faults (`RemoteProtocolError`, aiohttp + `TransferEncodingError`). Connect/timeout errors are intentionally excluded + so only mid-response transport failures trigger the retry. + """ + for current in _exception_chain(exc): + if isinstance(current, (httpx.ReadError, httpx.RemoteProtocolError)): + return True + error_type = type(current) + if error_type.__module__.startswith("httpcore") and error_type.__name__ in { + "ReadError", + "RemoteProtocolError", + }: + return True + if ( + error_type.__module__ == "aiohttp.http_exceptions" + and error_type.__name__ == "TransferEncodingError" + and "Not enough data to satisfy transfer length header" in str(current) + ): + return True + return False + + +def _without_internal_control_messages(state: RubricState) -> RubricState: + """Remove dcode control turns before the SDK builds grader evidence. + + Returns: + Original state when unchanged, otherwise a shallow copy with filtered + messages. + """ + messages = state.get("messages", []) + if not isinstance(messages, list): + return state + filtered: list[AnyMessage] = [ + message for message in messages if not is_conversation_control_message(message) + ] + if len(filtered) == len(messages): + return state + updated = dict(state) + updated["messages"] = filtered + return cast("RubricState", updated) + + +class RubricGraderState(AgentState[GraderResponse]): + """Nested-grader state used to scope verification-tool budgets.""" + + rubric_grading_operation_id: NotRequired[str] + + +class ReliableRubricMiddleware(RubricMiddleware): + """Run a context-aware nested grader and retry transient transport failures. + + The nested grader receives Deep Agents Code's verification middleware and + runtime context without requiring those application-specific capabilities in + the SDK's `RubricMiddleware`. A transport retry re-invokes only the grader, + never the task agent, so grader tools must be read-only or idempotent. + """ + + def __init__( # noqa: D107 + self, + *, + model: str | BaseChatModel, + system_prompt: str | None = None, + tools: Sequence[BaseTool] | None = None, + grader_middleware: Sequence[AgentMiddleware[Any, Any]] | None = None, + grader_context_schema: type[Any] | None = None, + max_iterations: int = 3, + on_evaluation: Callable[[RubricEvaluation], None] | None = None, + ) -> None: + super().__init__( + model=model, + system_prompt=system_prompt, + tools=tools, + max_iterations=max_iterations, + on_evaluation=on_evaluation, + ) + self._grader_middleware = list(grader_middleware or ()) + self._grader_context_schema = grader_context_schema + + @hook_config(can_jump_to=["model"]) + def after_agent( + self, + state: RubricState, + runtime: Runtime[Any], + ) -> dict[str, Any] | None: + """Grade synchronously while preserving nested graph interrupts. + + Returns: + The rubric state update, or `None` when no rubric is active. + + Raises: + GraphBubbleUp: If the nested grader pauses or otherwise bubbles control. + """ + prep = self._prepare_evaluation(state, runtime) + if prep is None: + return None + grading_run_id, iteration = prep + + try: + graded = self._grade( + state, + iteration, + context=getattr(runtime, "context", None), + ) + except GraphBubbleUp: + raise + except Exception as exc: # noqa: BLE001 + return self._handle_grader_exception( + runtime, + state, + grading_run_id, + iteration, + exc, + ) + + return self._finalize_evaluation( + graded, + state, + runtime, + grading_run_id, + iteration, + ) + + async def aafter_agent( + self, + state: RubricState, + runtime: Runtime[Any], + ) -> dict[str, Any] | None: + """Grade asynchronously while preserving nested graph interrupts. + + Returns: + The rubric state update, or `None` when no rubric is active. + + Raises: + GraphBubbleUp: If the nested grader pauses or otherwise bubbles control. + """ + prep = self._prepare_evaluation(state, runtime) + if prep is None: + return None + grading_run_id, iteration = prep + + try: + graded = await self._agrade( + state, + iteration, + context=getattr(runtime, "context", None), + ) + except GraphBubbleUp: + raise + except Exception as exc: # noqa: BLE001 + return self._handle_grader_exception( + runtime, + state, + grading_run_id, + iteration, + exc, + ) + + return self._finalize_evaluation( + graded, + state, + runtime, + grading_run_id, + iteration, + ) + + def _ensure_grader(self) -> Any: # noqa: ANN401 + if self._grader is not None: + return self._grader + + from deepagents._models import ( # noqa: PLC2701 + resolve_model, + ) + from langchain.agents import create_agent + + resolved_model = resolve_model(self._model) + self._resolved_model = resolved_model + self._grader = create_agent( + model=resolved_model, + system_prompt=self._system_prompt, + tools=self._tools, + middleware=self._grader_middleware, + name=RUBRIC_GRADER_MESSAGE_SOURCE, + response_format=GraderResponse, + state_schema=RubricGraderState, + context_schema=self._grader_context_schema, + ) + return self._grader + + def _grader_input( + self, + state: RubricState, + iteration: int, + ) -> dict[str, Any]: + """Build nested-grader input with a stable verification-operation ID. + + Returns: + The nested grader's input state. + """ + grading_run_id = state.get("_current_grading_run_id") or "untracked" + grader_state = _without_internal_control_messages(state) + payload = self._build_grader_payload(grader_state, iteration) + return { + "messages": [HumanMessage(content=payload)], + "rubric_grading_operation_id": f"{grading_run_id}:{iteration}", + } + + def _grade_once( + self, + state: RubricState, + iteration: int, + *, + context: object | None, + ) -> GraderResponse: + grader = self._ensure_grader() + metadata = self._grader_trace_metadata() + self._record_grader_trace_metadata(metadata) + result = grader.invoke( + self._grader_input(state, iteration), + config=self._grader_invocation_config(metadata), + context=context, + ) + self._record_grader_trace_metadata( + self._grader_trace_metadata( + effective_strategy=_strategy_from_result(result), + ) + ) + return self._extract_graded(result) + + async def _agrade_once( + self, + state: RubricState, + iteration: int, + *, + context: object | None, + ) -> GraderResponse: + grader = self._ensure_grader() + metadata = self._grader_trace_metadata() + self._record_grader_trace_metadata(metadata) + result = await grader.ainvoke( + self._grader_input(state, iteration), + config=self._grader_invocation_config(metadata), + context=context, + ) + self._record_grader_trace_metadata( + self._grader_trace_metadata( + effective_strategy=_strategy_from_result(result), + ) + ) + return self._extract_graded(result) + + def _grade( + self, + state: RubricState, + iteration: int, + *, + context: object | None = None, + ) -> GraderResponse: + try: + return self._grade_once(state, iteration, context=context) + except Exception as exc: + if not _is_transient_grader_transport_error(exc): + raise + logger.warning( + "Rubric grader transport failed; retrying grading once", + exc_info=True, + ) + return self._grade_once(state, iteration, context=context) + + async def _agrade( + self, + state: RubricState, + iteration: int, + *, + context: object | None = None, + ) -> GraderResponse: + try: + return await self._agrade_once(state, iteration, context=context) + except Exception as exc: + if not _is_transient_grader_transport_error(exc): + raise + logger.warning( + "Rubric grader transport failed; retrying grading once", + exc_info=True, + ) + return await self._agrade_once(state, iteration, context=context) diff --git a/libs/code/deepagents_code/resume_state.py b/libs/code/deepagents_code/resume_state.py new file mode 100644 index 0000000000..86cf1f0b08 --- /dev/null +++ b/libs/code/deepagents_code/resume_state.py @@ -0,0 +1,273 @@ +"""Schema and middleware for per-checkpoint state restored when resuming. + +`ResumeState` declares several checkpointed, schema-private channels. They fall +into two groups with *different* write paths: + +Written from inside the graph on successful model turns: + +- `_context_tokens` — total context tokens from the latest + `AIMessage.usage_metadata`, written by `ResumeStateMiddleware.after_model`. + Powers `/tokens` and the status bar. +- `_model_spec` / `_model_params` — the model and invocation params effectively + in use for the turn, written by `ConfigurableModelMiddleware` after a + successful model call. Lets `dcode -r` restore the model the resumed thread + was actually using instead of falling back to the user's global default. + +Written through the main graph or by the TUI client via `aupdate_state` (see +`DeepAgentsApp._persist_goal_rubric_state`) — these are user/agent-owned. Their +write sites are called out below: + +- `_goal_objective` / `_goal_status` / `_goal_rubric` / `_goal_status_note` — + the accepted goal and its lifecycle status. `_goal_objective`/`_goal_rubric` + are client-only, but `_goal_status`/`_goal_status_note` are *also* written + from inside the graph by the agent's `update_goal` tool. +- `_pending_goal_completion_note` — optional agent-provided completion evidence + awaiting the post-turn rubric result. +- `_sticky_rubric` — the TUI-owned persistent rubric. This is separate from + the public `rubric` graph input so one-shot rubric turns can be checkpointed + without being restored as sticky state. +- `_pending_goal_objective` / `_pending_goal_rubric` / `_pending_goal_kind` / + `_pending_goal_request_id` — a proposed goal or amendment and its originating + request, written by `GoalCriteriaMiddleware` inside the main graph, then + cleared by the TUI when the user accepts or rejects it. + +All of these are facts the CLI reads back from `state_values` on thread resume +so it can rehydrate the session without replaying or re-tokenizing history. + +The model-turn channels are persisted from inside the graph (rather than via a +separate client-side `aupdate_state` call) so the write rides the same checkpoint +as the model response and avoids creating a standalone `UpdateState` run in +LangSmith. Because they are versioned channel state, resuming a specific +checkpoint yields the values as of *that* checkpoint — not a thread-level +aggregate. Accepted goal/rubric state is client-written because the user sets it +outside any model turn; pending criteria proposals and agent-driven status +updates are graph-written. Both paths work identically against local and remote +(HTTP) graphs. +""" + +from __future__ import annotations + +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Literal, + NotRequired, + cast, + get_args, +) + +from deepagents.middleware.rubric import RubricResult +from langchain.agents.middleware.types import ( + AgentMiddleware, + AgentState, + ContextT, + PrivateStateAttr, +) +from langchain_core.messages import AIMessage + +if TYPE_CHECKING: + from langgraph.runtime import Runtime + +GoalStatus = Literal["active", "paused", "blocked", "complete"] +"""Lifecycle status of a TUI-owned goal. + +`active` and `blocked` are unfinished working states, `paused` preserves the goal +without driving work, and `complete` is terminal. A blocked goal is still +considered actionable (`active=True`) by `get_goal`, whereas a paused goal is +unfinished but reports `active=False`. +""" + +GoalProposalKind = Literal["create", "amend"] +"""Whether a pending review creates a goal or amends the current one.""" + +_GOAL_STATUS_VALUES: frozenset[str] = frozenset(get_args(GoalStatus)) +_GOAL_PROPOSAL_KIND_VALUES: frozenset[str] = frozenset(get_args(GoalProposalKind)) + + +def _flatten_literal_values(tp: object) -> frozenset[str]: + """Collect every string value from a (possibly unioned) `Literal` type. + + Args: + tp: A `Literal` type, or a union of `Literal`s, to inspect. + + Returns: + Every string member across the (possibly nested) `Literal` args. + """ + values: set[str] = set() + for arg in get_args(tp): + if isinstance(arg, str): + values.add(arg) + else: + values |= _flatten_literal_values(arg) + return frozenset(values) + + +RUBRIC_RESULT_VALUES: frozenset[str] = _flatten_literal_values(RubricResult) +"""Every verdict `RubricMiddleware` can emit for a completed grading run. + +Derived from the SDK's `RubricResult` `Literal` so it cannot drift out of sync +with the grader vocabulary: if the SDK renames or adds a verdict, this set +follows automatically. Consumers that branch on a rubric result (goal +auto-completion in `app.py`, the rubric-event formatters in `textual_adapter`) +treat any value outside this set as an unrecognized grade rather than silently +mishandling it. +""" + + +def coerce_goal_proposal_kind(value: object) -> GoalProposalKind | None: + """Narrow a persisted proposal kind to a known value. + + Args: + value: Raw value read from checkpoint state. + + Returns: + The recognized proposal kind, otherwise `None`. + """ + if isinstance(value, str) and value in _GOAL_PROPOSAL_KIND_VALUES: + return cast("GoalProposalKind", value) + return None + + +def coerce_goal_status(value: object) -> GoalStatus | None: + """Narrow a persisted goal-status value to a known `GoalStatus`. + + A corrupt or forward-version checkpoint can carry an unexpected status + string (or a non-string). Coercing to `None` rather than passing the raw + value through keeps the `GoalStatus` `Literal` load-bearing on the read + path, so an unknown status is treated as "no goal status" instead of a + silently active goal. Resume/restore callers should log the discard + separately so it is surfaced rather than dropped; the model-read path + (`_goal_snapshot`) intentionally treats an unknown status as `active` + without logging. + + Args: + value: Raw value read from checkpoint state. + + Returns: + The value when it is a recognized `GoalStatus`, otherwise `None`. + """ + if isinstance(value, str) and value in _GOAL_STATUS_VALUES: + return cast("GoalStatus", value) + return None + + +class GoalRubricChannels(AgentState): + """Goal/rubric state channels shared by every schema that touches them. + + Declared once here so each schema that carries these channels — + `ResumeState` and `goal_tools.GoalToolState` — inherits the *same* + `PrivateStateAttr`-marked annotations. Middleware state schemas merge with + later entries winning, so an independent re-declaration that dropped the + `PrivateStateAttr` marker would override these and leak the field into the + public graph input/output schema. Inheriting from a single base makes that + drift unrepresentable. + """ + + _goal_objective: Annotated[NotRequired[str | None], PrivateStateAttr] + """Accepted goal objective restored by the TUI on resume.""" + + _goal_status: Annotated[NotRequired[GoalStatus | None], PrivateStateAttr] + """Goal lifecycle status (`active`, `paused`, `blocked`, `complete`, or `None`).""" + + _goal_rubric: Annotated[NotRequired[str | None], PrivateStateAttr] + """Accepted rubric associated with `_goal_objective`.""" + + _goal_status_note: Annotated[NotRequired[str | None], PrivateStateAttr] + """Persisted completion evidence or blocker note for the goal.""" + + _pending_goal_completion_note: Annotated[NotRequired[str | None], PrivateStateAttr] + """Optional agent-provided completion evidence awaiting final grading.""" + + _sticky_rubric: Annotated[NotRequired[str | None], PrivateStateAttr] + """Persistent rubric owned by the TUI, distinct from graph input `rubric`.""" + + +class ResumeState(GoalRubricChannels): + """Extends agent state with per-checkpoint facts restored on resume. + + Inherits the shared goal/rubric channels from `GoalRubricChannels` and adds + the channels unique to resume: the after-model token/spec facts and the + pending-goal proposal awaiting acceptance. + """ + + _context_tokens: Annotated[NotRequired[int], PrivateStateAttr] + """Total context tokens reported by the model's last `usage_metadata`.""" + + _model_spec: Annotated[NotRequired[str], PrivateStateAttr] + """`provider:model` spec effectively in use for the latest turn.""" + + _model_params: Annotated[NotRequired[dict[str, Any] | None], PrivateStateAttr] + """Invocation params effectively in use for the latest turn.""" + + _pending_goal_objective: Annotated[NotRequired[str | None], PrivateStateAttr] + """Goal objective awaiting acceptance of proposed criteria.""" + + _pending_goal_rubric: Annotated[NotRequired[str | None], PrivateStateAttr] + """Proposed criteria awaiting user acceptance.""" + + _pending_goal_kind: Annotated[ + NotRequired[GoalProposalKind | None], PrivateStateAttr + ] + """Whether the pending review creates or amends a goal.""" + + _pending_goal_request_id: Annotated[NotRequired[str | None], PrivateStateAttr] + """Request that produced the pending proposal.""" + + +def _extract_context_tokens(message: AIMessage) -> int | None: + """Return the context-token count from an AI message, or `None` if absent. + + Prefers `input_tokens + output_tokens` when both are reported; falls back + to `total_tokens` when the model only provides the aggregate. + """ + usage = getattr(message, "usage_metadata", None) + if not usage: + return None + input_toks = usage.get("input_tokens", 0) or 0 + output_toks = usage.get("output_tokens", 0) or 0 + if input_toks or output_toks: + return input_toks + output_toks + total = usage.get("total_tokens", 0) or 0 + return total or None + + +class ResumeStateMiddleware(AgentMiddleware[ResumeState, ContextT]): + """Persists per-checkpoint resume facts after each model call. + + See the module docstring for why this rides the model node's checkpoint + instead of a separate `aupdate_state` (avoids a standalone `UpdateState` + run in LangSmith and works identically against remote graphs). + """ + + state_schema = ResumeState + + def after_model( # noqa: PLR6301 # AgentMiddleware hook must be an instance method. + self, + state: ResumeState, + runtime: Runtime[ContextT], # noqa: ARG002 + ) -> dict[str, Any] | None: + """Write `_context_tokens` for the latest turn. + + Model metadata is written by `ConfigurableModelMiddleware` from the + actual request that completed successfully; this hook only records token + usage from the most recent `AIMessage.usage_metadata`. + + Args: + state: Current agent state; only `messages` is inspected. + runtime: LangGraph runtime required by the middleware interface. + + Returns: + State update with `_context_tokens`, or `None` when no token count is + available. + """ + update: dict[str, Any] = {} + + for msg in reversed(state.get("messages") or []): + if isinstance(msg, AIMessage): + tokens = _extract_context_tokens(msg) + if tokens is not None: + update["_context_tokens"] = tokens + break + + return update or None diff --git a/libs/code/deepagents_code/server_graph.py b/libs/code/deepagents_code/server_graph.py new file mode 100644 index 0000000000..b133cda91e --- /dev/null +++ b/libs/code/deepagents_code/server_graph.py @@ -0,0 +1,422 @@ +"""Server-side graph entry point for `langgraph dev`. + +This module is referenced by the generated `langgraph.json` and exposes a graph +factory that the LangGraph server can load and serve. + +The graph is created by `make_graph()`, which reads configuration from +`ServerConfig.from_env()` — the same dataclass the CLI uses to *write* the +configuration via `ServerConfig.to_env()`. This shared schema ensures the two +sides stay in sync. +""" + +from __future__ import annotations + +import asyncio +import atexit +import logging +import sys +from typing import TYPE_CHECKING, Any + +from deepagents_code._server_config import ServerConfig +from deepagents_code._startup_error import ( + STARTUP_ERROR_MARKER as _STARTUP_ERROR_MARKER, + emit_startup_failure, +) +from deepagents_code.project_utils import ProjectContext, get_server_project_context + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +logger = logging.getLogger(__name__) + +_sandbox_cm: Any = None +_sandbox_backend: Any = None +_mcp_session_manager: Any = None + + +def _print_startup_error(message: str) -> None: + """Print a startup error for both humans and the parent app process. + + Args: + message: Concise startup failure to surface in the parent process. + """ + print(message, file=sys.stderr) # noqa: T201 # stderr fallback for logs + print( # noqa: T201 # machine-readable marker consumed by server.py + f"{_STARTUP_ERROR_MARKER}{message}", + file=sys.stderr, + ) + + +def _get_mcp_session_manager() -> Any: # noqa: ANN401 + """Return the process-wide MCP session manager singleton. + + Sessions are bound to the langgraph dev server's event loop. Cleanup + therefore belongs to that loop's normal shutdown path, not `atexit` — + an atexit handler runs after the loop is already closed and cannot + await `AsyncExitStack.aclose()` safely. Subprocess handles held by + stdio transports are released when the Python process exits. + """ + global _mcp_session_manager # noqa: PLW0603 + + if _mcp_session_manager is None: + from deepagents_code.mcp_tools import MCPSessionManager + + _mcp_session_manager = MCPSessionManager() + + return _mcp_session_manager + + +async def _build_tools( + config: ServerConfig, + project_context: ProjectContext | None, +) -> tuple[list[Any], list[Any] | None, list[Any]]: + """Assemble the tool list based on server config. + + Loads built-in tools (conditionally including web search when Tavily is + available) and MCP tools when enabled. + + MCP discovery is awaited on the server's event loop: LangGraph invokes this + async factory on its running loop, so discovery must use `await` rather than + `asyncio.run` (which raises inside a running loop). `stateless=True` ensures + discovery only uses throwaway sessions, while the shared runtime session + manager binds real sessions lazily inside the server loop on first tool + invocation. MCP adapter imports are warmed in a worker thread inside + `_load_tools_from_config` (only when active servers exist) because first + import can perform blocking package-resource scans. + + Args: + config: Deserialized server configuration. + project_context: Resolved project context for MCP discovery. + + Returns: + Tuple of `(tools, mcp_server_info, mcp_tools)`. + + Raises: + FileNotFoundError: If the MCP config file is not found. + RuntimeError: If MCP tool loading fails. + """ + from deepagents_code.config import settings + from deepagents_code.tools import fetch_url, get_current_thread_id, web_search + + tools: list[Any] = [fetch_url, get_current_thread_id] + if settings.has_tavily: + tools.append(web_search) + + mcp_server_info: list[Any] | None = None + mcp_tools: list[Any] = [] + if not config.no_mcp: + from deepagents_code.mcp_tools import resolve_and_load_mcp_tools + from deepagents_code.plugins.adapters.mcp import discover_plugin_mcp_configs + + project_dir = ( + project_context.project_root or project_context.user_cwd + if project_context is not None + else None + ) + # Offload plugin discovery: it does blocking disk IO (`os.mkdir` for + # per-plugin data dirs, plus state/manifest reads) that `blockbuster` + # rejects on the server event loop. + plugin_mcp_configs = await asyncio.to_thread( + discover_plugin_mcp_configs, project_dir=project_dir + ) + try: + mcp_tools, _, mcp_server_info = await resolve_and_load_mcp_tools( + explicit_config_path=config.mcp_config_path, + no_mcp=config.no_mcp, + trust_project_mcp=config.trust_project_mcp, + project_context=project_context, + additional_configs=plugin_mcp_configs, + stateless=True, + session_manager=_get_mcp_session_manager(), + ) + except FileNotFoundError: + logger.exception("MCP config file not found: %s", config.mcp_config_path) + raise + except RuntimeError: + logger.exception( + "Failed to load MCP tools (config: %s)", config.mcp_config_path + ) + raise + + tools.extend(mcp_tools) + if mcp_tools: + logger.info("Loaded %d MCP tool(s)", len(mcp_tools)) + + return tools, mcp_server_info, mcp_tools + + +def _criteria_context_tools( + tools: list[Any], + mcp_tools: list[Any], +) -> list[Any]: + """Select read-only external tools for criteria drafting and rubric grading. + + Args: + tools: Main agent tools in execution order. + mcp_tools: Exact tool objects returned by MCP discovery. + + Returns: + External context tools available to criteria generation and grading. + MCP tools are included only when their protocol annotations explicitly + declare them read-only. + """ + from deepagents_code.tools import fetch_url, web_search + + allowed_ids = {id(fetch_url), id(web_search)} + allowed_ids.update( + id(tool) for tool in mcp_tools if _mcp_tool_is_explicitly_read_only(tool) + ) + return [tool for tool in tools if id(tool) in allowed_ids] + + +def _mcp_tool_is_explicitly_read_only(tool: Any) -> bool: # noqa: ANN401 + """Return whether a wrapped MCP tool is unambiguously read-only. + + MCP `ToolAnnotations.readOnlyHint` is serialized by the installed adapter + into the LangChain tool's metadata as the camel-case `readOnlyHint` key. + Require the literal boolean `True` and reject a contradictory destructive + hint so absent, malformed, or ambiguous annotations fail closed. + + Returns: + `True` only for an explicitly and consistently read-only MCP tool. + """ + from deepagents_code.auto_mode import mcp_tool_is_coherently_read_only + + return mcp_tool_is_coherently_read_only(tool) + + +async def _make_graph() -> Any: # noqa: ANN401 + """Create the agent graph from environment-based configuration. + + Reads `DEEPAGENTS_CODE_SERVER_*` env vars via `ServerConfig.from_env()` + (the inverse of `ServerConfig.to_env()` used by the app process), resolves a + model, assembles tools, and compiles the agent graph. + + Returns: + Compiled LangGraph agent graph. + """ + config = ServerConfig.from_env() + + # Offload cwd/path resolution and the lazy settings bootstrap off the event + # loop. On Windows, `Path.resolve()` / `Path.cwd()` call `os.getcwd()`, which + # `blockbuster` rejects when invoked directly from the server loop (see + # issue #5043). Importing `deepagents_code.agent` / first `settings` access + # can also trigger `find_project_root()` -> `Path.cwd()`. + # + # Keep LangSmith redaction configuration on the server task: its fail-closed + # path calls `langsmith.configure(enabled=False)`, which sets both a global + # fallback and the current `_TRACING_ENABLED` ContextVar. `asyncio.to_thread` + # only updates a copied worker context, so a ContextVar disable there would + # not reach a parent tracing context that already has `enabled=True` (ContextVar + # wins over the global flag). + def _resolve_project_context_and_settings() -> tuple[ + ProjectContext | None, + Any, + Any, + Any, + Any, + Any, + Any, + ]: + project_context = get_server_project_context() + + from deepagents_code.agent import create_cli_agent, load_async_subagents + from deepagents_code.config import ( + configure_langsmith_secret_redaction, + create_model, + is_memory_auto_save_enabled, + settings, + ) + + if project_context is not None: + settings.reload_from_environment(start_path=project_context.user_cwd) + return ( + project_context, + create_cli_agent, + load_async_subagents, + create_model, + is_memory_auto_save_enabled, + settings, + configure_langsmith_secret_redaction, + ) + + ( + project_context, + create_cli_agent, + load_async_subagents, + create_model, + is_memory_auto_save_enabled, + settings, + configure_langsmith_secret_redaction, + ) = await asyncio.to_thread(_resolve_project_context_and_settings) + configure_langsmith_secret_redaction() + + # Offload to a worker thread: `create_model` does blocking disk IO for some + # providers (e.g. the `openai_codex` token store currently acquires a file + # lock via `langchain-openai` that calls `os.mkdir`), which `blockbuster` + # rejects on the server event loop. + result = await asyncio.to_thread( + create_model, + config.model, + extra_kwargs=config.model_params, + profile_overrides=config.profile_overrides, + ) + result.apply_to_settings() + + tools, mcp_server_info, mcp_tools = await _build_tools(config, project_context) + read_only_context_tools = _criteria_context_tools(tools, mcp_tools) + + # Create sandbox backend if a sandbox provider is configured. + # The context manager is created here in the factory, but its reference is + # stored in a module-level global (and cleaned up via atexit) so the sandbox + # lives for the entire server process lifetime. `make_graph` caches the built + # graph, so this runs once per process despite LangGraph's per-run factory + # invocation. + global _sandbox_cm, _sandbox_backend # noqa: PLW0603 + sandbox_backend = None + if config.sandbox_type: + from deepagents_code.integrations.sandbox_factory import create_sandbox + + try: + _sandbox_cm = create_sandbox( + config.sandbox_type, + sandbox_id=config.sandbox_id, + snapshot_name=config.sandbox_snapshot_name, + setup_script_path=config.sandbox_setup, + ) + _sandbox_backend = _sandbox_cm.__enter__() # noqa: PLC2801 # Context manager kept open for server process lifetime + sandbox_backend = _sandbox_backend + + def _cleanup_sandbox() -> None: + if _sandbox_cm is not None: + _sandbox_cm.__exit__(None, None, None) + + atexit.register(_cleanup_sandbox) + except ImportError: + logger.exception( + "Sandbox provider '%s' is not installed", config.sandbox_type + ) + _print_startup_error( + f"Sandbox provider '{config.sandbox_type}' is not installed" + ) + sys.exit(1) + except NotImplementedError: + logger.exception("Sandbox type '%s' is not supported", config.sandbox_type) + _print_startup_error( + f"Sandbox type '{config.sandbox_type}' is not supported" + ) + sys.exit(1) + except ValueError as exc: + logger.exception( + "Invalid sandbox configuration for '%s'", config.sandbox_type + ) + _print_startup_error(f"Invalid sandbox configuration: {exc}") + sys.exit(1) + except Exception as exc: + logger.exception("Sandbox creation failed for '%s'", config.sandbox_type) + _print_startup_error( + f"Sandbox creation failed for '{config.sandbox_type}': {exc}" + ) + sys.exit(1) + + def _create_cli_agent_sync() -> Any: # noqa: ANN401 + async_subagents = load_async_subagents() or None + auto_mode_enabled = config.interactive and sandbox_backend is None + + # These process-global settings writes are safe here because `make_graph` + # is lock-serialized and caches one graph for the server process lifetime. + if config.interpreter_ptc is not None: + settings.interpreter_ptc = config.interpreter_ptc + if config.interpreter_ptc_acknowledge_unsafe: + settings.interpreter_ptc_acknowledge_unsafe = True + if config.enable_interpreter: + settings.enable_interpreter = True + + agent, _composite_backend = create_cli_agent( + model=result.model, + assistant_id=config.assistant_id, + tools=tools, + mcp_tools=mcp_tools, + sandbox=sandbox_backend, + sandbox_type=config.sandbox_type, + system_prompt=config.system_prompt, + interactive=config.interactive, + auto_approve=config.auto_approve, + auto_mode_enabled=auto_mode_enabled, + interrupt_shell_only=config.interrupt_shell_only, + shell_allow_list=config.shell_allow_list, + fs_tools=config.allow_fs_tools, + enable_ask_user=config.enable_ask_user, + enable_memory=config.enable_memory, + memory_auto_save=is_memory_auto_save_enabled(), + enable_skills=config.enable_skills, + enable_shell=config.enable_shell, + enable_interpreter=config.enable_interpreter, + rubric_model=config.rubric_model, + rubric_max_iterations=config.rubric_max_iterations, + auto_classifier_model=config.auto_classifier_model, + recursion_limit=config.recursion_limit, + mcp_server_info=mcp_server_info, + cwd=project_context.user_cwd if project_context is not None else config.cwd, + project_context=project_context, + async_subagents=async_subagents, + goal_criteria_tools=read_only_context_tools, + rubric_grader_tools=read_only_context_tools, + ) + return agent + + return await asyncio.to_thread(_create_cli_agent_sync) + + +def _build_graph_factory( + builder: Callable[[], Awaitable[Any]] | None = None, +) -> Callable[[], Awaitable[Any]]: + """Build the cached async graph factory exposed to `langgraph dev`. + + The returned coroutine function is what `langgraph.json` references. It keeps + its cache and lock in this closure rather than in module-level globals, so + importing the module (e.g. for import-only checks) introduces no shared + mutable state. + + Args: + builder: Optional alternate graph builder. + + Returns: + A zero-arg async factory that builds the graph once and returns the + cached instance on every subsequent call. + """ + missing = object() + graph: Any = missing + lock = asyncio.Lock() + + async def make_graph() -> Any: # noqa: ANN401 + """Create (or return the cached) agent graph for `langgraph dev`. + + LangGraph loads this async factory from the generated `langgraph.json` + and invokes it lazily on its event loop — and again on every run. The + built graph is cached for the process lifetime so MCP discovery, sandbox + creation, and `atexit` registration each happen exactly once; re-running + them per request would re-discover MCP servers, leak sandbox sessions, + and stack duplicate `atexit` handlers. Any construction failure is + converted into a startup-error marker (scraped by the parent app + process) before exiting. + + Returns: + Compiled LangGraph agent graph. + """ + nonlocal graph + if graph is not missing: + return graph + async with lock: + if graph is missing: + try: + graph = await (builder or _make_graph)() + except Exception as exc: # noqa: BLE001 # top-level barrier: any construction failure must surface to the parent as a marker + emit_startup_failure(exc) + sys.exit(1) + return graph + + return make_graph + + +make_graph = _build_graph_factory() diff --git a/libs/code/deepagents_code/sessions.py b/libs/code/deepagents_code/sessions.py new file mode 100644 index 0000000000..37659bf3b8 --- /dev/null +++ b/libs/code/deepagents_code/sessions.py @@ -0,0 +1,1810 @@ +"""Thread management using LangGraph's built-in checkpoint persistence.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import sqlite3 +from contextlib import asynccontextmanager +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any, NamedTuple, NotRequired, TypedDict, cast + +from deepagents_code.goal_state_notice import is_internal_message + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + import aiosqlite + from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + + from deepagents_code.output import OutputFormat + +logger = logging.getLogger(__name__) + +_aiosqlite_patched = False +_jsonplus_serializer: JsonPlusSerializer | None = None +_message_count_cache: dict[str, tuple[str | None, int]] = {} +_MAX_MESSAGE_COUNT_CACHE = 4096 +_initial_prompt_cache: dict[str, tuple[str | None, str | None]] = {} +_MAX_INITIAL_PROMPT_CACHE = 4096 +_recent_threads_cache: dict[tuple[str | None, int], list[ThreadInfo]] = {} +_MAX_RECENT_THREADS_CACHE_KEYS = 16 +_DEFAULT_SQLITE_TIMEOUT = 5.0 +"""Seconds to wait out a locked database; matches the `sqlite3` default.""" + + +def _patch_aiosqlite() -> None: + """Patch aiosqlite.Connection with `is_alive()` if missing. + + Required by langgraph-checkpoint>=2.1.0. + See: https://github.com/langchain-ai/langgraph/issues/6583 + """ + global _aiosqlite_patched # noqa: PLW0603 # Module-level flag requires global statement + if _aiosqlite_patched: + return + + import aiosqlite as _aiosqlite + + if not hasattr(_aiosqlite.Connection, "is_alive"): + + def _is_alive(self: _aiosqlite.Connection) -> bool: + """Check if the connection is still alive. + + Returns: + True if connection is alive, False otherwise. + """ + return bool(self._running and self._connection is not None) + + # Dynamically adding a method to aiosqlite.Connection at runtime. + # Type checkers can't understand this monkey-patch, so we suppress the + # "attr-defined" error that would otherwise be raised. + _aiosqlite.Connection.is_alive = _is_alive # ty: ignore[unresolved-attribute] + + _aiosqlite_patched = True + + +async def _drain_aiosqlite_worker(conn: aiosqlite.Connection) -> None: + """Join the aiosqlite worker thread after its connection is closed. + + `aiosqlite.Connection` wraps a daemon `Thread` (`conn._thread`) that + drains its tx queue independently of the caller's event loop. The + library's `close()` puts a stop sentinel on the queue and awaits the + sentinel's future, but does not explicitly join the worker thread. + + If the connection is leaked (no explicit close) and the surrounding + event loop has already shut down, the worker can still pop a queued + item (typically from `Connection.__del__` calling `stop()`) and call + `future.get_loop().call_soon_threadsafe(...)` on the closed loop. That + raises `RuntimeError: Event loop is closed`, which pytest surfaces as + `PytestUnhandledThreadExceptionWarning` (and GitHub Actions then + surfaces as a workflow annotation). + + Explicitly joining the worker thread after close guarantees it has + exited before this coroutine returns, eliminating the race for any + connection routed through `_connect` / `get_checkpointer`. + """ + worker = getattr(conn, "_thread", None) + if worker is None or not worker.is_alive(): + return + # `RuntimeError` covers the "thread was never started" case; treat as + # already drained. + with contextlib.suppress(RuntimeError): + await asyncio.to_thread(worker.join, 5.0) + + +def _guard_sqlite_handle(conn: aiosqlite.Connection) -> None: + """Keep the sqlite handle closable when the opening task is cancelled. + + `aiosqlite` opens the database on its worker thread and delivers the raw + `sqlite3.Connection` back through a future, recording it on the + `Connection` only once the awaiting coroutine resumes. Background workers + are routinely cancelled at app exit, and a cancel landing anywhere in that + window leaves the handle unreachable from the cleanup that follows: + + - Cancelled while the worker is still opening, the library has no handle + recorded yet, so the cleanup it queues closes nothing. + - Cancelled after the handle is delivered but before the coroutine resumes, + the library clears its own record before that queued cleanup can run, so + again it closes nothing. + + Either way the garbage collector is left to report `ResourceWarning: + unclosed database`. Recording the handle from the worker thread covers the + first case; queueing an explicit close ahead of the library's own cleanup + covers the second. Both run on the thread that opened the handle, and + closing twice is a no-op, so neither disturbs a normal shutdown. + + Args: + conn: A connection that has not been opened yet. + """ + # No public hooks for any of this, so tolerate it moving: the leak avoided + # here is a warning at teardown, not something worth failing a query for. + connector = getattr(conn, "_connector", None) + queue = getattr(conn, "_tx", None) + stop = getattr(conn, "stop", None) + if connector is None or queue is None or stop is None: + logger.debug("aiosqlite internals moved; cannot guard the sqlite handle") + return + + def open_and_record() -> sqlite3.Connection: + handle = connector() + # The assignment aiosqlite makes once the awaiting coroutine resumes, + # made early enough that a cancel cannot get in front of it. + conn._connection = handle + return handle + + def stop_and_close() -> asyncio.Future[Any] | None: + # Runs before aiosqlite drops its own reference, so the handle is still + # here to queue a close for -- ahead of the stop sentinel, which ends + # the worker loop. A `None` future keeps the worker from reaching for an + # event loop that may already be gone. + handle = conn._connection + if handle is not None: + queue.put_nowait((None, handle.close)) + return stop() + + conn._connector = open_and_record + # Shadows the bound method on this one instance; the declared type is the + # unbound `stop(self)`, which a zero-argument replacement cannot match. + conn.stop = stop_and_close # ty: ignore[invalid-assignment] + + +def _new_connection(timeout: float = _DEFAULT_SQLITE_TIMEOUT) -> aiosqlite.Connection: + """Build an unopened connection to the sessions database. + + Args: + timeout: Seconds to wait out a locked database before giving up. + + Returns: + A connection that closes its sqlite handle even when interrupted. + """ + import aiosqlite as _aiosqlite + + _patch_aiosqlite() + + conn = _aiosqlite.connect(str(get_db_path()), timeout=timeout) + _guard_sqlite_handle(conn) + return conn + + +@asynccontextmanager +async def _connect() -> AsyncIterator[aiosqlite.Connection]: + """Import aiosqlite, apply the compatibility patch, and connect. + + Centralizes the deferred import + patch + connect sequence used by every + database function in this module. + + Yields: + An open aiosqlite connection to the sessions database. + """ + conn = _new_connection(timeout=30.0) + try: + async with conn as opened: + yield opened + finally: + await _drain_aiosqlite_worker(conn) + + +class ThreadInfo(TypedDict): + """Thread metadata returned by `list_threads`.""" + + thread_id: str + """Unique identifier for the thread.""" + + agent_name: str | None + """Name of the agent that owns the thread.""" + + updated_at: str | None + """ISO timestamp of the last update.""" + + created_at: NotRequired[str | None] + """ISO timestamp of thread creation (earliest checkpoint).""" + + git_branch: NotRequired[str | None] + """Git branch active when the thread was created.""" + + initial_prompt: NotRequired[str | None] + """First human message in the thread.""" + + message_count: NotRequired[int] + """Number of messages in the thread.""" + + latest_checkpoint_id: NotRequired[str | None] + """Most recent checkpoint ID for cache invalidation.""" + + cwd: NotRequired[str | None] + """Working directory where the thread was last used.""" + + +class _CheckpointSummary(NamedTuple): + """Structured data extracted from a thread's latest checkpoint.""" + + message_count: int | None + """Number of messages inlined in the latest checkpoint, or `None`. + + `None` means the latest checkpoint did not inline the `messages` channel + value, so the count is unknown and must be reconstructed from the `writes` + table. This happens when `messages` uses a `DeltaChannel` (a LangGraph + channel the deepagents SDK applies to `messages` as of v0.6) and the latest + checkpoint falls between periodic snapshots. An `int` (including `0`) is a + trustworthy count. + """ + + initial_prompt: str | None + """First human prompt recovered from the latest checkpoint.""" + + +def format_timestamp(iso_timestamp: str | None) -> str: + """Format ISO timestamp for display (e.g., 'Dec 30, 6:10pm'). + + Args: + iso_timestamp: ISO 8601 timestamp string, or `None`. + + Returns: + Formatted timestamp string or empty string if invalid. + """ + if not iso_timestamp: + return "" + try: + dt = datetime.fromisoformat(iso_timestamp).astimezone() + return ( + dt.strftime("%b %d, %-I:%M%p") + .lower() + .replace("am", "am") + .replace("pm", "pm") + ) + except (ValueError, TypeError): + logger.debug( + "Failed to parse timestamp %r; displaying as blank", + iso_timestamp, + exc_info=True, + ) + return "" + + +def format_relative_timestamp(iso_timestamp: str | None) -> str: + """Format ISO timestamp as relative time (e.g., '5m ago', '2h ago'). + + Args: + iso_timestamp: ISO 8601 timestamp string, or `None`. + + Returns: + Relative time string or empty string if invalid. + """ + if not iso_timestamp: + return "" + try: + dt = datetime.fromisoformat(iso_timestamp).astimezone() + except (ValueError, TypeError): + logger.debug( + "Failed to parse timestamp %r; displaying as blank", + iso_timestamp, + exc_info=True, + ) + return "" + + delta = datetime.now(tz=dt.tzinfo) - dt + seconds = int(delta.total_seconds()) + if seconds < 0: + return "just now" + if seconds < 60: # noqa: PLR2004 + return f"{seconds}s ago" + minutes = seconds // 60 + if minutes < 60: # noqa: PLR2004 + return f"{minutes}m ago" + hours = minutes // 60 + if hours < 24: # noqa: PLR2004 + return f"{hours}h ago" + days = hours // 24 + if days < 30: # noqa: PLR2004 + return f"{days}d ago" + if days < 365: # noqa: PLR2004 + months = days // 30 + return f"{months}mo ago" + years = days // 365 + return f"{years}y ago" + + +def format_path(path: str | None) -> str: + """Format a filesystem path for display. + + Paths under the user's home directory are shown relative to `~`. + All other paths are returned as-is. + + Args: + path: Absolute filesystem path, or `None`. + + Returns: + Formatted path string, or empty string if path is falsy. + """ + if not path: + return "" + try: + home = str(Path.home()) + if path == home: + return "~" + prefix = home + "/" + if path.startswith(prefix): + return "~/" + path[len(prefix) :] + except (RuntimeError, KeyError, OSError): + logger.debug( + "Could not resolve home directory for path formatting", exc_info=True + ) + return path + else: + return path + + +_db_path: Path | None = None + + +def get_db_path() -> Path: + """Get path to global database. + + The result is cached after the first successful call to avoid repeated + filesystem operations. + + Returns: + Path to the SQLite database file. + """ + global _db_path # noqa: PLW0603 # Module-level cache requires global statement + if _db_path is not None: + return _db_path + from deepagents_code.model_config import DEFAULT_STATE_DIR + + DEFAULT_STATE_DIR.mkdir(parents=True, exist_ok=True) + _db_path = DEFAULT_STATE_DIR / "sessions.db" + return _db_path + + +def generate_thread_id() -> str: + """Generate a new thread ID as a full UUID7 string. + + Returns: + UUID7 string (time-ordered for natural sort by creation time). + """ + from uuid_utils import uuid7 + + return str(uuid7()) + + +async def _table_exists(conn: aiosqlite.Connection, table: str) -> bool: + """Check if a table exists in the database. + + Returns: + True if table exists, False otherwise. + """ + query = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?" + async with conn.execute(query, (table,)) as cursor: + return await cursor.fetchone() is not None + + +_THREADS_LIST_INDEX = "idx_dcode_threads_list" +"""Covering index that makes the `list_threads` GROUP BY an index-only scan. + +LangGraph's `SqliteSaver` stores each checkpoint's full state blob inline in the +`checkpoints` row alongside the small `metadata` field. The thread-list query +only needs `metadata` (per-thread latest `updated_at`, `agent_name`, etc.), but +without a covering index SQLite scans the whole table — dragging every state +blob through I/O. On a large profile (e.g. ~12 GB of blobs) that scan takes +tens of seconds. This index carries exactly the expressions the query reads, so +the planner satisfies the GROUP BY from the index alone and never touches the +blob-bearing rows, turning a ~60 s scan into a sub-second lookup. + +The column order (leading `thread_id`) also lets the GROUP BY consume the index +in order. Keep the indexed expressions in sync with the `list_threads` query. +""" + + +async def _ensure_threads_list_index(conn: aiosqlite.Connection) -> None: + """Create the `list_threads` covering index if it does not already exist. + + Idempotent: `CREATE INDEX IF NOT EXISTS` is a near-instant catalog check once + the index exists. The one-time build on a pre-existing large database costs a + single full table scan (seconds to tens of seconds), after which every + `list_threads` call is a sub-second index-only scan. Runs in the aiosqlite + worker thread, so it does not block the event loop. + + A failure here is non-fatal: the list query still returns correct results via + the slower table scan, so we log and continue rather than break `threads + list` (e.g. on a read-only database or under write-lock contention). + """ + try: + await conn.execute( + f"CREATE INDEX IF NOT EXISTS {_THREADS_LIST_INDEX} ON checkpoints(" + "thread_id, " + "json_extract(metadata, '$.updated_at'), " + "checkpoint_id, " + "json_extract(metadata, '$.agent_name'), " + "json_extract(metadata, '$.git_branch'), " + "json_extract(metadata, '$.cwd'))" + ) + await conn.commit() + except Exception: + logger.warning( + "Failed to create the %s index; `threads list` will fall back to a " + "full table scan and may be slow on large databases", + _THREADS_LIST_INDEX, + exc_info=True, + ) + + +async def list_threads( + agent_name: str | None = None, + limit: int = 20, + include_message_count: bool = False, + sort_by: str = "updated", + branch: str | None = None, + cwd: str | None = None, +) -> list[ThreadInfo]: + """List threads from checkpoints table. + + Args: + agent_name: Optional filter by agent name. + limit: Maximum number of threads to return. + include_message_count: Whether to include message counts. + sort_by: Sort field — `"updated"` or `"created"`. + branch: Optional filter by git branch name. + cwd: Optional filter by working directory. Only threads whose stored + `cwd` metadata equals this path are returned. Matching is an + exact string comparison — no path normalization, symlink + resolution, or prefix matching. Threads without a stored `cwd` + (older rows) are excluded. + + Returns: + List of `ThreadInfo` dicts with `thread_id`, `agent_name`, + `updated_at`, `created_at`, `latest_checkpoint_id`, `git_branch`, + `cwd`, and optionally `message_count`. + + Raises: + ValueError: If `sort_by` is not `"updated"` or `"created"`. + """ + async with _connect() as conn: + if not await _table_exists(conn, "checkpoints"): + return [] + + # Ensure the covering index exists before the GROUP BY below, so the + # query is an index-only scan instead of a full scan over the (large, + # blob-bearing) checkpoints table. + await _ensure_threads_list_index(conn) + + if sort_by not in {"updated", "created"}: + msg = f"Invalid sort_by {sort_by!r}; expected 'updated' or 'created'" + raise ValueError(msg) + order_col = "created_at" if sort_by == "created" else "updated_at" + + where_clauses: list[str] = [] + params_list: list[str | int] = [] + + if agent_name: + where_clauses.append("json_extract(metadata, '$.agent_name') = ?") + params_list.append(agent_name) + if branch: + where_clauses.append("json_extract(metadata, '$.git_branch') = ?") + params_list.append(branch) + if cwd: + where_clauses.append("json_extract(metadata, '$.cwd') = ?") + params_list.append(cwd) + + where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + + query = f""" + SELECT thread_id, + json_extract(metadata, '$.agent_name') as agent_name, + MAX(json_extract(metadata, '$.updated_at')) as updated_at, + MAX(checkpoint_id) as latest_checkpoint_id, + MIN(json_extract(metadata, '$.updated_at')) as created_at, + MAX(json_extract(metadata, '$.git_branch')) as git_branch, + MAX(json_extract(metadata, '$.cwd')) as cwd + FROM checkpoints + {where_sql} + GROUP BY thread_id + ORDER BY {order_col} DESC + LIMIT ? + """ # noqa: S608 # where_sql/order_col derived from controlled internal values; user values use ? placeholders + params: tuple = (*params_list, limit) + + async with conn.execute(query, params) as cursor: + rows = await cursor.fetchall() + threads: list[ThreadInfo] = [ + ThreadInfo( + thread_id=r[0], + agent_name=r[1], + updated_at=r[2], + latest_checkpoint_id=r[3], + created_at=r[4], + git_branch=r[5], + cwd=r[6], + ) + for r in rows + ] + + # Fetch message counts if requested + if include_message_count and threads: + await _populate_message_counts(conn, threads) + + # Only cache unfiltered results so the thread selector modal + # doesn't receive branch-/cwd-filtered or differently-sorted data. + if sort_by == "updated" and branch is None and cwd is None: + _cache_recent_threads(agent_name, limit, threads) + return threads + + +async def populate_thread_checkpoint_details( + threads: list[ThreadInfo], + *, + include_message_count: bool = True, + include_initial_prompt: bool = True, +) -> list[ThreadInfo]: + """Populate checkpoint-derived fields for an existing thread list. + + This is used by the `/threads` modal to enrich rows in one background pass, + so the latest checkpoint is fetched and deserialized at most once per row. + + Args: + threads: Thread rows to enrich in place. + include_message_count: Whether to populate `message_count`. + include_initial_prompt: Whether to populate `initial_prompt`. + + Returns: + The same list object with missing checkpoint-derived fields populated. + """ + if not threads or (not include_message_count and not include_initial_prompt): + return threads + + async with _connect() as conn: + await _populate_checkpoint_fields( + conn, + threads, + include_message_count=include_message_count, + include_initial_prompt=include_initial_prompt, + ) + return threads + + +async def prewarm_thread_message_counts(limit: int | None = None) -> None: + """Prewarm thread selector cache for faster `/threads` open. + + Fetches a bounded list of recent threads and populates checkpoint-derived + fields for currently visible columns into the in-memory cache. Intended to + run in a background worker during app startup and again whenever the + session database has changed (e.g. after a turn writes new checkpoints), so + the selector's first paint is never missing a thread the user just created. + + Re-running this is cheap: the per-thread message-count and initial-prompt + caches are keyed on checkpoint freshness, so only threads whose latest + checkpoint changed are read back from disk. + + Args: + limit: Maximum threads to prewarm. Uses `get_thread_limit()` when `None`. + """ + thread_limit = limit if limit is not None else get_thread_limit() + if thread_limit < 1: + return + + try: + from deepagents_code.model_config import load_thread_config + + cfg = load_thread_config() + threads = await list_threads(limit=thread_limit, include_message_count=False) + if threads: + await populate_thread_checkpoint_details( + threads, + include_message_count=cfg.columns.get("messages", False), + include_initial_prompt=cfg.columns.get("initial_prompt", False), + ) + _cache_recent_threads(None, thread_limit, threads) + except (OSError, sqlite3.Error): + logger.debug("Could not prewarm thread selector cache", exc_info=True) + except Exception: + logger.warning( + "Unexpected error while prewarming thread selector cache", + exc_info=True, + ) + + +def get_cached_threads( + agent_name: str | None = None, + limit: int | None = None, +) -> list[ThreadInfo] | None: + """Get cached recent threads, if available. + + Args: + agent_name: Optional agent-name filter key. + limit: Maximum rows requested. Uses `get_thread_limit()` when `None`. + + Returns: + Copy of cached rows when available, otherwise `None`. + """ + + def _copy_with_cached_counts(rows: list[ThreadInfo]) -> list[ThreadInfo]: + copied_rows = _copy_threads(rows) + apply_cached_thread_message_counts(copied_rows) + apply_cached_thread_initial_prompts(copied_rows) + return copied_rows + + thread_limit = limit if limit is not None else get_thread_limit() + if thread_limit < 1: + return None + + exact = _recent_threads_cache.get((agent_name, thread_limit)) + if exact is not None: + return _copy_with_cached_counts(exact) + + best_key: tuple[str | None, int] | None = None + for key in _recent_threads_cache: + cache_agent, cache_limit = key + if cache_agent != agent_name or cache_limit < thread_limit: + continue + if best_key is None or cache_limit < best_key[1]: + best_key = key + + if best_key is None: + return None + + return _copy_with_cached_counts(_recent_threads_cache[best_key][:thread_limit]) + + +def apply_cached_thread_message_counts(threads: list[ThreadInfo]) -> int: + """Apply cached message counts onto thread rows when freshness matches. + + Args: + threads: Thread rows to mutate in place. + + Returns: + Number of rows that were populated from cache. + """ + populated = 0 + for thread in threads: + if "message_count" in thread: + continue + thread_id = thread["thread_id"] + freshness = _thread_freshness(thread) + cached = _message_count_cache.get(thread_id) + if cached is None or cached[0] != freshness: + continue + thread["message_count"] = cached[1] + populated += 1 + return populated + + +def apply_cached_thread_initial_prompts(threads: list[ThreadInfo]) -> int: + """Apply cached initial prompts onto thread rows when freshness matches. + + Args: + threads: Thread rows to mutate in place. + + Returns: + Number of rows that were populated from cache. + """ + populated = 0 + for thread in threads: + if "initial_prompt" in thread: + continue + thread_id = thread["thread_id"] + freshness = _thread_freshness(thread) + cached = _initial_prompt_cache.get(thread_id) + if cached is None or cached[0] != freshness: + continue + thread["initial_prompt"] = cached[1] + populated += 1 + return populated + + +async def _populate_message_counts( + conn: aiosqlite.Connection, + threads: list[ThreadInfo], +) -> None: + """Fill `message_count` on thread rows with cache-aware lookup.""" + await _populate_checkpoint_fields( + conn, + threads, + include_message_count=True, + include_initial_prompt=False, + ) + + +async def _get_jsonplus_serializer() -> JsonPlusSerializer: + """Return a cached JsonPlus serializer, loading it off the UI loop.""" + global _jsonplus_serializer # noqa: PLW0603 # Module-level cache requires global statement + if _jsonplus_serializer is not None: + return _jsonplus_serializer + + loop = asyncio.get_running_loop() + _jsonplus_serializer = await loop.run_in_executor(None, _create_jsonplus_serializer) + return _jsonplus_serializer + + +def _create_jsonplus_serializer() -> JsonPlusSerializer: + """Import and create a JsonPlus serializer. + + Returns: + A ready `JsonPlusSerializer` instance. + """ + from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + + return JsonPlusSerializer() + + +def _cache_message_count(thread_id: str, freshness: str | None, count: int) -> None: + """Cache a thread's message count with a freshness token.""" + if len(_message_count_cache) >= _MAX_MESSAGE_COUNT_CACHE and ( + thread_id not in _message_count_cache + ): + oldest = next(iter(_message_count_cache)) + _message_count_cache.pop(oldest, None) + _message_count_cache[thread_id] = (freshness, count) + + +def _cache_initial_prompt( + thread_id: str, + freshness: str | None, + initial_prompt: str | None, +) -> None: + """Cache a thread's initial prompt with a freshness token.""" + if len(_initial_prompt_cache) >= _MAX_INITIAL_PROMPT_CACHE and ( + thread_id not in _initial_prompt_cache + ): + oldest = next(iter(_initial_prompt_cache)) + _initial_prompt_cache.pop(oldest, None) + _initial_prompt_cache[thread_id] = (freshness, initial_prompt) + + +def _thread_freshness(thread: ThreadInfo) -> str | None: + """Return a cache freshness token for a thread row. + + The token is checkpoint-granular (`latest_checkpoint_id`). The + writes-reconstructed `message_count` includes pending writes on the latest + checkpoint, which in principle can change without a new checkpoint ID — so + this token does not capture intra-checkpoint write churn. In practice that + is benign: dcode only mutates `messages` through the agent graph, and every + batch of message writes culminates in a new checkpoint (each superstep, + `aupdate_state`, interrupt, and cancellation all advance + `latest_checkpoint_id`). The only window where a cached count can lag is + opening the `/threads` selector mid-superstep against an actively streaming + thread; the selector does not live-refresh, so that count stays put until + the modal is reopened (by then a new checkpoint exists and the cache + refreshes). Making the key write-sensitive would require probing the + `writes` table for every row on every `list_threads`, which is not worth it + for a cosmetic count. + """ + return thread.get("latest_checkpoint_id") or thread.get("updated_at") + + +def _cache_recent_threads( + agent_name: str | None, + limit: int, + threads: list[ThreadInfo], +) -> None: + """Store a copy of recent thread rows for fast selector startup.""" + key = (agent_name, max(1, limit)) + if len(_recent_threads_cache) >= _MAX_RECENT_THREADS_CACHE_KEYS and ( + key not in _recent_threads_cache + ): + _recent_threads_cache.clear() + _recent_threads_cache[key] = _copy_threads(threads) + + +def _copy_threads(threads: list[ThreadInfo]) -> list[ThreadInfo]: + """Return shallow-copied thread rows.""" + return [ThreadInfo(**thread) for thread in threads] + + +async def _populate_checkpoint_fields( + conn: aiosqlite.Connection, + threads: list[ThreadInfo], + *, + include_message_count: bool, + include_initial_prompt: bool, +) -> None: + """Populate checkpoint-derived thread fields with a batched latest-row pass.""" + serde = await _get_jsonplus_serializer() + + # Phase 1: apply cache hits, collect threads that need DB fetch. + uncached: list[ThreadInfo] = [] + for thread in threads: + thread_id = thread["thread_id"] + freshness = _thread_freshness(thread) + needs_count = False + needs_prompt = False + + if include_message_count: + cached = _message_count_cache.get(thread_id) + if cached is not None and cached[0] == freshness: + thread["message_count"] = cached[1] + else: + needs_count = True + + if include_initial_prompt and "initial_prompt" not in thread: + cached_prompt = _initial_prompt_cache.get(thread_id) + if cached_prompt is not None and cached_prompt[0] == freshness: + thread["initial_prompt"] = cached_prompt[1] + else: + needs_prompt = True + + if needs_count or needs_prompt: + uncached.append(thread) + + if not uncached: + return + + # Phase 2: batch-fetch all uncached threads. + uncached_ids = [t["thread_id"] for t in uncached] + batch_results: dict[str, _CheckpointSummary] = {} + if include_message_count or include_initial_prompt: + batch_results = await _load_latest_checkpoint_summaries_batch( + conn, uncached_ids, serde + ) + # `initial_prompt` cannot be recovered from the latest checkpoint alone: + # `after_model` middleware (e.g., `ResumeStateMiddleware`) writes partial + # checkpoints whose `channel_values` omit `messages`. Read the very first + # write to the `messages` channel from the `writes` table instead — that + # row holds the user's original input. + prompt_results: dict[str, str | None] = {} + if include_initial_prompt: + prompt_results = await _load_initial_prompts_from_writes_batch( + conn, uncached_ids, serde + ) + + # Phase 3: apply inline results, deferring threads whose latest checkpoint + # does not inline the `messages` channel value. When `messages` uses a + # `DeltaChannel` (LangGraph channel applied by the deepagents SDK as of + # v0.6) the full list is only snapshotted into `channel_values` periodically, + # so the latest checkpoint usually omits it; the count must then be + # reconstructed from the `writes` table. + needs_writes_count: list[str] = [] + for thread in uncached: + thread_id = thread["thread_id"] + freshness = _thread_freshness(thread) + + if include_message_count and "message_count" not in thread: + summary = batch_results.get(thread_id) + if summary is not None and summary.message_count is not None: + thread["message_count"] = summary.message_count + _cache_message_count(thread_id, freshness, summary.message_count) + else: + needs_writes_count.append(thread_id) + if include_initial_prompt and "initial_prompt" not in thread: + if thread_id in prompt_results: + prompt = prompt_results[thread_id] + else: + prompt = batch_results.get( + thread_id, _CheckpointSummary(None, None) + ).initial_prompt + thread["initial_prompt"] = prompt + _cache_initial_prompt(thread_id, freshness, prompt) + + # Phase 4: reconstruct counts for delta-channel threads from the `writes` + # table by replaying the `messages` writes through the canonical reducer. + if needs_writes_count: + writes_counts = await _load_message_counts_from_writes_batch( + conn, needs_writes_count, serde + ) + uncached_by_id = {t["thread_id"]: t for t in uncached} + for thread_id in needs_writes_count: + count = writes_counts.get(thread_id, 0) + thread = uncached_by_id[thread_id] + thread["message_count"] = count + _cache_message_count(thread_id, _thread_freshness(thread), count) + + +_SQLITE_MAX_VARIABLE_NUMBER = 500 +"""Max `?` placeholders per SQL query. + +SQLite limits how many `?` parameters a single query can have (default 999, +lower on some builds). If a user accumulates hundreds of threads and the +`/threads` modal fetches them all at once, the `IN (?, ?, ...)` clause could +exceed that limit. We chunk to this size to stay safe. +""" + + +async def _load_latest_checkpoint_summaries_batch( + conn: aiosqlite.Connection, + thread_ids: list[str], + serde: JsonPlusSerializer, +) -> dict[str, _CheckpointSummary]: + """Batch-load the latest checkpoint summary for multiple threads. + + Uses a window function to fetch the latest checkpoint per thread, issuing + one query per chunk for SQLite variable-limit safety. + + Args: + conn: Database connection. + thread_ids: Thread IDs to look up. + serde: Serializer for decoding checkpoint blobs. + + Returns: + Dict mapping thread IDs to their checkpoint summaries. + """ + if not thread_ids: + return {} + + results: dict[str, _CheckpointSummary] = {} + + for start in range(0, len(thread_ids), _SQLITE_MAX_VARIABLE_NUMBER): + chunk = thread_ids[start : start + _SQLITE_MAX_VARIABLE_NUMBER] + placeholders = ",".join("?" * len(chunk)) + query = f""" + SELECT thread_id, type, checkpoint FROM ( + SELECT thread_id, type, checkpoint, + ROW_NUMBER() OVER ( + PARTITION BY thread_id ORDER BY checkpoint_id DESC + ) AS rn + FROM checkpoints + WHERE thread_id IN ({placeholders}) + ) WHERE rn = 1 + """ # noqa: S608 # placeholders built from len(chunk); user values use ? params + async with conn.execute(query, chunk) as cursor: + rows = await cursor.fetchall() + + loop = asyncio.get_running_loop() + for row in rows: + tid, type_str, checkpoint_blob = row + if not type_str or not checkpoint_blob: + results[tid] = _CheckpointSummary( + message_count=None, initial_prompt=None + ) + continue + try: + data = await loop.run_in_executor( + None, serde.loads_typed, (type_str, checkpoint_blob) + ) + results[tid] = _summarize_checkpoint(data) + except Exception: + logger.warning( + "Failed to deserialize checkpoint for thread %s; " + "message count and initial prompt may be incomplete", + tid, + exc_info=True, + ) + results[tid] = _CheckpointSummary( + message_count=None, initial_prompt=None + ) + + return results + + +async def _load_initial_prompts_from_writes_batch( + conn: aiosqlite.Connection, + thread_ids: list[str], + serde: JsonPlusSerializer, +) -> dict[str, str | None]: + """Batch-load initial prompts from the LangGraph `writes` table. + + For each thread, returns the first human/user message extracted from the + earliest write to the `messages` channel (ordered by `checkpoint_id` ASC, + then `idx` ASC). + + Args: + conn: Database connection. + thread_ids: Thread IDs to look up. + serde: Serializer for decoding write blobs. + + Returns: + Dict mapping thread IDs to their initial prompt text. Threads with no + write to the `messages` channel are absent from the result; threads + whose first such write decoded but contained no human/user entry map + to `None`. + """ + if not thread_ids: + return {} + + results: dict[str, str | None] = {} + loop = asyncio.get_running_loop() + for start in range(0, len(thread_ids), _SQLITE_MAX_VARIABLE_NUMBER): + chunk = thread_ids[start : start + _SQLITE_MAX_VARIABLE_NUMBER] + placeholders = ",".join("?" * len(chunk)) + query = f""" + SELECT thread_id, type, value FROM ( + SELECT thread_id, type, value, + ROW_NUMBER() OVER ( + PARTITION BY thread_id + ORDER BY checkpoint_id ASC, idx ASC + ) AS rn + FROM writes + WHERE thread_id IN ({placeholders}) AND channel = 'messages' + ) WHERE rn = 1 + """ # noqa: S608 # placeholders built from len(chunk); user values use ? params + async with conn.execute(query, chunk) as cursor: + rows = await cursor.fetchall() + + for row in rows: + tid, type_str, value_blob = row + if not type_str or not value_blob: + continue + try: + messages = await loop.run_in_executor( + None, serde.loads_typed, (type_str, value_blob) + ) + except Exception: + logger.warning( + "Failed to deserialize initial messages write for thread %s", + tid, + exc_info=True, + ) + continue + if not isinstance(messages, list): + continue + results[tid] = _initial_prompt_from_messages(cast("list[object]", messages)) + + return results + + +async def _load_message_counts_from_writes_batch( + conn: aiosqlite.Connection, + thread_ids: list[str], + serde: JsonPlusSerializer, +) -> dict[str, int]: + """Reconstruct message counts from the LangGraph `writes` table. + + For threads whose latest checkpoint does not inline the `messages` channel + value — a `DeltaChannel` between snapshots, where the deepagents SDK (>= 0.6) + applies LangGraph's `DeltaChannel` to `messages` — the full list is rebuilt + by replaying every `messages` write, then counted. We replay through + `add_messages` as a count-equivalent stand-in for the channel's actual + reducer (`_messages_delta_reducer`): both dedup by ID and honor + `RemoveMessage` / `REMOVE_ALL_MESSAGES`, so they produce the same final + message set. An `Overwrite` write resets the accumulator to its value, + matching the net effect of `DeltaChannel.replay_writes` (where the last + `Overwrite` is the reset point). + + Reduction runs in a single worker-thread hop per chunk (decode is CPU-bound + and a long thread can have thousands of writes; dispatching per row both + serialized the work and added an executor round-trip each time). The common + append-and-clear history folds in one `add_messages` pass (linear), which is + why a busy thread no longer takes seconds to count. See + `_count_messages_from_deltas` for the fold and its exact-fold fallback. + + Only the root namespace (`checkpoint_ns = ''`) is counted, matching both the + inline path and the conversation the `/threads` selector cares about; + subgraph (subagent) writes under the same `thread_id` are excluded. + + Folding the *entire* write history (rather than walking the head + checkpoint's parent chain) is intentional and matches what dcode shows when + a thread is opened: it reads state via `aget_state` without a + `checkpoint_id`, which applies pending writes (`apply_pending_writes=True`), + so the latest checkpoint's not-yet-committed `messages` writes are part of + the user-visible list and must be counted. dcode only ever appends to the + latest checkpoint (no time travel, no `checkpoint_id`-targeted + `aupdate_state`), so histories are linear and the full fold equals the + head-of-chain reconstruction. A forked/abandoned branch (which dcode does + not create) is the only case where this could over-count. + + Args: + conn: Database connection. + thread_ids: Thread IDs to look up. + serde: Serializer for decoding write blobs. + + Returns: + Dict mapping each thread ID with at least one decodable `messages` + write to its reconstructed message count. Threads with no such + writes are absent from the result. + """ + if not thread_ids: + return {} + + loop = asyncio.get_running_loop() + results: dict[str, int] = {} + # Chunks partition by thread, so every write for a given thread lands in the + # same query; each thread is counted exactly once. Ordering by + # (checkpoint_id, task_id, idx) replays deltas oldest-to-newest, matching how + # LangGraph applies them on load. + for start in range(0, len(thread_ids), _SQLITE_MAX_VARIABLE_NUMBER): + chunk = thread_ids[start : start + _SQLITE_MAX_VARIABLE_NUMBER] + placeholders = ",".join("?" * len(chunk)) + query = f""" + SELECT thread_id, type, value + FROM writes + WHERE thread_id IN ({placeholders}) + AND checkpoint_ns = '' + AND channel = 'messages' + ORDER BY thread_id, checkpoint_id ASC, task_id ASC, idx ASC + """ # noqa: S608 # placeholders built from len(chunk); user values use ? params + async with conn.execute(query, chunk) as cursor: + rows = await cursor.fetchall() + + chunk_counts = await loop.run_in_executor( + None, _reduce_message_write_rows, list(rows), serde + ) + results.update(chunk_counts) + + return results + + +def _reduce_message_write_rows( + rows: list[tuple[str, str | None, bytes | None]], + serde: JsonPlusSerializer, +) -> dict[str, int]: + """Decode `messages`-channel write rows and count messages per thread. + + Runs synchronously in a worker thread. Rows must be ordered so each thread's + deltas are oldest-to-newest. Undecodable rows are skipped (logged), matching + the per-row error handling of the previous implementation. + + Returns: + Mapping of thread ID to reconstructed message count. + """ + deltas_by_thread: dict[str, list[Any]] = {} + for tid, type_str, value_blob in rows: + if not type_str or not value_blob: + continue + try: + delta = serde.loads_typed((type_str, value_blob)) + except Exception: + logger.warning( + "Failed to replay messages write for thread %s; " + "message count may be inaccurate", + tid, + exc_info=True, + ) + continue + deltas_by_thread.setdefault(tid, []).append(delta) + + counts: dict[str, int] = {} + for tid, deltas in deltas_by_thread.items(): + try: + counts[tid] = _count_messages_from_deltas(deltas) + except Exception: + # Keep one malformed thread from failing the whole `threads list` + # load: skip it (its count is simply absent) rather than propagating. + logger.warning( + "Failed to count messages for thread %s; omitting its count", + tid, + exc_info=True, + ) + return counts + + +def _visible_message_count(messages: list[object]) -> int: + """Count messages that appear in user-facing thread history. + + Returns: + Number of messages not classified as hidden application context. + """ + return sum(not is_internal_message(message) for message in messages) + + +def _count_messages_from_deltas(deltas: list[Any]) -> int: + """Count messages from an ordered list of `messages`-channel write deltas. + + Fast path: appends and full-clears (`REMOVE_ALL_MESSAGES`, `Overwrite`) fold + into one `add_messages` pass — O(n) instead of the O(n^2) incremental fold, + so threads with thousands of writes count in milliseconds. For these ops the + single-pass result is count-equivalent to the sequential fold (both dedup by + ID, and clears collapse to the post-clear tail). + + Slow path: a specific `RemoveMessage` (delete-by-ID) or any reducer error + falls back to the exact sequential fold as a conservative measure. A + delete-by-ID concatenated into the single `buffer` can make batch + `add_messages` raise (the target ID may be absent at that buffer position), + and we do not rely on unproven count-equivalence of batched removal. In + practice the two folds still agree on the count for these histories; the + sequential fold simply guarantees it. Such deletes are rare in linear dcode + histories, so the common case stays on the fast path. + + Returns: + Number of messages after reducing the deltas. + """ + from langchain_core.messages import RemoveMessage + from langgraph.graph.message import REMOVE_ALL_MESSAGES, add_messages + from langgraph.types import Overwrite + + buffer: list[Any] = [] + needs_exact_fold = False + for delta in deltas: + if isinstance(delta, Overwrite): + value = delta.value + buffer = list(value) if isinstance(value, list) else [] + continue + items = delta if isinstance(delta, list) else [delta] + for item in items: + if isinstance(item, RemoveMessage): + if item.id == REMOVE_ALL_MESSAGES: + buffer = [] + else: + needs_exact_fold = True + break + else: + buffer.append(item) + if needs_exact_fold: + break + + if not needs_exact_fold: + try: + reduced = cast("list[Any]", add_messages([], buffer)) + return _visible_message_count(cast("list[object]", reduced)) + except Exception: + logger.debug( + "Batched message-count fold failed; using sequential fold", + exc_info=True, + ) + + return _incremental_message_count(deltas) + + +def _incremental_message_count(deltas: list[Any]) -> int: + """Count messages by folding deltas sequentially through `add_messages`. + + Exact reference reduction: applies one delta at a time, resetting on + `Overwrite` and skipping any delta the reducer rejects (e.g. a delete for an + absent ID). Used as the fallback when the batched fast path cannot guarantee + a matching count. + + Returns: + Number of messages after the sequential fold. + """ + from langgraph.graph.message import add_messages + from langgraph.types import Overwrite + + reduced: list[Any] = [] + for delta in deltas: + if isinstance(delta, Overwrite): + value = delta.value + reduced = list(value) if isinstance(value, list) else [] + continue + try: + reduced = cast("list[Any]", add_messages(reduced, delta)) + except Exception: + logger.warning( + "Failed to replay messages write; message count may be inaccurate", + exc_info=True, + ) + continue + return _visible_message_count(cast("list[object]", reduced)) + + +def _summarize_checkpoint(data: object) -> _CheckpointSummary: + """Extract message count and initial human prompt from checkpoint data. + + Returns: + Structured summary for the decoded checkpoint payload. + """ + messages = _checkpoint_messages(data) + return _CheckpointSummary( + message_count=( + _visible_message_count(messages) if messages is not None else None + ), + initial_prompt=_initial_prompt_from_messages(messages or []), + ) + + +def _checkpoint_messages(data: object) -> list[object] | None: + """Return inlined checkpoint messages, or `None` when not inlined. + + A `None` return distinguishes a checkpoint that omits the `messages` + channel entirely (a `DeltaChannel` between snapshots, where the deepagents + SDK applies LangGraph's `DeltaChannel` to `messages` as of v0.6) from one + that inlines an empty list. The former requires reconstructing the count + from the `writes` table; the latter is a genuine zero. + """ + if not isinstance(data, dict): + return None + + payload = cast("dict[str, object]", data) + channel_values = payload.get("channel_values") + if not isinstance(channel_values, dict): + return None + + channel_values_dict = cast("dict[str, object]", channel_values) + messages = channel_values_dict.get("messages") + if not isinstance(messages, list): + return None + + return cast("list[object]", messages) + + +def _initial_prompt_from_messages(messages: list[object]) -> str | None: + """Return the first non-system human message content from a message list. + + Accepts both LangChain `HumanMessage` objects (with `type == "human"`) and + plain dicts in OpenAI chat shape (`{"role": "user", "content": ...}`). The + first write to the `messages` channel is the raw user input passed to the + agent, which is preserved verbatim as a dict; subsequent writes are + serialized `BaseMessage` instances produced after the model runs. + + Synthetic `[SYSTEM]`-prefixed human messages (e.g. an interrupt + cancellation notice) are skipped so they never surface as a thread's prompt. + """ + for msg in messages: + if is_internal_message(msg): + continue + if getattr(msg, "type", None) == "human": + prompt = _coerce_prompt_text(getattr(msg, "content", None)) + elif isinstance(msg, dict): + msg_dict = cast("dict[str, object]", msg) + role = msg_dict.get("role") + type_ = msg_dict.get("type") + if role not in {"user", "human"} and type_ != "human": + continue + prompt = _coerce_prompt_text(msg_dict.get("content")) + else: + continue + return prompt + return None + + +def _coerce_prompt_text(content: object) -> str | None: + """Normalize checkpoint message content into displayable text. + + Returns: + Displayable prompt text, or `None` when the content is empty. + """ + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if isinstance(part, dict): + part_dict = cast("dict[str, object]", part) + text = part_dict.get("text") + parts.append(text if isinstance(text, str) else "") + else: + parts.append(str(part)) + joined = " ".join(parts).strip() + return joined or None + if content is None: + return None + return str(content) + + +async def get_most_recent( + agent_name: str | None = None, + *, + exclude_thread_id: str | None = None, +) -> str | None: + """Get the most recent thread, optionally agent-filtered and/or excluding a thread. + + Args: + agent_name: Return only threads created by this agent. + exclude_thread_id: Ignore this thread when selecting the most recent one. + + Returns: + Most recent thread ID, or `None` if no matching threads exist. + """ + async with _connect() as conn: + if not await _table_exists(conn, "checkpoints"): + return None + + if agent_name and exclude_thread_id: + query = """ + SELECT thread_id FROM checkpoints + WHERE json_extract(metadata, '$.agent_name') = ? + AND thread_id != ? + ORDER BY checkpoint_id DESC + LIMIT 1 + """ + params: tuple[str, ...] = (agent_name, exclude_thread_id) + elif agent_name: + query = """ + SELECT thread_id FROM checkpoints + WHERE json_extract(metadata, '$.agent_name') = ? + ORDER BY checkpoint_id DESC + LIMIT 1 + """ + params = (agent_name,) + elif exclude_thread_id: + query = """ + SELECT thread_id FROM checkpoints + WHERE thread_id != ? + ORDER BY checkpoint_id DESC + LIMIT 1 + """ + params = (exclude_thread_id,) + else: + query = ( + "SELECT thread_id FROM checkpoints ORDER BY checkpoint_id DESC LIMIT 1" + ) + params = () + + async with conn.execute(query, params) as cursor: + row = await cursor.fetchone() + return row[0] if row else None + + +async def get_thread_agent(thread_id: str) -> str | None: + """Get agent_name for a thread. + + Returns: + Agent name associated with the thread, or None if not found. + """ + async with _connect() as conn: + if not await _table_exists(conn, "checkpoints"): + return None + + query = """ + SELECT json_extract(metadata, '$.agent_name') + FROM checkpoints + WHERE thread_id = ? + LIMIT 1 + """ + async with conn.execute(query, (thread_id,)) as cursor: + row = await cursor.fetchone() + return row[0] if row else None + + +async def get_thread_cwd(thread_id: str) -> str | None: + """Get the most recently stored cwd for a thread. + + Args: + thread_id: The thread whose stored cwd to look up. + + Returns: + Most recent cwd for the thread, or None if not found. + """ + async with _connect() as conn: + if not await _table_exists(conn, "checkpoints"): + return None + + query = """ + SELECT json_extract(metadata, '$.cwd') + FROM checkpoints + WHERE thread_id = ? AND json_extract(metadata, '$.cwd') IS NOT NULL + ORDER BY checkpoint_id DESC + LIMIT 1 + """ + async with conn.execute(query, (thread_id,)) as cursor: + row = await cursor.fetchone() + value = row[0] if row else None + return value if isinstance(value, str) and value else None + + +async def thread_exists(thread_id: str) -> bool: + """Check if a thread exists in checkpoints. + + Returns: + True if thread exists, False otherwise. + """ + async with _connect() as conn: + if not await _table_exists(conn, "checkpoints"): + return False + + query = "SELECT 1 FROM checkpoints WHERE thread_id = ? LIMIT 1" + async with conn.execute(query, (thread_id,)) as cursor: + row = await cursor.fetchone() + return row is not None + + +async def find_similar_threads(thread_id: str, limit: int = 3) -> list[str]: + """Find threads whose IDs start with the given prefix. + + Args: + thread_id: Prefix to match against thread IDs. + limit: Maximum number of matching threads to return. + + Returns: + List of thread IDs that begin with the given prefix. + """ + async with _connect() as conn: + if not await _table_exists(conn, "checkpoints"): + return [] + + query = """ + SELECT DISTINCT thread_id + FROM checkpoints + WHERE thread_id LIKE ? + ORDER BY thread_id + LIMIT ? + """ + prefix = thread_id + "%" + async with conn.execute(query, (prefix, limit)) as cursor: + rows = await cursor.fetchall() + return [r[0] for r in rows] + + +async def delete_thread(thread_id: str) -> bool: + """Delete thread checkpoints and any offloaded conversation history. + + Removes the thread's checkpoint/write rows, then makes a best-effort attempt + to remove the per-thread offloaded conversation-history archive under + `~/.deepagents` (local mode) so deletion does not leave orphaned history + behind. History cleanup failures are logged, not raised, and do not affect + the return value, which reflects only whether checkpoint rows were removed. + + Returns: + True if thread checkpoints were deleted, False if not found. + """ + deleted = False + async with _connect() as conn: + if await _table_exists(conn, "checkpoints"): + cursor = await conn.execute( + "DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,) + ) + deleted = cursor.rowcount > 0 + if await _table_exists(conn, "writes"): + await conn.execute( + "DELETE FROM writes WHERE thread_id = ?", (thread_id,) + ) + await conn.commit() + if deleted: + _message_count_cache.pop(thread_id, None) + for key, rows in list(_recent_threads_cache.items()): + filtered = [row for row in rows if row["thread_id"] != thread_id] + _recent_threads_cache[key] = filtered + + from deepagents_code.offload import delete_offloaded_history + + delete_offloaded_history(thread_id) + return deleted + + +@asynccontextmanager +async def get_checkpointer() -> AsyncIterator[AsyncSqliteSaver]: + """Get AsyncSqliteSaver for the global database. + + Yields: + AsyncSqliteSaver instance for checkpoint persistence. + """ + from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + + # Built here rather than through `AsyncSqliteSaver.from_conn_string` so the + # connection is one this module owns and can clean up after an interrupted + # connect; see `_guard_sqlite_handle`. + conn = _new_connection() + try: + async with conn as opened: + yield AsyncSqliteSaver(opened) + finally: + await _drain_aiosqlite_worker(conn) + + +_DEFAULT_THREAD_LIMIT = 20 + + +def get_thread_limit() -> int: + """Read the thread listing limit from `DA_CLI_RECENT_THREADS`. + + Falls back to `_DEFAULT_THREAD_LIMIT` when the variable is unset or contains + a non-integer value. The result is clamped to a minimum of 1. + + Returns: + Number of threads to display. + """ + import os + + raw = os.environ.get("DA_CLI_RECENT_THREADS") + if raw is None: + return _DEFAULT_THREAD_LIMIT + try: + return max(1, int(raw)) + except ValueError: + logger.warning( + "Invalid DA_CLI_RECENT_THREADS value %r, using default %d", + raw, + _DEFAULT_THREAD_LIMIT, + ) + return _DEFAULT_THREAD_LIMIT + + +async def list_threads_command( + agent_name: str | None = None, + limit: int | None = None, + sort_by: str | None = None, + branch: str | None = None, + cwd: str | None = None, + verbose: bool = False, + relative: bool | None = None, + *, + output_format: OutputFormat = "text", +) -> None: + """CLI handler for `deepagents threads list`. + + Fetches and displays a table of recent conversation threads, optionally + filtered by agent name, git branch, or working directory. + + Args: + agent_name: Only show threads belonging to this agent. + + When `None`, threads for all agents are shown. + limit: Maximum number of threads to display. + + When `None`, reads from `DA_CLI_RECENT_THREADS` or falls back to + the default. + sort_by: Sort field — `"updated"` or `"created"`. + + When `None`, reads from config (`~/.deepagents/config.toml`). + branch: Only show threads from this git branch. + cwd: Only show threads whose stored `cwd` metadata equals this path + (exact string match — no normalization or prefix matching). When + `None`, no cwd filter is applied. Threads without a stored `cwd` + (older rows) are excluded when this is set. + verbose: When `True`, show all columns (branch, created, prompt). + relative: Show timestamps as relative time (e.g., '5m ago'). + + When `None`, reads from config (`~/.deepagents/config.toml`). + output_format: Output format — `'text'` (Rich) or `'json'`. + """ + from deepagents_code.model_config import ( + load_thread_relative_time, + load_thread_sort_order, + ) + + if sort_by is None: + raw = load_thread_sort_order() + sort_by = "created" if raw == "created_at" else "updated" + if relative is None: + relative = load_thread_relative_time() + + fmt_ts = format_relative_timestamp if relative else format_timestamp + + limit = get_thread_limit() if limit is None else max(1, limit) + + threads = await list_threads( + agent_name, + limit=limit, + include_message_count=True, + sort_by=sort_by, + branch=branch, + cwd=cwd, + ) + + if verbose and threads: + await populate_thread_checkpoint_details( + threads, include_message_count=False, include_initial_prompt=True + ) + + if output_format == "json": + from deepagents_code.output import write_json + + write_json("threads list", list(threads)) + return + + from rich.markup import escape as escape_markup + from rich.table import Table + + from deepagents_code import theme + from deepagents_code.config import console + + if not threads: + filters = [] + if agent_name: + filters.append(f"agent '{escape_markup(agent_name)}'") + if branch: + filters.append(f"branch '{escape_markup(branch)}'") + if cwd: + filters.append(f"cwd '{escape_markup(cwd)}'") + if filters: + console.print( + f"[yellow]No threads found for {' and '.join(filters)}.[/yellow]" + ) + else: + console.print("[yellow]No threads found.[/yellow]") + if cwd: + # Older threads predating cwd-metadata storage are silently + # filtered out by the `json_extract` equality match. Tell the + # user explicitly when unfiltered rows exist so they don't think + # their history is gone. + unfiltered = await list_threads( + agent_name, + limit=limit, + include_message_count=False, + sort_by=sort_by, + branch=branch, + ) + legacy_count = sum(1 for t in unfiltered if not t.get("cwd")) + if legacy_count: + console.print( + f"[dim]{legacy_count} older thread" + f"{'s have' if legacy_count != 1 else ' has'} " + "no cwd metadata; run without --cwd to see " + f"{'them' if legacy_count != 1 else 'it'}.[/dim]" + ) + console.print("[dim]Start a conversation with: deepagents[/dim]") + return + + title_parts = [] + if agent_name: + title_parts.append(f"agent '{escape_markup(agent_name)}'") + if branch: + title_parts.append(f"branch '{escape_markup(branch)}'") + if cwd: + title_parts.append(f"cwd '{escape_markup(cwd)}'") + + title_filter = f" for {' and '.join(title_parts)}" if title_parts else "" + sort_label = "created" if sort_by == "created" else "updated" + title = f"Recent Threads{title_filter} (last {limit}, by {sort_label})" + + table = Table(title=title, show_header=True, header_style=f"bold {theme.PRIMARY}") + table.add_column("Thread ID", style="bold") + table.add_column("Agent") + table.add_column("Messages", justify="right") + if verbose: + table.add_column("Created") + table.add_column("Updated" if sort_by == "updated" else "Last Used") + if verbose: + table.add_column("Branch") + table.add_column("Location") + table.add_column("Prompt", max_width=40, no_wrap=True) + + prompt_max = 40 + + for t in threads: + row: list[str] = [ + t["thread_id"], + t["agent_name"] or "unknown", + str(t.get("message_count", 0)), + ] + if verbose: + row.append(fmt_ts(t.get("created_at"))) + row.append(fmt_ts(t.get("updated_at"))) + if verbose: + prompt = " ".join((t.get("initial_prompt") or "").split()) + if len(prompt) > prompt_max: + prompt = prompt[: prompt_max - 3] + "..." + row.extend( + [ + t.get("git_branch") or "", + format_path(t.get("cwd")), + prompt, + ] + ) + table.add_row(*row) + + console.print() + console.print(table) + if len(threads) >= limit: + console.print( + f"[dim]Showing last {limit} threads. " + "Override with -n/--limit or DA_CLI_RECENT_THREADS.[/dim]" + ) + console.print() + + +async def delete_thread_command( + thread_id: str, + *, + dry_run: bool = False, + output_format: OutputFormat = "text", +) -> None: + """CLI handler for: deepagents threads delete. + + Args: + thread_id: ID of the thread to delete. + dry_run: If `True`, print what would happen without making changes. + output_format: Output format — `'text'` (Rich) or `'json'`. + """ + if dry_run: + exists = await thread_exists(thread_id) + if output_format == "json": + from deepagents_code.output import write_json + + write_json( + "threads delete", + {"thread_id": thread_id, "exists": exists, "dry_run": True}, + ) + return + + from rich.markup import escape as escape_markup + + from deepagents_code.config import console + + escaped_id = escape_markup(thread_id) + if exists: + console.print(f"Would delete thread '{escaped_id}'.") + else: + console.print(f"Thread '{escaped_id}' not found. Nothing to delete.") + console.print("No changes made.", style="dim") + return + + deleted = await delete_thread(thread_id) + + if output_format == "json": + from deepagents_code.output import write_json + + write_json("threads delete", {"thread_id": thread_id, "deleted": deleted}) + return + + from rich.markup import escape as escape_markup + + from deepagents_code import theme + from deepagents_code.config import console + + escaped_id = escape_markup(thread_id) + if deleted: + console.print(f"[green]Thread '{escaped_id}' deleted.[/green]") + else: + console.print( + f"Thread '{escaped_id}' not found or already deleted.", + style=theme.MUTED, + ) diff --git a/libs/code/deepagents_code/skills/__init__.py b/libs/code/deepagents_code/skills/__init__.py new file mode 100644 index 0000000000..7bb3e026fc --- /dev/null +++ b/libs/code/deepagents_code/skills/__init__.py @@ -0,0 +1,18 @@ +"""Skills module for Deep Agents Code. + +Public API: +- execute_skills_command: Execute skills subcommands (list/create/info/delete) +- setup_skills_parser: Setup argparse configuration for skills commands + +All other components are internal implementation details. +""" + +from deepagents_code.skills.commands import ( + execute_skills_command, + setup_skills_parser, +) + +__all__ = [ + "execute_skills_command", + "setup_skills_parser", +] diff --git a/libs/cli/deepagents_cli/skills/commands.py b/libs/code/deepagents_code/skills/commands.py similarity index 81% rename from libs/cli/deepagents_cli/skills/commands.py rename to libs/code/deepagents_code/skills/commands.py index 64b1c553ff..575842ab05 100644 --- a/libs/cli/deepagents_cli/skills/commands.py +++ b/libs/code/deepagents_code/skills/commands.py @@ -1,27 +1,20 @@ -"""CLI commands for skill management. - -These commands are registered with the CLI via main.py: -- deepagents skills list [options] -- deepagents skills create [options] -- deepagents skills info [options] -- deepagents skills delete [options] -""" +"""CLI commands for skill management.""" from __future__ import annotations import argparse import shutil from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, assert_never if TYPE_CHECKING: from collections.abc import Callable from deepagents.middleware.skills import SkillMetadata - from deepagents_cli.output import OutputFormat + from deepagents_code.output import OutputFormat -from deepagents_cli import theme +from deepagents_code import theme MAX_SKILL_NAME_LENGTH = 64 @@ -56,8 +49,8 @@ def _validate_name(name: str) -> tuple[bool, str]: if len(name) > MAX_SKILL_NAME_LENGTH: return False, "cannot exceed 64 characters" - # Check for path traversal sequences (CLI-specific; the SDK validates - # against the directory name instead, but the CLI accepts user input + # Check for path traversal sequences (dcode-specific; the SDK validates + # against the directory name instead, but dcode accepts user input # directly so we need explicit path-safety checks) if ".." in name or "/" in name or "\\" in name: return False, "cannot contain path components" @@ -153,8 +146,8 @@ def _list( """ from rich.markup import escape as escape_markup - from deepagents_cli.config import Settings, console, get_glyphs - from deepagents_cli.skills.load import list_skills + from deepagents_code.config import Settings, console, get_glyphs + from deepagents_code.skills.load import list_skills settings = Settings.from_environment() user_skills_dir = settings.get_user_skills_dir(agent) @@ -166,7 +159,7 @@ def _list( if project: if not project_skills_dir: if output_format == "json": - from deepagents_cli.output import write_json + from deepagents_code.output import write_json write_json("skills list", []) return @@ -190,7 +183,7 @@ def _list( if not has_deepagents_skills and not has_agent_skills: if output_format == "json": - from deepagents_cli.output import write_json + from deepagents_code.output import write_json write_json("skills list", []) return @@ -202,7 +195,7 @@ def _list( ) console.print( "\n[dim]Create a project skill:\n" - " deepagents skills create my-skill --project[/dim]", + " dcode skills create my-skill --project[/dim]", style=theme.MUTED, ) return @@ -215,7 +208,7 @@ def _list( ) if output_format == "json": - from deepagents_cli.output import write_json + from deepagents_code.output import write_json write_json("skills list", [dict(s) for s in skills]) return @@ -232,7 +225,7 @@ def _list( ) if output_format == "json": - from deepagents_cli.output import write_json + from deepagents_code.output import write_json write_json("skills list", [dict(s) for s in skills]) return @@ -252,8 +245,7 @@ def _list( style=theme.MUTED, ) console.print( - "\n[dim]Create your first skill:\n" - " deepagents skills create my-skill[/dim]", + "\n[dim]Create your first skill:\n dcode skills create my-skill[/dim]", style=theme.MUTED, ) return @@ -353,7 +345,7 @@ def _generate_template(skill_name: str) -> str: # (Warning: SKILL.md files exceeding 10 MB are silently skipped at load time.) # Optional fields per Agent Skills spec: # license: Apache-2.0 -# compatibility: Designed for Deep Agents CLI +# compatibility: Designed for Deep Agents Code # metadata: # author: your-org # version: "1.0" @@ -415,7 +407,7 @@ def _create( Raises: SystemExit: If the skill name is invalid or the directory cannot be created. """ - from deepagents_cli.config import Settings, console, get_glyphs + from deepagents_code.config import Settings, console, get_glyphs # Validate skill name first (per Agent Skills spec) is_valid, error_msg = _validate_name(skill_name) @@ -459,7 +451,7 @@ def _create( if skill_dir.exists(): if output_format == "json": - from deepagents_cli.output import write_json + from deepagents_code.output import write_json write_json( "skills create", @@ -485,7 +477,7 @@ def _create( skill_md.write_text(template) if output_format == "json": - from deepagents_cli.output import write_json + from deepagents_code.output import write_json write_json( "skills create", @@ -511,7 +503,7 @@ def _create( "\n" f" nano {skill_md}\n" "\n" - " See examples/skills/ in the deepagents-cli repo for example skills:\n" + " See examples/skills/ in the deepagents-code repo for example skills:\n" " - web-research: Structured research workflow\n" " - langgraph-docs: LangGraph documentation lookup\n" "\n" @@ -542,8 +534,8 @@ def _info( """ from rich.markup import escape as escape_markup - from deepagents_cli.config import Settings, console - from deepagents_cli.skills.load import list_skills + from deepagents_code.config import Settings, console + from deepagents_code.skills.load import list_skills settings = Settings.from_environment() user_skills_dir = settings.get_user_skills_dir(agent) @@ -582,7 +574,7 @@ def _info( raise SystemExit(1) if output_format == "json": - from deepagents_cli.output import write_json + from deepagents_code.output import write_json write_json("skills info", dict(skill)) return @@ -688,8 +680,8 @@ def _delete( """ from rich.markup import escape as escape_markup - from deepagents_cli.config import Settings, console, get_glyphs - from deepagents_cli.skills.load import list_skills + from deepagents_code.config import Settings, console, get_glyphs + from deepagents_code.skills.load import list_skills # Validate skill name first (per Agent Skills spec) is_valid, error_msg = _validate_name(skill_name) @@ -751,7 +743,7 @@ def _delete( if dry_run: if output_format == "json": - from deepagents_cli.output import write_json + from deepagents_code.output import write_json write_json( "skills delete", @@ -842,7 +834,7 @@ def _delete( raise SystemExit(1) from e if output_format == "json": - from deepagents_cli.output import write_json + from deepagents_code.output import write_json write_json( "skills delete", @@ -861,6 +853,131 @@ def _delete( ) +def _trust(args: argparse.Namespace) -> None: + """Handle `skills trust list|revoke|clear`. + + Args: + args: Parsed arguments with a `trust_command` attribute. + + Raises: + SystemExit: If the trust store cannot be read, or trust entries cannot + be revoked or cleared. + """ + from rich.markup import escape + + from deepagents_code.config import console, get_glyphs + from deepagents_code.skills.trust import ( + RevokeResult, + clear_trusted_skill_dirs, + list_trusted_skill_dir_entries, + revoke_skill_dir_trust, + ) + + command = getattr(args, "trust_command", None) + output_format = getattr(args, "output_format", "text") + checkmark = get_glyphs().checkmark + + if command in {"list", "ls"}: + # Read strictly so an unreadable store surfaces as an error instead of + # falsely reporting "No trusted skill directories" — the whole point of + # the audit command is to show what is trusted so it can be revoked. + try: + entries = list_trusted_skill_dir_entries(strict=True) + except (OSError, ValueError) as exc: + console.print( + f"[bold red]Error:[/bold red] Could not read the skill trust " + f"store: {escape(str(exc))}" + ) + raise SystemExit(1) from exc + if output_format == "json": + from deepagents_code.output import write_json + + write_json( + "skills trust list", + [ + {"dir": path, "trusted_at": trusted_at} + for path, trusted_at in entries + ], + ) + return + if not entries: + console.print() + console.print("[yellow]No trusted skill directories.[/yellow]") + console.print( + "[dim]Directories are trusted when you approve a skill that " + "resolves outside the standard skill roots.[/dim]", + style=theme.MUTED, + ) + console.print() + return + console.print( + "\n[bold]Trusted skill directories:[/bold]\n", style=theme.PRIMARY + ) + for path, trusted_at in entries: + console.print(f" {escape(str(path))}") + if trusted_at: + console.print( + f" [dim]trusted {escape(trusted_at)}[/dim]", style=theme.MUTED + ) + console.print() + elif command == "revoke": + target = args.dir + result = revoke_skill_dir_trust(target) + # An I/O/read failure is a hard error regardless of output format + # (matching `list`): print red and exit non-zero without emitting a + # success envelope a script might misread. + if result is RevokeResult.ERROR: + console.print( + "[bold red]Error:[/bold red] Could not revoke trust for: " + f"{escape(str(target))}" + ) + raise SystemExit(1) + if output_format == "json": + from deepagents_code.output import write_json + + write_json( + "skills trust revoke", + {"dir": str(target), "result": result.value}, + ) + return + # `ERROR` was handled above (early exit), so only `REMOVED`/`NOT_FOUND` + # remain. Match exhaustively with `assert_never` so adding a future + # `RevokeResult` member is a static error here rather than a silent + # success that prints nothing yet exits 0. + match result: + case RevokeResult.REMOVED: + console.print( + f"{checkmark} Revoked trust for: {escape(str(target))}", + style=theme.PRIMARY, + ) + case RevokeResult.NOT_FOUND: + # Report honestly, not a false success. + console.print( + f"[yellow]No trust entry found for:[/yellow] {escape(str(target))}" + ) + case _: # pragma: no cover - exhaustiveness guard + assert_never(result) + elif command == "clear": + if not clear_trusted_skill_dirs(): + console.print( + "[bold red]Error:[/bold red] Could not clear trusted directories." + ) + raise SystemExit(1) + if output_format == "json": + from deepagents_code.output import write_json + + write_json("skills trust clear", {"cleared": True}) + return + console.print( + f"{checkmark} Cleared all trusted skill directories.", + style=theme.PRIMARY, + ) + else: + from deepagents_code.ui import show_skills_trust_help + + show_skills_trust_help() + + def setup_skills_parser( subparsers: Any, # noqa: ANN401 # argparse subparsers uses dynamic typing *, @@ -886,7 +1003,7 @@ def setup_skills_parser( # Lazy wrapper: defers ui import until the help action fires. def _lazy_help(fn_name: str) -> Callable[[], None]: def _show() -> None: - from deepagents_cli import ui + from deepagents_code import ui getattr(ui, fn_name)() @@ -1020,6 +1137,45 @@ def help_parent(help_fn: Callable[[], None]) -> list[argparse.ArgumentParser]: action="store_true", help="Show what would happen without making changes", ) + + # Skills trust — manage directories approved to be read outside the + # standard skill roots (the persistent counterpart to the in-TUI prompt). + trust_parser = skills_subparsers.add_parser( + "trust", + help="Manage trusted skill directories", + description=( + "List, revoke, or clear skill directories that have been trusted " + "to be read even though they resolve outside the standard skill " + "roots (for example, symlink targets approved at invocation time)." + ), + add_help=False, + parents=help_parent(_lazy_help("show_skills_trust_help")), + ) + if add_output_args is not None: + add_output_args(trust_parser) + trust_subparsers = trust_parser.add_subparsers( + dest="trust_command", help="Trust command" + ) + trust_list_parser = trust_subparsers.add_parser( + "list", + aliases=["ls"], + help="List trusted skill directories", + ) + if add_output_args is not None: + add_output_args(trust_list_parser) + revoke_parser = trust_subparsers.add_parser( + "revoke", + help="Revoke trust for a directory", + ) + revoke_parser.add_argument("dir", help="Directory path to revoke") + if add_output_args is not None: + add_output_args(revoke_parser) + clear_parser = trust_subparsers.add_parser( + "clear", + help="Remove all trusted skill directories", + ) + if add_output_args is not None: + add_output_args(clear_parser) return skills_parser @@ -1032,10 +1188,16 @@ def execute_skills_command(args: argparse.Namespace) -> None: Raises: SystemExit: If the agent name is invalid. """ - from deepagents_cli.config import console + from deepagents_code.config import console + + # The `trust` subcommand manages directory paths, not agent-scoped skills, + # so it has no `--agent` and is dispatched before agent validation. + if args.skills_command == "trust": + _trust(args) + return # validate agent argument - if args.agent: + if getattr(args, "agent", None): is_valid, error_msg = _validate_name(args.agent) if not is_valid: console.print( @@ -1079,7 +1241,7 @@ def execute_skills_command(args: argparse.Namespace) -> None: ) else: # No subcommand provided, show skills help screen - from deepagents_cli.ui import show_skills_help + from deepagents_code.ui import show_skills_help show_skills_help() diff --git a/libs/code/deepagents_code/skills/invocation.py b/libs/code/deepagents_code/skills/invocation.py new file mode 100644 index 0000000000..784b7d5a40 --- /dev/null +++ b/libs/code/deepagents_code/skills/invocation.py @@ -0,0 +1,120 @@ +"""Helpers for loading and formatting skill invocations.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pathlib import Path + + from deepagents_code.skills.load import ExtendedSkillMetadata + + +@dataclass(frozen=True) +class SkillInvocationEnvelope: + """Structured prompt and checkpoint metadata for a skill invocation. + + Attributes: + prompt: Composed prompt that wraps `SKILL.md` content with + invocation instructions. + message_kwargs: Extra fields merged into the initial HumanMessage. + """ + + prompt: str + message_kwargs: dict[str, Any] + + +def discover_skills_and_roots( + assistant_id: str, + *, + plugin_skill_sources: tuple[tuple[Path, str], ...] = (), + plugin_skill_roots: tuple[Path, ...] = (), +) -> tuple[list[ExtendedSkillMetadata], list[Path]]: + """Discover skills and build pre-resolved containment roots. + + Args: + assistant_id: Agent identifier used to resolve user skill directories. + plugin_skill_sources: Plugin-owned skill directories and namespaces, + supplied by the plugin composition layer. + plugin_skill_roots: Plugin-owned roots allowed for content loading. + + Returns: + Tuple of `(skill metadata list, pre-resolved containment roots)`. + """ + from deepagents_code.config import settings + from deepagents_code.skills.load import list_skills + from deepagents_code.skills.trust import load_trusted_skill_dirs + + skills = list_skills( + built_in_skills_dir=settings.get_built_in_skills_dir(), + plugin_skill_sources=plugin_skill_sources, + user_skills_dir=settings.get_user_skills_dir(assistant_id), + project_skills_dir=settings.get_project_skills_dir(), + user_agent_skills_dir=settings.get_user_agent_skills_dir(), + project_agent_skills_dir=settings.get_project_agent_skills_dir(), + user_claude_skills_dir=settings.get_user_claude_skills_dir(), + project_claude_skills_dir=settings.get_project_claude_skills_dir(), + ) + roots = [ + path.resolve() + for path in ( + settings.get_built_in_skills_dir(), + *plugin_skill_roots, + settings.get_user_skills_dir(assistant_id), + settings.get_project_skills_dir(), + settings.get_user_agent_skills_dir(), + settings.get_project_agent_skills_dir(), + settings.get_user_claude_skills_dir(), + settings.get_project_claude_skills_dir(), + ) + if path is not None + ] + roots.extend(path.resolve() for path in settings.get_extra_skills_dirs()) + # Persisted in-the-moment approvals extend the containment allowlist just + # like the declarative `extra_allowed_dirs`, but are managed by the trust + # store rather than hand-edited config. These entries are already the + # canonical approved directories and are verified against post-approval + # symlink swaps by `load_trusted_skill_dirs`, so they are added as-is + # rather than re-resolved (re-resolving would follow an injected symlink to + # a directory the user never approved). + roots.extend(load_trusted_skill_dirs()) + return skills, roots + + +def build_skill_invocation_envelope( + skill: ExtendedSkillMetadata, + content: str, + args: str = "", +) -> SkillInvocationEnvelope: + """Build the wrapped prompt and persisted metadata for a skill. + + Args: + skill: Loaded skill metadata. + content: Raw `SKILL.md` content. + args: Optional user request appended after the skill body. + + Returns: + A `SkillInvocationEnvelope` with the composed prompt and + `message_kwargs` containing persisted skill metadata. + """ + prompt = ( + f"I'm invoking the skill `{skill['name']}`. " + "Below are the full instructions from the skill's SKILL.md file. " + "Follow these instructions to complete the task.\n\n" + f"---\n{content}\n---" + ) + if args: + prompt += f"\n\n**User request:** {args}" + + message_kwargs = { + "additional_kwargs": { + "__skill": { + "name": skill["name"], + "description": str(skill.get("description", "")), + "source": str(skill.get("source", "")), + "args": args, + }, + }, + } + return SkillInvocationEnvelope(prompt=prompt, message_kwargs=message_kwargs) diff --git a/libs/code/deepagents_code/skills/load.py b/libs/code/deepagents_code/skills/load.py new file mode 100644 index 0000000000..c963fb0913 --- /dev/null +++ b/libs/code/deepagents_code/skills/load.py @@ -0,0 +1,222 @@ +"""Skill loader for CLI commands. + +This module provides filesystem-based skill discovery for CLI operations +(list, create, info, delete). It wraps the prebuilt middleware functionality from +deepagents.middleware.skills and adapts it for direct filesystem access +needed by CLI commands. + +For middleware usage within agents, use +deepagents.middleware.skills.SkillsMiddleware directly. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Literal, cast + +from deepagents.backends.filesystem import FilesystemBackend + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path +from deepagents.middleware.skills import ( + SkillMetadata, + _list_skills as list_skills_from_backend, # noqa: PLC2701 # Intentional access to internal skill listing +) + +from deepagents_code._version import __version__ as _cli_version +from deepagents_code.skills.merge import merge_skill + +logger = logging.getLogger(__name__) + + +class ExtendedSkillMetadata(SkillMetadata): + """Extended skill metadata for CLI display, adds source tracking. + + Attributes: + source: Origin of the skill. One of `'built-in'`, `'user'`, `'project'`, + or `'claude (experimental)'`. + """ + + source: Literal["built-in", "plugin", "user", "project", "claude (experimental)"] + + +# Re-export for CLI commands +__all__ = ["SkillMetadata", "list_skills", "load_skill_content"] + + +def list_skills( + *, + built_in_skills_dir: Path | None = None, + plugin_skill_sources: Sequence[tuple[Path, str]] = (), + user_skills_dir: Path | None = None, + project_skills_dir: Path | None = None, + user_agent_skills_dir: Path | None = None, + project_agent_skills_dir: Path | None = None, + user_claude_skills_dir: Path | None = None, + project_claude_skills_dir: Path | None = None, +) -> list[ExtendedSkillMetadata]: + """List skills from built-in, user, and/or project directories. + + This is a dcode-specific wrapper around the prebuilt middleware's skill loading + functionality. It uses `FilesystemBackend` to load skills from local directories. + + Precedence order (lowest to highest): + 0. `built_in_skills_dir` (`/built_in_skills/`) + 1. `plugin_skill_sources` + 2. `user_skills_dir` (`~/.deepagents/{agent}/skills/`) + 3. `user_agent_skills_dir` (`~/.agents/skills/`) + 4. `project_skills_dir` (`.deepagents/skills/`) + 5. `project_agent_skills_dir` (`.agents/skills/`) + 6. `user_claude_skills_dir` (`~/.claude/skills/`, experimental) + 7. `project_claude_skills_dir` (`.claude/skills/`, experimental) + + Skills from higher-precedence directories override those with the same name. + + Args: + built_in_skills_dir: Path to built-in skills shipped with the package. + plugin_skill_sources: Plugin skill source directories with namespaces. + user_skills_dir: Path to `~/.deepagents/{agent}/skills/`. + project_skills_dir: Path to `.deepagents/skills/`. + user_agent_skills_dir: Path to `~/.agents/skills/` (alias). + project_agent_skills_dir: Path to `.agents/skills/` (alias). + user_claude_skills_dir: Path to `~/.claude/skills/` (experimental). + project_claude_skills_dir: Path to `.claude/skills/` (experimental). + + Returns: + Merged list of skill metadata from all sources, with higher-precedence + directories taking priority when names conflict. + """ + all_skills: dict[str, ExtendedSkillMetadata] = {} + merged_source_labels: dict[str, str | None] = {} + + sources: list[tuple[Path | None, str, bool, str]] = [ + (built_in_skills_dir, "built-in", False, ""), + *[ + (path, "plugin", False, namespace) + for path, namespace in plugin_skill_sources + ], + (user_skills_dir, "user", False, ""), + (user_agent_skills_dir, "user", False, ""), + (project_skills_dir, "project", False, ""), + (project_agent_skills_dir, "project", False, ""), + (user_claude_skills_dir, "claude (experimental)", True, ""), + (project_claude_skills_dir, "claude (experimental)", True, ""), + ] + """Sources in precedence order (lowest to highest). + + Each tuple: `(directory, source label, is_experimental, namespace)`. + + Each source is individually try/except-guarded so a single inaccessible + directory doesn't block the rest. + """ + + for skill_dir, source_label, experimental, namespace in sources: + if not skill_dir or not skill_dir.exists(): + continue + try: + backend = FilesystemBackend(root_dir=str(skill_dir), virtual_mode=False) + if namespace: + # Plugin sources are walked recursively so nested skill + # directories are namespaced as `plugin:sub:skill`, matching + # both the runtime middleware and plugin conventions. + from deepagents_code.plugins.adapters.skills_middleware import ( + load_namespaced_skills, + ) + + skills = load_namespaced_skills( + backend, str(skill_dir.resolve()), namespace + ) + else: + skills = list_skills_from_backend(backend=backend, source_path=".") + if experimental and skills: + logger.info( + "Discovered %d skill(s) from experimental Claude path: %s", + len(skills), + skill_dir, + ) + for skill in skills: + extra: dict[str, object] = {"source": source_label} + if source_label == "built-in": + extra["metadata"] = { + **skill["metadata"], + "deepagents-code-version": _cli_version, + } + extended = cast("ExtendedSkillMetadata", {**skill, **extra}) + merge_skill( + all_skills, + merged_source_labels, + extended, + source_label=source_label, + ) + except Exception: + # Degrade gracefully — one malformed/inaccessible source must not + # block discovery of others, so catch broadly and log instead. + # WARNING (not ERROR) because a half-written SKILL.md from a user is + # an expected condition, not a code defect. + logger.warning( + "Could not load skills from %s", + skill_dir, + exc_info=True, + ) + + return list(all_skills.values()) + + +def load_skill_content( + skill_path: str, + *, + allowed_roots: Sequence[Path] = (), +) -> str | None: + """Read the full raw SKILL.md content for a skill. + + Returns the complete file content including any YAML frontmatter. + Callers are responsible for parsing or stripping frontmatter if needed. + + When `allowed_roots` is provided, the resolved path must fall within at + least one root directory. This prevents symlink traversal from reading files + outside known skill directories. + + Args: + skill_path: Path to the SKILL.md file (from `SkillMetadata['path']`). + allowed_roots: Skill root directories the resolved path must be + contained within. + + Callers must pre-resolve these via `Path.resolve()` — the resolved + skill path is compared directly, so un-resolved roots cause false + containment failures. + + If empty, containment is not checked. + + Returns: + Full text content of the SKILL.md file, or `None` on read failure. + + Raises: + PermissionError: If the resolved path is outside all `allowed_roots`. + """ + from pathlib import Path + + path = Path(skill_path).resolve() + + if allowed_roots and not any(path.is_relative_to(root) for root in allowed_roots): + logger.warning( + "Skill path %s is outside all allowed roots, refusing to read", + skill_path, + ) + from deepagents_code._env_vars import EXTRA_SKILLS_DIRS + + msg = ( + f"Skill path {skill_path} resolves outside all allowed skill " + "directories. If this is a symlink, add the target directory to " + f"{EXTRA_SKILLS_DIRS} or [skills].extra_allowed_dirs " + "in ~/.deepagents/config.toml." + ) + raise PermissionError(msg) + + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + logger.warning( + "Could not read skill content from %s", skill_path, exc_info=True + ) + return None diff --git a/libs/code/deepagents_code/skills/merge.py b/libs/code/deepagents_code/skills/merge.py new file mode 100644 index 0000000000..d60f8ea489 --- /dev/null +++ b/libs/code/deepagents_code/skills/merge.py @@ -0,0 +1,66 @@ +"""Shared skill-merge helper with override (name-collision) debug logging. + +Both skill discovery paths — the CLI `skills list` loader +(`deepagents_code.skills.load`) and the runtime agent loader +(`deepagents_code.plugins.adapters.skills_middleware.PluginSkillsMiddleware`) — +merge skills from multiple sources by precedence, last-one-wins, keyed on skill +name. A higher-precedence skill replaces a lower-precedence skill with the same +name. That override behavior is intentional; this helper leaves it unchanged and +makes each replacement observable in debug logs. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + from collections.abc import Mapping, MutableMapping + +logger = logging.getLogger(__name__) + +_SkillT = TypeVar("_SkillT", bound="Mapping[str, object]") + + +def merge_skill( + merged: MutableMapping[str, _SkillT], + source_labels: MutableMapping[str, str | None], + skill: _SkillT, + *, + source_label: str | None = None, +) -> None: + """Merge one skill into `merged` by name, last-one-wins. + + Emits one `DEBUG` log whenever a skill replaces an already-merged skill with + the same name, recording the skill name plus the previous and replacement + source paths and labels so the winning definition is unambiguous. Nothing is + logged when there is no collision. + + Callers must iterate sources in ascending precedence order so the replacing + skill is always the higher-precedence one. + + Args: + merged: Accumulator mapping skill name to merged metadata; mutated in + place. + source_labels: Parallel accumulator mapping skill name to the label of + the source that last supplied it; mutated in place so the previous + label is available on the next collision. + skill: Skill metadata to merge. Must expose `name`; `path`, when present, + is included in the override log to identify the colliding files. + source_label: Human-readable label for the source supplying `skill`, + when known. A missing or empty label renders as `"unknown"` in the + log. + """ + name = str(skill["name"]) + previous = merged.get(name) + if previous is not None: + logger.debug( + "Skill %r override: %s (source: %s) replaced by %s (source: %s)", + name, + previous.get("path"), + source_labels.get(name) or "unknown", + skill.get("path"), + source_label or "unknown", + ) + merged[name] = skill + source_labels[name] = source_label diff --git a/libs/code/deepagents_code/skills/trust.py b/libs/code/deepagents_code/skills/trust.py new file mode 100644 index 0000000000..a2cab7b094 --- /dev/null +++ b/libs/code/deepagents_code/skills/trust.py @@ -0,0 +1,546 @@ +"""Trust store for skill directories that resolve outside trusted roots. + +`load_skill_content` refuses to read a `SKILL.md` whose resolved path falls +outside every trusted skill root — this stops a symlink inside a skill +directory from reading arbitrary files. The static escape hatch is the +`DEEPAGENTS_CODE_EXTRA_SKILLS_DIRS` env var / `[skills].extra_allowed_dirs` +config allowlist. + +This module adds an in-the-moment, persistent approval path: when a skill +resolves outside the trusted roots, the user is asked once to allow the +resolved target directory, and the decision is remembered. Trust is keyed by +the approved target directory — the canonical path resolved and shown to the +user at approval time, stored as-is and never re-resolved. + +Two distinct post-approval swaps are caught by two distinct layers, so neither +grants access the user never approved: + +* Re-pointing the *discovery* symlink (the `SKILL.md` path) at a new target is + caught by containment enforcement in `load_skill_content`: the new target is + not on the allowlist, so the read is refused and the user is re-prompted. + The stored trust entry — the original resolved target — is untouched. +* Replacing the *stored* directory itself (or one of its parents) with a symlink + is caught by the `resolve()`-to-self re-verification in + `load_trusted_skill_dirs`, which drops the stale entry rather than following + the injected symlink to a directory the user never approved. + +Trust entries are app-managed bookkeeping (a set of approved directories), not +user-facing configuration, so they live alongside the other state files under +`~/.deepagents/.state/skill_trust.json` rather than in the hand-editable +`config.toml`. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import os +import tempfile +from datetime import UTC, datetime +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING, Any, TypedDict + +if TYPE_CHECKING: + from collections.abc import Mapping + +logger = logging.getLogger(__name__) + +_STORAGE_VERSION = 1 +"""Schema version stamped into `skill_trust.json`; bump on incompatible changes.""" + + +class _TrustEntry(TypedDict): + """One trusted-directory record in the store's `dirs` map.""" + + trusted_at: str + """ISO-8601 UTC timestamp of when the directory was approved.""" + + +class _TrustStore(TypedDict): + """On-disk shape of `skill_trust.json`.""" + + version: int + """Schema version written to the store file.""" + + dirs: dict[str, _TrustEntry] + """Trusted directories keyed by their approved absolute path.""" + + +class RevokeResult(Enum): + """Outcome of a `revoke_skill_dir_trust` call. + + Distinguishing `NOT_FOUND` from `REMOVED` lets the CLI print an honest + message instead of a false success when the target was never trusted (a + plain bool collapsed the two). + """ + + REMOVED = "removed" + """An entry existed and was removed from the store.""" + + NOT_FOUND = "not_found" + """No matching entry existed; the store was left unchanged.""" + + ERROR = "error" + """The store could not be read or the removal could not be persisted.""" + + +def _default_store_path() -> Path: + """Return `~/.deepagents/.state/skill_trust.json`. + + Resolved at call time (not import time) so tests can redirect storage by + monkeypatching `deepagents_code.model_config.DEFAULT_STATE_DIR` — the same + pattern `auth_store.auth_path` uses. + """ + from deepagents_code.model_config import DEFAULT_STATE_DIR + + return DEFAULT_STATE_DIR / "skill_trust.json" + + +def _normalize(target_dir: Path | str) -> str: + """Return the resolved absolute string form of a directory key.""" + return str(Path(target_dir).expanduser().resolve()) + + +def _approved_key(target_dir: Path | str) -> str: + """Return the already-approved directory key without resolving again.""" + return str(Path(target_dir).expanduser()) + + +def _load_store(store_path: Path, *, strict: bool = False) -> dict[str, Any]: + """Read the JSON trust store file. + + Args: + store_path: Path to the trust store file. + strict: When `True`, a store that exists but cannot be read or parsed + re-raises instead of degrading to `{}`. Read/modify/write callers + pass `strict=True` so a transient read error aborts the write + rather than silently rebuilding the store from an empty dict (which + would clobber every prior approval). The audit path passes it too so + it can report an unreadable store instead of claiming nothing is + trusted. Enforcement callers leave it `False` to stay fail-closed. + + Returns: + Parsed JSON data, or an empty dict when the file is missing, or (only + when `strict` is `False`) when it is unreadable or corrupt. + A corrupt store degrades to "nothing trusted" so a bad file can't + crash startup. It is *not* self-healed on the next approval: + ordinary writes read with `strict=True` and refuse rather than + clobber a store they can't parse, so recovery from a corrupt file + requires `skills trust clear` (or `clear_trusted_skill_dirs`, + the only writer that overwrites blindly). + + Raises: + OSError: When `strict` and an existing store cannot be read. + json.JSONDecodeError: When `strict` and an existing store is not valid + JSON. + ValueError: When `strict` and the store's top-level value is not a JSON + object, or its `version` is unrecognized (non-integer, or newer than + this build understands). + """ + # A missing store is a normal first-run state, never an error — return + # empty even under `strict` so callers don't have to special-case it. + if not store_path.exists(): + return {} + try: + data = json.loads(store_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + if strict: + raise + # A corrupt store silently drops every prior approval and forces a + # re-prompt, so log at WARNING (not DEBUG) to leave a breadcrumb for + # the otherwise-unexplained re-prompt. + logger.warning( + "Skill trust store %s is corrupt; treating as empty: %s", store_path, exc + ) + return {} + except OSError as exc: + if strict: + raise + logger.warning( + "Could not read skill trust store %s; treating as empty: %s", + store_path, + exc, + ) + return {} + if not isinstance(data, dict): + if strict: + msg = f"Skill trust store {store_path} is not a JSON object" + raise ValueError(msg) + logger.warning( + "Skill trust store %s is not a JSON object; ignoring", store_path + ) + return {} + # A store written by a newer build may carry an incompatible schema. Reading + # its `dirs` regardless could misinterpret entries, so refuse: fail-closed + # (treat as nothing trusted) for enforcement, and surface the error for the + # audit path. A present-but-non-integer `version` is unrecognized in the same + # way (only tampering or a corrupt write produces it, since every writer + # stamps an int), so it is refused too rather than falling through and + # trusting `dirs`. A missing `version` stays tolerated: an empty `{}` file + # has no `dirs` to trust anyway. Together this makes the `_STORAGE_VERSION` + # "bump on incompatible changes" contract enforceable rather than + # aspirational. + version = data.get("version") + if version is not None and ( + not isinstance(version, int) or version > _STORAGE_VERSION + ): + if strict: + msg = ( + f"Skill trust store {store_path} has an unrecognized schema " + f"version {version!r} (this build understands <= {_STORAGE_VERSION}); " + f"refusing to read it" + ) + raise ValueError(msg) + logger.warning( + "Skill trust store %s has an unrecognized schema version %r " + "(this build understands <= %s); treating as empty", + store_path, + version, + _STORAGE_VERSION, + ) + return {} + return data + + +def _save_store(data: Mapping[str, Any], store_path: Path) -> bool: + """Atomic write of JSON trust data to `store_path`. + + Uses `tempfile.mkstemp` + `Path.replace` for crash safety. + + Args: + data: Full store dict to write. + store_path: Destination path. + + Returns: + `True` on success, `False` on I/O failure. + """ + try: + store_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(dir=store_path.parent, suffix=".tmp") + # Wrap the raw fd in a file object in its own stage: if `os.fdopen` + # raises, it did not take ownership, so the fd is still open and must be + # closed explicitly (otherwise it leaks). Only close it here — once + # `fdopen` succeeds the `with` below owns and closes it exactly once, and + # a bare `os.close(fd)` in the outer handler could race a recycled fd + # (this runs under `asyncio.to_thread`). + try: + handle = os.fdopen(fd, "w", encoding="utf-8") + except BaseException: + with contextlib.suppress(OSError): + os.close(fd) + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + try: + with handle as f: + json.dump(data, f, indent=2) + Path(tmp_path).replace(store_path) + except BaseException: + with contextlib.suppress(OSError): + Path(tmp_path).unlink() + raise + except (OSError, ValueError): + logger.exception("Failed to save skill trust store to %s", store_path) + return False + return True + + +def _read_dirs(store_path: Path, *, strict: bool = False) -> dict[str, Any]: + """Return the `dirs` mapping from the store, or an empty dict. + + Args: + store_path: Path to the trust store file. + strict: Propagated to `_load_store`; see its docstring. + """ + dirs = _load_store(store_path, strict=strict).get("dirs", {}) + return dirs if isinstance(dirs, dict) else {} + + +def is_skill_dir_trusted( + target_dir: Path | str, + *, + store_path: Path | None = None, +) -> bool: + """Check whether a resolved skill directory has been trusted. + + Warning: + This resolves `target_dir` and checks raw membership; it does NOT do + the `resolve()`-to-self re-verification that `load_trusted_skill_dirs` + performs on each stored entry. It is therefore **not** the + containment-enforcement primitive — enforcement builds the allowlist + from `load_trusted_skill_dirs`, which drops post-approval symlink swaps. + Use this only for informational "is this exact resolved dir on record?" + checks. + + Note that this check happens to fail *closed*, not open: because it + resolves the query `target_dir`, a stored directory later swapped for a + symlink is reported **not** trusted (the query resolves to the swap + target, which is not the stored key), forcing a re-prompt — the safe + direction. It is excluded from enforcement for being an exact-membership + test that skips the resolve-to-self recheck, not because it could grant + access the user never approved. + + The lookup resolves `target_dir` (via `_normalize`), but `trust_skill_dir` + stores the expanduser-only `_approved_key`. In the live flow the two + coincide because callers approve an already-resolved path, so the keys + are identical. A caller that trusted a *non-canonical* path would see a + false negative here (the only failure direction, and the safe one). Pass + an already-resolved directory to keep the check meaningful. + + Args: + target_dir: Directory to check; resolved before lookup. + store_path: Path to the trust store file. Defaults to + `~/.deepagents/.state/skill_trust.json`. + + Returns: + `True` if the resolved directory is present in the store. + """ + if store_path is None: + store_path = _default_store_path() + return _normalize(target_dir) in _read_dirs(store_path) + + +def trust_skill_dir( + target_dir: Path | str, + *, + store_path: Path | None = None, +) -> bool: + """Persist trust for a resolved skill directory. + + Args: + target_dir: Canonical directory to trust. This is expected to be the + already-resolved path shown to the user, and is not resolved again + before storing so a post-approval symlink swap cannot change what + gets persisted. + store_path: Path to the trust store file. Defaults to + `~/.deepagents/.state/skill_trust.json`. + + Returns: + `True` if the entry was saved successfully. + """ + if store_path is None: + store_path = _default_store_path() + + # Read strictly: if an existing store can't be read, abort rather than + # rebuild it from `{}` and overwrite (which would drop every prior + # approval). A transient read error should re-prompt next time, not + # silently erase the store. + try: + data = _load_store(store_path, strict=True) + except (OSError, ValueError): + logger.exception( + "Refusing to persist skill trust: could not read existing store %s", + store_path, + ) + return False + # The key is stored expanduser-only and never re-resolved (that is the + # anti-symlink-swap property). That only holds the invariant "the stored key + # is the canonical dir the user approved" if the caller already passed a + # canonical path. If it did not, `load_trusted_skill_dirs` will later drop + # the entry at its resolve()-to-self check and the approval silently never + # persists (re-prompt every session). Warn at the write boundary so that + # caller bug surfaces here instead of as a mysterious never-remembered trust. + key = _approved_key(target_dir) + try: + is_canonical = key == _normalize(target_dir) + except OSError: + # Resolving for the diagnostic failed; skip the warning rather than + # abort the write. The read-time resolve()-to-self check is the actual + # safety net, not this best-effort boundary hint. + is_canonical = True + if not is_canonical: + logger.warning( + "trust_skill_dir called with a non-canonical path %r; the stored " + "entry will be dropped at read time. Pass an already-resolved " + "directory.", + target_dir, + ) + + dirs = data.get("dirs") + if not isinstance(dirs, dict): + dirs = {} + dirs[key] = _TrustEntry(trusted_at=datetime.now(UTC).isoformat()) + return _save_store(_TrustStore(version=_STORAGE_VERSION, dirs=dirs), store_path) + + +def revoke_skill_dir_trust( + target_dir: Path | str, + *, + store_path: Path | None = None, +) -> RevokeResult: + """Remove trust for a skill directory. + + Matches on both the approved (expanduser-only) key form that + `trust_skill_dir` stores and the fully-resolved form, so a caller can + revoke either by the path they see in `skills trust list` or by the + original symlink path. + + Args: + target_dir: Directory to revoke. + store_path: Path to the trust store file. Defaults to + `~/.deepagents/.state/skill_trust.json`. + + Returns: + `RevokeResult.REMOVED` if a matching entry was removed and persisted, + `RevokeResult.NOT_FOUND` if no entry matched (store left unchanged), or + `RevokeResult.ERROR` if the store could not be read or the write failed. + """ + if store_path is None: + store_path = _default_store_path() + + # Read strictly so a transient read error aborts rather than rebuilding + # from `{}` and dropping the other entries on the next save. + try: + data = _load_store(store_path, strict=True) + except (OSError, ValueError): + logger.exception( + "Refusing to revoke skill trust: could not read existing store %s", + store_path, + ) + return RevokeResult.ERROR + dirs = data.get("dirs") + if not isinstance(dirs, dict): + return RevokeResult.NOT_FOUND + keys = {_approved_key(target_dir), _normalize(target_dir)} + removed = False + for key in keys: + if key in dirs: + del dirs[key] + removed = True + if not removed: + return RevokeResult.NOT_FOUND + data["version"] = _STORAGE_VERSION + data["dirs"] = dirs + return RevokeResult.REMOVED if _save_store(data, store_path) else RevokeResult.ERROR + + +def clear_trusted_skill_dirs(*, store_path: Path | None = None) -> bool: + """Remove all trusted skill directories. + + Args: + store_path: Path to the trust store file. Defaults to + `~/.deepagents/.state/skill_trust.json`. + + Returns: + `True` if the store was cleared (or was already empty). + """ + if store_path is None: + store_path = _default_store_path() + + if not store_path.exists(): + return True + return _save_store(_TrustStore(version=_STORAGE_VERSION, dirs={}), store_path) + + +def list_trusted_skill_dirs( + *, + store_path: Path | None = None, + strict: bool = False, +) -> list[str]: + """Return the sorted list of trusted skill directory paths. + + Args: + store_path: Path to the trust store file. Defaults to + `~/.deepagents/.state/skill_trust.json`. + strict: When `True`, an existing-but-unreadable store re-raises instead + of degrading to an empty list. The audit command (`skills trust + list`) passes `strict=True` so it can report an error rather than + falsely printing "No trusted skill directories" while entries the + user cannot then see or revoke sit in an unreadable file. + + Returns: + Sorted absolute directory paths previously trusted. + + When `strict`, an existing-but-unreadable or corrupt store propagates + the underlying error (`OSError` / `json.JSONDecodeError` / `ValueError`) + from `_load_store` instead of returning a list. + """ + if store_path is None: + store_path = _default_store_path() + return sorted(_read_dirs(store_path, strict=strict)) + + +def list_trusted_skill_dir_entries( + *, + store_path: Path | None = None, + strict: bool = False, +) -> list[tuple[str, str]]: + """Return trusted directories paired with their approval timestamps. + + The audit surface for the `trusted_at` metadata that `trust_skill_dir` + records: `list_trusted_skill_dirs` returns only paths (all enforcement + needs), so this is the one reader of the timestamp, used by `skills trust + list` to show *when* each directory was approved. + + Args: + store_path: Path to the trust store file. Defaults to + `~/.deepagents/.state/skill_trust.json`. + strict: Propagated to `_load_store`; see `list_trusted_skill_dirs`. + + Returns: + `(path, trusted_at)` tuples sorted by path. `trusted_at` is the stored + ISO-8601 string, or `""` when a hand-edited entry omitted + or malformed it (the path is still listed so it remains + visible and revocable). + """ + if store_path is None: + store_path = _default_store_path() + entries: list[tuple[str, str]] = [] + for path, entry in _read_dirs(store_path, strict=strict).items(): + trusted_at = entry.get("trusted_at", "") if isinstance(entry, dict) else "" + entries.append((path, trusted_at if isinstance(trusted_at, str) else "")) + return sorted(entries) + + +def load_trusted_skill_dirs(*, store_path: Path | None = None) -> list[Path]: + """Return verified trusted skill directories as canonical `Path` objects. + + Used to extend the containment allowlist passed to `load_skill_content`. + + Stored entries are the exact canonical directory the user approved (already + resolved at trust time). Each entry is re-verified here rather than blindly + re-resolved: if a stored path no longer resolves to itself — because it, or + a parent component, was replaced with a symlink after approval — the current + resolution would point somewhere the user never approved. Such entries are + dropped (and logged) instead of silently allowlisting the swapped target, so + a post-approval symlink swap re-prompts rather than granting access. + + Args: + store_path: Path to the trust store file. Defaults to + `~/.deepagents/.state/skill_trust.json`. + + Returns: + Canonical directory paths that still resolve to themselves; empty when + nothing is trusted. + """ + verified: list[Path] = [] + for entry in list_trusted_skill_dirs(store_path=store_path): + stored = Path(entry) + try: + resolves_to_self = stored.resolve() == stored + except (OSError, RuntimeError): + # A single unresolvable entry (e.g. a symlink cycle introduced under + # the stored path) must not abort discovery of every other skill. + # Drop it like the swap case below. `RuntimeError` is caught + # alongside `OSError` to match the resolve guard in + # `app._prompt_skill_trust_and_retry` (some Python builds surface a + # symlink loop as `RuntimeError`). + logger.warning( + "Trusted skill directory %s could not be resolved; " + "ignoring the trust entry.", + entry, + exc_info=True, + ) + continue + if resolves_to_self: + verified.append(stored) + else: + logger.warning( + "Trusted skill directory %s no longer resolves to itself " + "(a symlink may have been introduced since approval); " + "ignoring the stale trust entry.", + entry, + ) + return verified diff --git a/libs/code/deepagents_code/state_migration.py b/libs/code/deepagents_code/state_migration.py new file mode 100644 index 0000000000..8a49b50a70 --- /dev/null +++ b/libs/code/deepagents_code/state_migration.py @@ -0,0 +1,136 @@ +"""One-time migration of legacy state files into `~/.deepagents/.state/`. + +Earlier versions wrote internal state directly under `~/.deepagents/`, +mixing it with user-facing agent directories (so e.g. `mcp-tokens/` +showed up in `deepagents agents list`). State now lives in a dedicated +`.state/` subdirectory; this module moves any legacy files into place +on startup. + +The migration is best-effort and idempotent: it skips entries whose +destination already exists, logs and continues on per-entry failures, +and never blocks startup on I/O errors. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from deepagents_code.model_config import DEFAULT_CONFIG_DIR, DEFAULT_STATE_DIR +from deepagents_code.onboarding import ONBOARDING_MARKER_FILENAME + +if TYPE_CHECKING: + from collections.abc import Iterable + from pathlib import Path + +logger = logging.getLogger(__name__) + + +_LEGACY_NAMES: tuple[str, ...] = ( + "mcp-tokens", + "sessions.db", + "sessions.db-wal", + "sessions.db-shm", + "latest_version.json", + "update_state.json", + "history.jsonl", + ONBOARDING_MARKER_FILENAME, +) +"""Names directly under `~/.deepagents/` that now live in `.state/`. + +`sessions.db-wal` and `sessions.db-shm` are SQLite sidecar files that may +or may not be present depending on whether the database was opened in WAL +mode and whether a checkpoint had run before shutdown. +""" + + +def _iter_migrations( + config_dir: Path, + state_dir: Path, + names: Iterable[str], +) -> Iterable[tuple[Path, Path]]: + for name in names: + yield config_dir / name, state_dir / name + + +def migrate_legacy_state( + *, + config_dir: Path = DEFAULT_CONFIG_DIR, + state_dir: Path = DEFAULT_STATE_DIR, +) -> None: + """Move legacy state entries from `config_dir` into `state_dir`. + + Idempotent: each entry is skipped when the destination already exists + (the migration ran on a prior invocation) or when the source does not + exist (nothing to move). Errors on individual entries are logged and + swallowed so a single unmovable file does not block the rest. + + Args: + config_dir: Directory holding legacy state. Defaults to + `~/.deepagents/`. + state_dir: Destination directory for state files. Defaults to + `~/.deepagents/.state/`. + """ + try: + if not config_dir.is_dir(): + return + except OSError: + logger.debug( + "Could not stat %s; skipping state migration", + config_dir, + exc_info=True, + ) + return + + pending: list[tuple[Path, Path]] = [] + for src, dst in _iter_migrations(config_dir, state_dir, _LEGACY_NAMES): + try: + src_exists = src.exists() + except OSError: + continue + if not src_exists: + continue + try: + dst_exists = dst.exists() + except OSError: + continue + if dst_exists: + # Both exist — typically an app downgrade after the migration + # already ran once (the older version recreates a fresh file + # at the legacy path) or a manually pre-populated `.state/`. + # Clobbering either copy could lose data, so skip and warn so + # the user can resolve it. + logger.warning( + "Cannot migrate %s -> %s: destination already exists. " + "Inspect both files and either delete the obsolete one " + "or move the legacy file in manually.", + src, + dst, + ) + continue + pending.append((src, dst)) + if not pending: + return + + try: + state_dir.mkdir(parents=True, exist_ok=True) + except OSError: + logger.warning( + "Could not create state directory %s; skipping state migration", + state_dir, + exc_info=True, + ) + return + + for src, dst in pending: + try: + src.rename(dst) + except OSError: + logger.warning( + "Failed to migrate %s -> %s; leaving legacy file in place", + src, + dst, + exc_info=True, + ) + continue + logger.info("Migrated %s -> %s", src, dst) diff --git a/libs/code/deepagents_code/subagents.py b/libs/code/deepagents_code/subagents.py new file mode 100644 index 0000000000..d86e005fd3 --- /dev/null +++ b/libs/code/deepagents_code/subagents.py @@ -0,0 +1,278 @@ +"""Subagent loader for app. + +Loads custom subagent definitions from the filesystem. Subagents are defined +as markdown files with YAML frontmatter in the agents/ directory. + +Directory structure: + .deepagents/agents/{agent_name}/AGENTS.md + +Example file (researcher/AGENTS.md): + --- + name: researcher # optional; defaults to the folder name + description: Research topics on the web before writing content + model: anthropic:claude-haiku-4-5-20251001 + --- + + You are a research assistant with access to web search. + + ## Your Process + 1. Search for relevant information + 2. Summarize findings clearly + +The `name` field is optional; when omitted it defaults to the folder name +(e.g. `researcher`). This diverges from the Agent Skills specification +(`deepagents.middleware.skills`), which requires `name` in frontmatter and +warns when it does not match the parent directory name. Subagents use the +folder name as an implicit fallback instead because subagent definitions are +already uniquely identified by their folder — requiring a redundant `name` +field adds friction without adding information. +""" + +from __future__ import annotations + +import logging +import re +from typing import TYPE_CHECKING, TypedDict + +import yaml + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + + +class SubagentMetadata(TypedDict): + """Metadata for a custom subagent loaded from filesystem.""" + + name: str + """Unique identifier for the subagent, used with the task tool.""" + + description: str + """What this subagent does. Main agent uses this to decide when to delegate.""" + + system_prompt: str + """Instructions for the subagent (body of the markdown file).""" + + model: str | None + """Optional model override in 'provider:model-name' format.""" + + source: str + """Where this subagent was loaded from ('user' or 'project').""" + + path: str + """Absolute path to the subagent definition file.""" + + +def _parse_subagent_file( + file_path: Path, *, fallback_name: str | None = None +) -> SubagentMetadata | None: + """Parse a subagent markdown file with YAML frontmatter. + + The file must have YAML frontmatter (delimited by ---) containing at minimum + a 'description' field. The body of the file becomes the system_prompt. + + Unlike the Agent Skills spec, `name` is optional here — when omitted the + folder name passed via `fallback_name` is used instead. Skills require + `name` in frontmatter and warn when it doesn't match the directory name; + subagents relax that to a fallback so users don't repeat the folder name + redundantly. + + Args: + file_path: Path to the markdown file. + fallback_name: Name to use when the frontmatter omits `name` entirely. + A present-but-empty, whitespace-only, or non-string `name` is + treated as invalid and rejected rather than falling back, so a typo + surfaces loudly instead of being silently masked by the folder name. + + Returns: + SubagentMetadata if parsing succeeds, None otherwise. + """ + try: + content = file_path.read_text(encoding="utf-8") + except OSError as exc: + logger.warning("Skipping subagent %s: could not read file (%s)", file_path, exc) + return None + + # Extract YAML frontmatter (--- delimited) + match = re.match(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", content, re.DOTALL) + if not match: + logger.warning( + "Skipping subagent %s: missing YAML frontmatter. The file must start " + "with a '---' delimited block containing at least 'description'.", + file_path, + ) + return None + + try: + frontmatter = yaml.safe_load(match.group(1)) + except yaml.YAMLError as exc: + logger.warning( + "Skipping subagent %s: invalid YAML frontmatter (%s)", file_path, exc + ) + return None + + # Validate frontmatter structure and required fields + if not isinstance(frontmatter, dict): + logger.warning( + "Skipping subagent %s: frontmatter must be a mapping with a " + "'description' field.", + file_path, + ) + return None + + name_value = frontmatter.get("name", fallback_name) + description_value = frontmatter.get("description") + model = frontmatter.get("model") + + # Validate types: name and description must be non-empty strings (leading and + # trailing whitespace is stripped, so a whitespace-only value is rejected). + # model is optional but must be a string if present. + name = ( + name_value.strip() + if isinstance(name_value, str) and name_value.strip() + else None + ) + description = ( + description_value.strip() + if isinstance(description_value, str) and description_value.strip() + else None + ) + model_valid = model is None or isinstance(model, str) + + if name is None or description is None or not model_valid: + invalid_fields: list[str] = [] + if name is None: + invalid_fields.append("name (non-empty string required)") + if description is None: + invalid_fields.append("description (non-empty string required)") + if not model_valid: + invalid_fields.append("model (string required when present)") + logger.warning( + "Skipping subagent %s: invalid or missing frontmatter field(s): %s", + file_path, + ", ".join(invalid_fields), + ) + return None + + if "name" not in frontmatter: + # Fallback engaged. Log it so a typo'd key (e.g. `nmae:`) that silently + # resolves to the folder name is at least diagnosable at debug level. + logger.debug( + "Subagent %s: 'name' omitted from frontmatter; using folder name %r.", + file_path, + name, + ) + + return { + "name": name, + "description": description, + "system_prompt": match.group(2).strip(), + "model": model, + "source": "", # Set by caller + "path": str(file_path), + } + + +def _load_subagents_from_dir( + agents_dir: Path, source: str +) -> dict[str, SubagentMetadata]: + """Load subagents from a directory. + + Expects structure: agents_dir/{subagent_name}/AGENTS.md + + Args: + agents_dir: Directory containing subagent folders. + source: Source identifier ('user' or 'project'). + + Returns: + Dict mapping subagent name to metadata. + """ + subagents: dict[str, SubagentMetadata] = {} + + if not agents_dir.exists() or not agents_dir.is_dir(): + return subagents + + for entry in agents_dir.iterdir(): + if not entry.is_dir(): + # A stray file directly under agents/ is a common mistake: subagents + # must live at agents/{name}/AGENTS.md, not agents/{name}.md. + if entry.suffix.lower() == ".md": + logger.warning( + "Ignoring %s subagent file %s: subagents must be defined at " + "%s/{subagent-name}/AGENTS.md, not as a file directly in the " + "agents directory.", + source, + entry, + agents_dir, + ) + continue + + # Look for {folder_name}/AGENTS.md + subagent_file = entry / "AGENTS.md" + if not subagent_file.exists(): + # The folder exists but holds a differently-named markdown file + # (e.g. agent.md or {name}.md) instead of the required AGENTS.md. + stray_md = [p.name for p in entry.glob("*.md")] + if stray_md: + logger.warning( + "Ignoring %s subagent folder %s: expected an AGENTS.md file " + "but found %s. Rename the definition to AGENTS.md.", + source, + entry, + ", ".join(sorted(stray_md)), + ) + continue + + subagent = _parse_subagent_file(subagent_file, fallback_name=entry.name) + if subagent: + subagent["source"] = source + # The folder name and a declared `name` can differ, so two folders can + # resolve to the same subagent name and silently collapse to one entry. + # Iteration order is filesystem-dependent, so warn rather than let a + # definition vanish without explanation. + existing = subagents.get(subagent["name"]) + if existing is not None: + logger.warning( + "Subagent name collision in %s: %s and %s both resolve to " + "name=%r. Using %s; give each subagent a unique folder or " + "frontmatter 'name'.", + agents_dir, + existing["path"], + subagent["path"], + subagent["name"], + subagent["path"], + ) + subagents[subagent["name"]] = subagent + + return subagents + + +def list_subagents( + *, + user_agents_dir: Path | None = None, + project_agents_dir: Path | None = None, +) -> list[SubagentMetadata]: + """List subagents from user and/or project directories. + + Scans for subagent definitions in the provided directories. + Project subagents override user subagents with the same name. + + Args: + user_agents_dir: Path to user-level agents directory. + project_agents_dir: Path to project-level agents directory. + + Returns: + List of subagent metadata, with project subagents taking precedence. + """ + all_subagents: dict[str, SubagentMetadata] = {} + + # Load user subagents first (lower priority) + if user_agents_dir is not None: + all_subagents.update(_load_subagents_from_dir(user_agents_dir, "user")) + + # Load project subagents second (override user) + if project_agents_dir is not None: + all_subagents.update(_load_subagents_from_dir(project_agents_dir, "project")) + + return list(all_subagents.values()) diff --git a/libs/cli/deepagents_cli/system_prompt.md b/libs/code/deepagents_code/system_prompt.md similarity index 78% rename from libs/cli/deepagents_cli/system_prompt.md rename to libs/code/deepagents_code/system_prompt.md index 446fcf7937..56f5d0d7b8 100644 --- a/libs/cli/deepagents_cli/system_prompt.md +++ b/libs/code/deepagents_code/system_prompt.md @@ -1,4 +1,4 @@ -# Deep Agents CLI +# Deep Agents Code (dcode) You are a deep agent, an AI assistant running in {mode_description}. You help with tasks like coding, debugging, research, analysis, and more. @@ -7,9 +7,9 @@ You are a deep agent, an AI assistant running in {mode_description}. You help wi # Core Behavior - Be concise and direct. Answer in fewer than 4 lines unless detail is requested. -- After working on a file, stop — don't explain what you did unless asked. - NEVER add unnecessary preamble ("Sure!", "Great question!", "I'll now..."). - Don't say "I'll now do X" — just do it. +- After working on a file, stop — don't explain what you did unless asked. - No time estimates. Focus on what needs to be done, not how long. {ambiguity_guidance} - When you run non-trivial bash commands, briefly explain what they do. @@ -17,18 +17,16 @@ You are a deep agent, an AI assistant running in {mode_description}. You help wi ## Professional Objectivity -- Prioritize technical accuracy over validating the user's beliefs +- Prioritize accuracy over validating the user's beliefs - Disagree respectfully when the user is incorrect - Avoid unnecessary superlatives, praise, or emotional validation ## Following Conventions - Check existing code for libraries and frameworks before assuming -- Mimic existing code style, naming conventions, and patterns - Prefer editing existing files over creating new ones - Only make changes that are directly requested — don't add features, refactor, or "improve" code beyond what was asked - Never add comments unless asked -- CRITICAL: Read files before editing — understand existing code before making changes ## Doing Tasks @@ -54,15 +52,18 @@ CRITICAL: Match what the user asked for EXACTLY. - If steps are repeatedly failing, make note of what's going wrong and share an updated plan with the user. - Use tools and dependencies specified by the user or already present in the codebase. Don't substitute without asking. -## Tool Usage +## Clarifying Requests -IMPORTANT: Use specialized tools instead of shell commands: +- Do not ask for details the user already supplied. +- Use reasonable defaults when the request clearly implies them. +- Prioritize missing semantics like content, delivery, detail level, or alert criteria. +- Avoid opening with a long explanation of tool, scheduling, or integration limitations when a concise blocking followup question would move the task forward. +- Ask domain-defining questions before implementation questions. +- For monitoring or alerting requests, ask what signals, thresholds, or conditions should trigger an alert. -- `read_file` over `cat`/`head`/`tail` -- `edit_file` over `sed`/`awk` -- `write_file` over `echo`/heredoc -- `grep` tool over shell `grep`/`rg` -- `glob` over shell `find`/`ls` +## Tool Usage + +{filesystem_tool_guidance} When performing multiple independent operations, make all tool calls in a single response — don't make sequential calls when parallel is possible. @@ -76,32 +77,7 @@ Reading sequentially when parallel is possible: read_file("/path/a.py") → wait → read_file("/path/b.py") → wait -### shell - -Execute shell commands. Always quote paths with spaces. The bash command will be run from your current working directory. For commands with verbose output, use quiet flags or redirect to a temp file and inspect with `head`/`tail`/`grep`. - - -pytest /foo/bar/tests - - - -cd /foo/bar && pytest tests - - -### File Tools - -- read_file: Read file contents (use absolute paths) -- edit_file: Replace exact strings in files (must read first, provide unique old_string) -- write_file: Create or overwrite files -- ls: List directory contents -- glob: Find files by pattern (e.g., "**/*.py") -- grep: Search file contents - -Always use absolute paths starting with /. - -### web_search - -Search for documentation, error solutions, and code examples. +When a single tool call in a parallel fanout fails with a schema error like `Unknown JSON field`, do NOT submit additional parallel calls with the same invalid field — drop the offending field and retry as a single corrected call before fanning out again. ## File Reading Best Practices @@ -109,9 +85,9 @@ When exploring codebases or reading multiple files, use pagination to prevent co **Pattern for codebase exploration:** -1. First scan: `read_file(path, limit=100)` - See file structure and key sections -2. Targeted read: `read_file(path, offset=100, limit=200)` - Read specific sections -3. Full read: Only use `read_file(path)` without limit when necessary for editing +1. First scan: `read_file(file_path="...", limit=100)` - See file structure and key sections +2. Targeted read: `read_file(file_path="...", offset=100, limit=200)` - Read specific sections +3. Full read: Only use `read_file(file_path="...")` without limit when necessary for editing **When to paginate:** @@ -124,15 +100,6 @@ When exploring codebases or reading multiple files, use pagination to prevent co - Small files (<500 lines) - Files you need to edit immediately after reading -## Working with Subagents (task tool) - -When delegating to subagents: - -- **Use filesystem for large I/O**: If input/output is large (>500 words), communicate via files -- **Parallelize independent work**: Spawn parallel subagents for independent tasks -- **Clear specifications**: Tell subagent exactly what format/structure you need -- **Main agent synthesizes**: Subagents gather/execute, main agent integrates results - ## Git Safety Protocol - NEVER update the git config @@ -228,16 +195,3 @@ When you use the web_search tool: 6. If the search doesn't find what you need, explain what you found and ask clarifying questions The user only sees your text responses - not tool results. Always provide a complete, natural language answer after using web_search. - -### Todo List Management - -When using the write_todos tool: - -1. Use todos for any task with 2+ steps — they give the user visibility -2. Mark tasks `in_progress` before starting, `completed` immediately after -3. Don't batch completions — mark each item done as you finish it -4. If a task reveals sub-tasks, add them right away -5. For simple 1-step tasks, just do them directly -{todo_guidance} - -The todo list is a planning tool - use it judiciously to avoid overwhelming the user with excessive task tracking. diff --git a/libs/cli/deepagents_cli/terminal_capabilities.py b/libs/code/deepagents_code/terminal_capabilities.py similarity index 95% rename from libs/cli/deepagents_cli/terminal_capabilities.py rename to libs/code/deepagents_code/terminal_capabilities.py index 73f23b4d9b..211cd3b95c 100644 --- a/libs/cli/deepagents_cli/terminal_capabilities.py +++ b/libs/code/deepagents_code/terminal_capabilities.py @@ -2,7 +2,7 @@ Detect optional terminal features without reading from `stdin`. -The CLI only uses kitty-keyboard-protocol support to choose a user-facing +The app only uses kitty-keyboard-protocol support to choose a user-facing newline shortcut label. To keep startup safe on remote or high-latency PTYs, detection is conservative and relies on side-effect-free terminal identity signals plus an explicit environment-variable override. @@ -16,7 +16,7 @@ from functools import cache from typing import TYPE_CHECKING -from deepagents_cli._env_vars import KITTY_KEYBOARD +from deepagents_code._env_vars import KITTY_KEYBOARD if TYPE_CHECKING: from collections.abc import Mapping @@ -84,7 +84,7 @@ def supports_kitty_keyboard_protocol() -> bool: queued input bytes. That means it may under-detect some configurable terminals, but it will not interfere with Textual's input stream. - Set `DEEPAGENTS_CLI_KITTY_KEYBOARD` to an accepted truthy value (`1`, + Set `DEEPAGENTS_CODE_KITTY_KEYBOARD` to an accepted truthy value (`1`, `true`, `yes`, `on`) to force-enable the label, a falsy value (`0`, `false`, `no`, `off`) to force-disable it, or `auto`/unset to use heuristic detection. diff --git a/libs/code/deepagents_code/terminal_escape.py b/libs/code/deepagents_code/terminal_escape.py new file mode 100644 index 0000000000..5deeab5edc --- /dev/null +++ b/libs/code/deepagents_code/terminal_escape.py @@ -0,0 +1,287 @@ +"""Best-effort writer for terminal escape/control sequences. + +Centralizes the "fire and forget" pattern the app uses for cosmetic terminal +control (OSC 9;4 taskbar progress today; eventually OSC 52 clipboard and the +iTerm2 cursor guide). Writes prefer `/dev/tty` so output reaches the terminal +even when stdout/stderr are redirected, fall back to `sys.__stderr__`, and +never raise — cosmetic control output must not crash the app. + +Set `DEEPAGENTS_CODE_NO_TERMINAL_ESCAPE=1` to disable all output (useful for +unsupported terminals or noisy logs). +""" + +from __future__ import annotations + +import atexit +import logging +import pathlib +import sys +import threading +from enum import StrEnum +from typing import TYPE_CHECKING + +from deepagents_code._env_vars import NO_TERMINAL_ESCAPE, is_env_truthy + +if TYPE_CHECKING: + from typing import TextIO + +logger = logging.getLogger(__name__) + +_PROGRESS_MIN = 0 +"""Lower clamp bound for determinate `OSC 9;4` progress percentages.""" + +_PROGRESS_MAX = 100 +"""Upper clamp bound for determinate `OSC 9;4` progress percentages.""" + + +class TerminalProgressState(StrEnum): + """`OSC 9;4` progress states. + + See https://learn.microsoft.com/en-us/windows/terminal/tutorials/progress-bar-sequences. + """ + + CLEAR = "0" + """Remove any progress indicator. Percentage is ignored.""" + + NORMAL = "1" + """Determinate progress shown with the default (success) color.""" + + ERROR = "2" + """Determinate progress shown with the error/red color.""" + + INDETERMINATE = "3" + """Activity in progress with no known percentage; renders as a pulse.""" + + WARNING = "4" + """Determinate progress shown with the warning/yellow color.""" + + +def _is_disabled() -> bool: + """Return whether terminal-escape output is opt-out disabled.""" + return is_env_truthy(NO_TERMINAL_ESCAPE) + + +def _open_tty() -> TextIO | None: + """Return an open `/dev/tty` handle, or `None` if unavailable.""" + try: + return pathlib.Path("/dev/tty").open("w", encoding="utf-8") + except OSError: + return None + + +def _is_stream_tty(stream: TextIO | None) -> bool: + """Return whether `stream` is a real TTY.""" + if stream is None: + return False + try: + return bool(stream.isatty()) + except (ValueError, OSError): + return False + + +def write_terminal_escape(sequence: str) -> bool: + r"""Best-effort write of a terminal control sequence. + + Prefers `/dev/tty` so the sequence reaches the terminal even when stdout + or stderr are redirected. Falls back to `sys.__stderr__` only if it is a + TTY. + + Returns `False` (no-op) when output is disabled or no TTY is reachable. + + Args: + sequence: Raw escape sequence to write, including leading `\x1b`/`ESC` + and terminator. + + Returns: + `True` if the sequence was written and flushed without error. + """ + if _is_disabled() or not sequence: + return False + tty = _open_tty() + if tty is not None: + try: + with tty: + tty.write(sequence) + tty.flush() + except (OSError, UnicodeError) as exc: + logger.debug("terminal_escape /dev/tty write failed: %s", exc) + else: + return True + stderr = sys.__stderr__ + if stderr is not None and _is_stream_tty(stderr): + try: + stderr.write(sequence) + stderr.flush() + except (OSError, ValueError) as exc: + logger.debug("terminal_escape stderr write failed: %s", exc) + return False + return True + return False + + +def write_osc(command: str, payload: str = "", *, st: bool = False) -> bool: + r"""Write an `OSC ;` sequence. + + Args: + command: The numeric OSC command (e.g. `"9;4"` for taskbar progress). + payload: Optional semicolon-joined payload appended after the command. + st: When `True`, terminate with String Terminator (`ESC \`) instead of + the default BEL (`\a`). + + BEL matches the Windows Terminal docs and works on most terminals; + VTE-derived terminals may prefer ST. + + Returns: + `True` if the sequence was written. + """ + body = f"{command};{payload}" if payload else command + terminator = "\x1b\\" if st else "\a" + return write_terminal_escape(f"\x1b]{body}{terminator}") + + +_progress_active = False +_terminal_background_active = False +_atexit_registered = False +_atexit_lock = threading.Lock() + + +def _ensure_atexit_registered() -> None: + """Register terminal-state cleanup exactly once.""" + global _atexit_registered # noqa: PLW0603 + + with _atexit_lock: + if not _atexit_registered: + atexit.register(_atexit_clear) + _atexit_registered = True + + +def _validate_progress(progress: int | None, state: TerminalProgressState) -> int: + """Clamp/normalize `progress` for a given `state`. + + Determinate states (`NORMAL`, `ERROR`, `WARNING`) clamp to `[0, 100]`; + `INDETERMINATE` and `CLEAR` always emit `0`. A non-`None` `progress` + supplied with `CLEAR`/`INDETERMINATE` is dropped with a debug log so + misuse stays observable without raising on a cosmetic write path. A + `progress` that can't be coerced to `int` is treated the same way. + + Args: + progress: Raw percentage, or `None`. + state: The OSC 9;4 progress state. + + Returns: + The normalized progress integer to emit. + """ + if state in {TerminalProgressState.CLEAR, TerminalProgressState.INDETERMINATE}: + if progress is not None and progress != 0: + logger.debug( + "terminal_progress: ignoring progress=%r for state=%s", + progress, + state.name, + ) + return 0 + if progress is None: + return 0 + try: + coerced = int(progress) + except (TypeError, ValueError) as exc: + logger.debug( + "terminal_progress: non-numeric progress=%r ignored (%s)", progress, exc + ) + return 0 + return max(_PROGRESS_MIN, min(_PROGRESS_MAX, coerced)) + + +def set_terminal_progress( + progress: int | None = None, + *, + state: TerminalProgressState = TerminalProgressState.NORMAL, +) -> bool: + """Set the terminal's `OSC 9;4` progress indicator. + + Fires unconditionally — terminals that don't recognize `OSC 9;4` silently + ignore the sequence. Set `DEEPAGENTS_CODE_NO_TERMINAL_ESCAPE=1` to opt out + entirely. + + Args: + progress: Percentage `0-100` for determinate states. Ignored for + `INDETERMINATE` and `CLEAR`. + state: One of `TerminalProgressState`. + + Returns: + `True` if the sequence was written. + """ + global _progress_active # noqa: PLW0603 + + value = _validate_progress(progress, state) + payload = f"{state.value};{value}" + written = write_osc("9;4", payload) + if written and state is not TerminalProgressState.CLEAR: + _ensure_atexit_registered() + _progress_active = True + elif state is TerminalProgressState.CLEAR: + _progress_active = False + return written + + +def clear_terminal_progress() -> bool: + """Clear the terminal's progress indicator. + + Emits `OSC 9;4;0;0`. + + Returns: + `True` if the sequence was written. + """ + return set_terminal_progress(state=TerminalProgressState.CLEAR) + + +def set_terminal_background(color: str) -> bool: + """Set the terminal's dynamic default background color with `OSC 11`. + + This is cosmetic and intentionally best-effort. Terminals that don't + support `OSC 11` ignore it; `OSC 111` is emitted from `atexit` to restore + the default background when this call succeeds. + + Args: + color: Terminal color payload, usually a CSS-style hex color such as + `#11121D`. + + Returns: + `True` if the sequence was written. + """ + global _terminal_background_active # noqa: PLW0603 + + if not color: + return False + written = write_osc("11", color, st=True) + if written: + _ensure_atexit_registered() + _terminal_background_active = True + return written + + +def reset_terminal_background() -> bool: + """Reset the terminal's dynamic default background color with `OSC 111`. + + Returns: + `True` if the sequence was written. + """ + global _terminal_background_active # noqa: PLW0603 + + written = write_osc("111", st=True) + if written: + _terminal_background_active = False + return written + + +def _atexit_clear() -> None: + """`atexit` hook that clears any leftover terminal state.""" + if _progress_active: + try: + clear_terminal_progress() + except Exception: + logger.warning("Failed to clear terminal progress at exit", exc_info=True) + if _terminal_background_active: + try: + reset_terminal_background() + except Exception: + logger.warning("Failed to reset terminal background at exit", exc_info=True) diff --git a/libs/cli/deepagents_cli/theme.py b/libs/code/deepagents_code/theme.py similarity index 80% rename from libs/cli/deepagents_cli/theme.py rename to libs/code/deepagents_code/theme.py index 5b8e758562..6b9c7c1e66 100644 --- a/libs/cli/deepagents_cli/theme.py +++ b/libs/code/deepagents_code/theme.py @@ -1,16 +1,17 @@ -"""LangChain brand colors and semantic constants for the CLI. +"""LangChain brand colors and semantic constants for the app. Single source of truth for color values used in Python code (Rich markup, `Content.styled`, `Content.from_markup`). CSS-side styling should reference Textual CSS variables: built-in variables (`$primary`, `$background`, `$text-muted`, `$error-muted`, etc.) are set via `register_theme()` in `DeepAgentsApp.__init__`, while the few app-specific -variables (`$mode-bash`, `$mode-command`, `$skill`, `$skill-hover`, `$tool`, -`$tool-hover`) are backed by these constants via `App.get_theme_variable_defaults()`. +variables (`$mode-bash`, `$mode-command`, `$mode-incognito`, `$skill`, +`$skill-hover`, `$tool`, `$tool-hover`) are backed by these constants via +`App.get_theme_variable_defaults()`. Code that needs custom CSS variable values should call `get_css_variable_defaults(dark=...)`. For the full semantic color palette, look -up the `ThemeColors` instance via `ThemeEntry.REGISTRY`. +up the `ThemeColors` instance via `get_registry()`. Users can define custom themes in `~/.deepagents/config.toml` under `[themes.]` sections. Each new theme section must include `label` (str); @@ -22,12 +23,13 @@ from __future__ import annotations +import functools import logging import re from dataclasses import dataclass, fields from pathlib import Path from types import MappingProxyType -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from collections.abc import Mapping @@ -93,6 +95,10 @@ LC_TOOL_HOVER = "#FFCB91" """Tool call hover — lighter variant for interactive feedback.""" +LC_INCOGNITO = "#2DD4BF" +"""Incognito shell accent — teal, must read distinctly against `LC_PINK` +shell and `error` warning border colors.""" + # --------------------------------------------------------------------------- # Brand palette — light @@ -151,6 +157,9 @@ LC_LIGHT_TOOL_HOVER = "#78350F" """Tool call hover (darkened for light bg contrast).""" +LC_LIGHT_INCOGNITO = "#0F766E" +"""Incognito shell accent (darkened for light bg contrast).""" + # --------------------------------------------------------------------------- # Semantic constants (ANSI color names for Rich console output) @@ -227,6 +236,18 @@ # --------------------------------------------------------------------------- +_textual_colors_cache: dict[tuple[str, bool], ThemeColors] = {} +"""Cache of derived built-in `ThemeColors` keyed on `(theme name, dark)`. + +A built-in Textual theme does not change its color values once registered under +a name, so its derived colors only change when the active theme changes. Caching +avoids re-running over a dozen hex validations on every widget render. The `dark` +flag is part of the key defensively; for built-in themes it is already fixed by +the name. Only registered built-ins are cached (see `get_theme_colors`) — the +cache is cleared by `reload_registry`. +""" + + _HEX_RE = re.compile(r"^#[0-9A-Fa-f]{6}$") """Matches a 7-character hex color string like `#7AA2F7`. @@ -272,6 +293,9 @@ class ThemeColors: mode_command: str """Command mode indicator — borders, prompts, and message prefixes.""" + mode_incognito: str + """Incognito shell indicator — borders, prompts, and message prefixes.""" + skill: str """Skill invocation accent — border and header text.""" @@ -345,6 +369,7 @@ def merged(cls, base: ThemeColors, overrides: dict[str, str]) -> ThemeColors: muted=LC_MUTED, mode_bash=LC_PINK, mode_command=LC_PURPLE, + mode_incognito=LC_INCOGNITO, skill=LC_SKILL, skill_hover=LC_SKILL_HOVER, tool=LC_TOOL, @@ -366,6 +391,7 @@ def merged(cls, base: ThemeColors, overrides: dict[str, str]) -> ThemeColors: muted=LC_LIGHT_MUTED, mode_bash=LC_LIGHT_PINK, mode_command=LC_LIGHT_PURPLE, + mode_incognito=LC_LIGHT_INCOGNITO, skill=LC_LIGHT_SKILL, skill_hover=LC_LIGHT_SKILL_HOVER, tool=LC_LIGHT_TOOL, @@ -402,12 +428,6 @@ class ThemeEntry: `False` for Textual built-in themes that Textual already knows about. """ - REGISTRY: ClassVar[Mapping[str, ThemeEntry]] - """All registered theme entries, keyed by Textual theme name. - - Read-only after module load (`MappingProxyType`). - """ - def __post_init__(self) -> None: """Validate that the label is a non-empty string. @@ -419,9 +439,37 @@ def __post_init__(self) -> None: raise ValueError(msg) +# Curated labels for Textual built-in themes. Themes not listed here fall back +# to a humanized version of the slug (e.g. `ansi-dark` → `Ansi Dark`), so newly +# shipped Textual themes appear in the picker without code changes. +_TEXTUAL_THEME_LABELS: Mapping[str, str] = MappingProxyType( + { + "textual-dark": "Textual Dark", + "textual-light": "Textual Light", + "ansi-dark": "Terminal ANSI Dark", + "ansi-light": "Terminal ANSI Light", + "catppuccin-frappe": "Catppuccin Frappé", + "rose-pine": "Rosé Pine", + "rose-pine-dawn": "Rosé Pine Dawn", + "rose-pine-moon": "Rosé Pine Moon", + "tokyo-night": "Tokyo Night", + } +) + + def _builtin_themes() -> dict[str, ThemeEntry]: """Return the built-in theme entries as a mutable dict. + Textual built-ins are discovered from `textual.theme.BUILTIN_THEMES` so + newly shipped Textual themes appear automatically. They are not registered + via `register_theme()` — Textual's own `$primary`, `$background`, etc. + apply. The `colors` field provides fallback values for app-specific CSS + vars (`$mode-bash`, `$mode-command`, `$mode-incognito`) and Python-side + styling. For standard properties (primary, secondary, etc.), + `get_theme_colors()` dynamically + resolves from the actual Textual theme at runtime so the Python and CSS + color systems stay in sync. + Returns: Dict of built-in theme names to `ThemeEntry` instances. """ @@ -436,52 +484,34 @@ def _builtin_themes() -> dict[str, ThemeEntry]: dark=False, colors=LIGHT_COLORS, ) - # Textual built-in themes — not registered via register_theme() (Textual's - # own $primary, $background, etc. apply). The `colors` field provides - # fallback values for app-specific CSS vars ($mode-bash, $mode-command) and - # Python-side styling. For standard properties (primary, secondary, etc.), - # get_theme_colors() dynamically resolves from the actual Textual theme at - # runtime so the Python and CSS color systems stay in sync. - - def _bi(label: str, *, is_dark: bool) -> ThemeEntry: - return ThemeEntry( + + from textual.theme import BUILTIN_THEMES + + for name, builtin in BUILTIN_THEMES.items(): + label = _TEXTUAL_THEME_LABELS.get(name) or name.replace("-", " ").title() + r[name] = ThemeEntry( label=label, - dark=is_dark, - colors=DARK_COLORS if is_dark else LIGHT_COLORS, + dark=builtin.dark, + colors=DARK_COLORS if builtin.dark else LIGHT_COLORS, custom=False, ) - - r["textual-dark"] = _bi("Textual Dark", is_dark=True) - r["textual-light"] = _bi("Textual Light", is_dark=False) - r["textual-ansi"] = _bi("Terminal (ANSI)", is_dark=False) - # Popular community themes (all ship with Textual >= 8.0) - r["atom-one-dark"] = _bi("Atom One Dark", is_dark=True) - r["atom-one-light"] = _bi("Atom One Light", is_dark=False) - r["catppuccin-frappe"] = _bi("Catppuccin Frappé", is_dark=True) - r["catppuccin-latte"] = _bi("Catppuccin Latte", is_dark=False) - r["catppuccin-macchiato"] = _bi("Catppuccin Macchiato", is_dark=True) - r["catppuccin-mocha"] = _bi("Catppuccin Mocha", is_dark=True) - r["dracula"] = _bi("Dracula", is_dark=True) - r["flexoki"] = _bi("Flexoki", is_dark=True) - r["gruvbox"] = _bi("Gruvbox", is_dark=True) - r["monokai"] = _bi("Monokai", is_dark=True) - r["nord"] = _bi("Nord", is_dark=True) - r["rose-pine"] = _bi("Rosé Pine", is_dark=True) - r["rose-pine-dawn"] = _bi("Rosé Pine Dawn", is_dark=False) - r["rose-pine-moon"] = _bi("Rosé Pine Moon", is_dark=True) - r["solarized-dark"] = _bi("Solarized Dark", is_dark=True) - r["solarized-light"] = _bi("Solarized Light", is_dark=False) - r["tokyo-night"] = _bi("Tokyo Night", is_dark=True) return r -_BUILTIN_NAMES: frozenset[str] = frozenset(_builtin_themes()) -"""Names of built-in themes. +@functools.cache +def _builtin_names() -> frozenset[str]: + """Names of built-in themes; lazily computed and cached. -User `[themes.]` sections matching a built-in name override its colors -rather than creating a new theme. Derived from `_builtin_themes()` to stay in -sync automatically. -""" + User `[themes.]` sections matching a built-in name override its colors + rather than creating a new theme. Derived from `_builtin_themes()` so the + set stays in sync automatically. Lazy because `_builtin_themes()` imports + `textual.theme.BUILTIN_THEMES`, and we don't want to pull Textual onto the + `deepagents --help` / `deepagents -v` cold-start path. + + Returns: + Frozen set of built-in theme names. + """ + return frozenset(_builtin_themes()) def _load_user_themes( @@ -588,7 +618,7 @@ def _load_user_themes( ) # --- Built-in override: merge color tweaks into the existing entry - if name in _BUILTIN_NAMES: + if name in _builtin_names(): existing = builtins.get(name) if existing is None: logger.warning( @@ -670,20 +700,26 @@ def _build_registry( return MappingProxyType(r) -ThemeEntry.REGISTRY = _build_registry() -"""Read-only mapping of Textual theme names to `ThemeEntry` instances. +@functools.cache +def get_registry() -> MappingProxyType[str, ThemeEntry]: + """Return the read-only theme registry, building it on first access. + + Lazy so that `theme.py` can be imported on the `deepagents --help` cold + path without pulling in `textual.theme.BUILTIN_THEMES` (which transitively + imports Textual, ~470ms). Inside a Textual app, the build is microseconds + because Textual is already loaded; callers like `_register_custom_themes()` + iterate the result during `App.__init__`, which warms the cache before any + user-facing surface (e.g. the theme picker) reads it. + """ + return _build_registry() -Built via `_build_registry()` so the mutable staging dict is scoped to a -function call and cannot be mutated after freeze. The `ClassVar` declaration on -`ThemeEntry` provides the type; this assignment supplies the value. -""" DEFAULT_THEME = "langchain" """Theme name used when no preference is saved.""" def reload_registry() -> MappingProxyType[str, ThemeEntry]: - """Rebuild the theme registry from disk and update `ThemeEntry.REGISTRY`. + """Rebuild the theme registry from disk. Re-reads `~/.deepagents/config.toml` for user-defined themes so that `/reload` can pick up config changes without restarting the app. @@ -691,8 +727,10 @@ def reload_registry() -> MappingProxyType[str, ThemeEntry]: Returns: The new frozen registry. """ - ThemeEntry.REGISTRY = _build_registry() - return ThemeEntry.REGISTRY + get_registry.cache_clear() + _builtin_names.cache_clear() + _textual_colors_cache.clear() + return get_registry() def get_css_variable_defaults( @@ -715,6 +753,7 @@ def get_css_variable_defaults( return { "mode-bash": c.mode_bash, "mode-command": c.mode_command, + "mode-incognito": c.mode_incognito, "skill": c.skill, "skill-hover": c.skill_hover, "tool": c.tool, @@ -732,7 +771,7 @@ def _resolve_app(widget_or_app: object) -> object: The resolved App instance. """ return ( - widget_or_app.app # type: ignore[attr-defined] + widget_or_app.app # ty: ignore[unresolved-attribute] if hasattr(type(widget_or_app), "app") else widget_or_app ) @@ -742,13 +781,11 @@ def _colors_from_textual_theme(app: object) -> ThemeColors: """Construct `ThemeColors` from the app's active Textual theme. Reads standard properties (primary, secondary, etc.) from the resolved - theme so Python-side styling matches CSS. `muted` falls back to the - dark/light base unconditionally (no Textual equivalent). - `mode_bash` is derived from the theme's `error` color, and `mode_command` - from `secondary`, falling back to the base palette when non-hex. - - Non-hex values (e.g. `ansi_blue` in the ANSI theme) are detected and fall - back to the base palette automatically. + theme so Python-side styling matches CSS. `muted` and `mode_incognito` + have no Textual equivalent and always source from the dark/light base + palette. `mode_bash` is derived from the theme's `error` color and + `mode_command` from `secondary`, both falling back to the base palette + when non-hex values (e.g. `ansi_blue` in the ANSI theme) are detected. Args: app: The Textual App instance. @@ -756,7 +793,7 @@ def _colors_from_textual_theme(app: object) -> ThemeColors: Returns: `ThemeColors` derived from the active theme. """ - ct = app.current_theme # type: ignore[attr-defined] + ct = app.current_theme # ty: ignore[unresolved-attribute] dark: bool = ct.dark base = DARK_COLORS if dark else LIGHT_COLORS @@ -786,6 +823,7 @@ def _hex_or(val: str | None, fallback: str) -> str: muted=base.muted, mode_bash=_hex_or(ct.error, base.mode_bash), mode_command=_hex_or(ct.secondary, base.mode_command), + mode_incognito=base.mode_incognito, # No Textual equivalent — always use base palette. skill=base.skill, skill_hover=base.skill_hover, @@ -827,16 +865,27 @@ def get_theme_colors(widget_or_app: App | object | None = None) -> ThemeColors: except (ImportError, LookupError): return DARK_COLORS app = _resolve_app(widget_or_app) - entry = ThemeEntry.REGISTRY.get(app.theme) # type: ignore[attr-defined] + entry = get_registry().get(app.theme) # ty: ignore[unresolved-attribute] # Custom themes (LC-branded / user-defined) use pre-built colors. if entry is not None and entry.custom: return entry.colors # Built-in or unrecognized themes — derive from the resolved Textual - # theme so Python styling matches CSS. + # theme so Python styling matches CSS. Cache only registered built-ins, + # since unregistered runtime themes may reuse a name with different colors. try: - return _colors_from_textual_theme(app) + ct = app.current_theme # ty: ignore[unresolved-attribute] + if entry is None: + colors = _colors_from_textual_theme(app) + else: + key = (app.theme, bool(ct.dark)) # ty: ignore[unresolved-attribute] + colors = _textual_colors_cache.get(key) + if colors is None: + colors = _colors_from_textual_theme(app) + _textual_colors_cache[key] = colors except Exception: logger.warning("Could not resolve theme colors dynamically", exc_info=True) if entry is not None: return entry.colors return DARK_COLORS + else: + return colors diff --git a/libs/code/deepagents_code/tool_catalog.py b/libs/code/deepagents_code/tool_catalog.py new file mode 100644 index 0000000000..abb92507bc --- /dev/null +++ b/libs/code/deepagents_code/tool_catalog.py @@ -0,0 +1,567 @@ +"""Enumerate the tools available to the agent. + +Backs two entry points: the `dcode tools list` CLI command (`_run_tools_list`) +and the interactive `/tools` slash command (`app._handle_tools_command`). + +The tool set is read from the *real* tool objects the agent binds rather than a +hand-maintained catalog, so names and descriptions never drift from what the +model actually sees. Built-in tools are collected by compiling the agent with a +throwaway offline chat model (no credentials, no network) and reading the bound +tool node; MCP tools are discovered via the same path the app and server use. + +The collection functions here lazily import the heavy agent stack (agent +compilation, MCP discovery) inside their bodies. Only the fake-model base is +imported at module top, so importing this module is cheap relative to the agent +stack — and this module is itself imported lazily by both entry points +(`_run_tools_list` and `_handle_tools_command`), never on the startup hot path. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, cast + +from deepagents_code._constants import FS_TOOL_NAMES +from deepagents_code._fake_models import _ToolBindingFakeModel + +if TYPE_CHECKING: + from collections.abc import Sequence + + from deepagents import FsToolName + from langgraph.prebuilt.tool_node import ToolNode + + from deepagents_code.mcp_tools import MCPServerInfo, MCPServerStatus + +logger = logging.getLogger(__name__) + +ToolSource = Literal["built-in", "mcp"] +"""Stable source token identifying where a tool group comes from. + +Emitted verbatim in the `--json` output, so it is a public contract; keep it a +`Literal` of stable tokens (not a bare `str`), following the same convention as +`mcp_tools.MCPServerStatus`. +""" + +BUILT_IN_GROUP = "Built-in" +"""Display label for the group of tools bundled with `deepagents-code`.""" + +_FILESYSTEM_TOOL_NAMES = FS_TOOL_NAMES +"""Which enumerated tools the `fs_tools` allowlist governs. + +Aliased from the shared `_constants.FS_TOOL_NAMES` (see its docstring); the +drift guard in `test_tool_catalog` pins it so a new or renamed SDK filesystem +tool fails a test instead of silently escaping the leak check below. +""" + + +@dataclass(frozen=True, slots=True) +class ToolEntry: + """A single tool's display metadata.""" + + name: str + """Tool name as bound on the agent (e.g. `read_file`).""" + + description: str + """First non-empty line of the tool's description, whitespace-collapsed.""" + + +@dataclass(frozen=True, slots=True) +class ToolGroup: + """A named group of tools sharing a source.""" + + label: str + """Group heading (`Built-in`, or the MCP server name).""" + + source: ToolSource + """Stable source token: `built-in` or `mcp`.""" + + tools: tuple[ToolEntry, ...] + """Tools in this group, in bind order.""" + + +@dataclass(frozen=True, slots=True) +class UnavailableServer: + """An MCP server that was discovered but currently exposes no tools.""" + + name: str + """Server name from the MCP configuration.""" + + status: MCPServerStatus + """Load status token — any non-`ok` `mcp_tools.MCPServerStatus`. + + Reuses `MCPServerStatus` (rather than a bare `str`) so the same closed value + set governs this field and its `--json` output, and so the token list has a + single source of truth in `mcp_tools`. + """ + + detail: str + """Human-readable reason from discovery, or `""` when none was given. + + For config-load failures this is discovery's own reason string, which may + include the local config file path (e.g. `~/.deepagents/mcp.json: ...`) — + the same text the interactive `/mcp` viewer shows. See `collect_mcp_catalog`. + """ + + def __post_init__(self) -> None: + """Enforce that an unavailable server is never `ok`. + + An `ok` server exposes tools and belongs in a `ToolGroup`, never here; + rejecting it at construction keeps the documented non-`ok` invariant + from being silently violated by a future producer. + + Raises: + ValueError: If `status` is `"ok"`. + """ + if self.status == "ok": + msg = "UnavailableServer.status must be a non-'ok' MCPServerStatus" + raise ValueError(msg) + + +@dataclass(frozen=True, slots=True) +class ToolCatalog: + """Everything `dcode tools list` needs to render, in display order.""" + + groups: tuple[ToolGroup, ...] + """Built-in group first, then one group per MCP server that exposes tools.""" + + unavailable: tuple[UnavailableServer, ...] = () + """MCP servers discovered with no tools (errored, needing login, or disabled). + + Surfaced rather than dropped so a user debugging a missing tool can see why + it is absent. + """ + + mcp_error: str | None = None + """Generic notice set when MCP discovery itself failed; `None` on success. + + Raw exception detail is logged at debug level, never embedded here, so no + file paths or stack traces leak into CLI/JSON output. + """ + + +def unavailable_server_display(server: UnavailableServer) -> tuple[str, str]: + """Return the `(status_label, detail)` display pair for an unavailable server. + + Shared by the CLI (`client.commands.tools._print_unavailable_servers`) and + TUI (`app._render_tool_catalog`) renderers so both describe a server the same + way. A disabled server shows its reconnect guidance if present, else the + generic "disabled by user", with no separate detail; other statuses show the + status token plus discovery's reason string when present. + + Args: + server: A server that loaded with no usable tools. + + Returns: + `(status_label, detail)`: the primary status text and any secondary + detail (`""` when none). Each renderer lays these out itself, e.g. as + `status_label: detail`. + """ + if server.status == "disabled": + return (server.detail or "disabled by user", "") + return (server.status, server.detail) + + +class _CatalogModel(_ToolBindingFakeModel): + """Offline placeholder model used only to compile the agent for enumeration. + + Compiling the agent binds every tool but never calls the model, so this + never issues a request — enumeration only reads the bound tool node. It + exists so tool enumeration works without credentials or network access. + Inherits the `bind_tools` passthrough and minimal `profile` the agent + runtime reads during setup from `_ToolBindingFakeModel`. + """ + + model: str = "catalog" + + +def _first_line(text: str | None) -> str: + """Return the first non-empty line of `text`, whitespace-collapsed.""" + if not text: + return "" + for line in text.splitlines(): + stripped = line.strip() + if stripped: + return " ".join(stripped.split()) + return "" + + +def collect_built_in_tools( + *, + assistant_id: str = "agent", + enable_interpreter: bool = False, + fs_tools: list[FsToolName] | None = None, +) -> list[ToolEntry]: + """Enumerate the built-in tools the agent binds by default. + + Compiles the agent with an offline placeholder model and reads the bound + tool node. Memory and skills are disabled because they contribute no tools + (they only augment the system prompt). The selected assistant id is still + forwarded so agent-specific subagents are loaded from the same directory the + normal launch path uses. The custom CLI tools are included the same way + `server_graph._build_tools` adds them, so `web_search` appears only when + Tavily is configured. + + Args: + assistant_id: Resolved dcode agent identifier to compile. + enable_interpreter: Wire the JS interpreter middleware so `js_eval` + appears when the default agent would bind it. Callers should pass + the resolved runtime setting (see `_resolve_enable_interpreter`) so + the list matches the tools the agent actually binds. + fs_tools: Filesystem tool allowlist. Forwarded to the catalog agent so + it is built exactly like the runtime session. The SDK's + `FilesystemMiddleware` omits disallowed tools from the node + entirely, so forwarding alone narrows the enumeration; a defensive + check below verifies no disallowed tool leaked through and logs + loudly if one did (see the comment on that check). + + Returns: + Built-in tools in bind order. + + Raises: + RuntimeError: If the compiled graph does not expose its bound tools. + """ + from deepagents_code.agent import create_cli_agent + from deepagents_code.config import settings + from deepagents_code.tools import fetch_url, get_current_thread_id, web_search + + # Keep in sync with `server_graph._build_tools`: web_search is bound only + # when Tavily is configured, so it appears here only under the same gate. + custom_tools: list[Any] = [fetch_url, get_current_thread_id] + if settings.has_tavily: + custom_tools.append(web_search) + + agent, _backend = create_cli_agent( + _CatalogModel(), + assistant_id=assistant_id, + tools=custom_tools, + enable_memory=False, + enable_skills=False, + enable_shell=True, + enable_interpreter=enable_interpreter, + fs_tools=fs_tools, + ) + tools = collect_tools_from_agent(agent) + if tools is None: + msg = "Compiled agent does not expose a LangGraph tool node" + raise RuntimeError(msg) + # Defensive backstop against a change in SDK behavior. The SDK's + # `FilesystemMiddleware` omits disallowed tools from the bound node + # entirely, so `collect_tools_from_agent` should already return only + # allowlisted filesystem tools. If a disallowed tool *does* leak through, + # enforcement broke on the real agent (this enumeration is built from the + # same `create_cli_agent` the runtime uses). Return the *unfiltered* list + # and log loudly rather than scrubbing: scrubbing would hide the one signal + # that enforcement failed and make `/tools` report a restricted surface over + # an unrestricted agent. (`None` — the unrestricted default — skips this.) + if isinstance(fs_tools, list): + enabled = frozenset(fs_tools) + leaked = [ + tool.name + for tool in tools + if tool.name in _FILESYSTEM_TOOL_NAMES and tool.name not in enabled + ] + if leaked: + logger.error( + "Filesystem tool allowlist backstop detected %s in the tool " + "listing: the enumerated agent exposed disallowed filesystem " + "tool(s) not in the allowlist %s. This indicates the allowlist " + "was not applied to the underlying agent; the listing reflects " + "the agent's actual (unrestricted) tools.", + leaked, + sorted(enabled), + ) + return tools + + +def collect_tools_from_agent(agent: object) -> list[ToolEntry] | None: + """Read tools from a local compiled agent when its graph is inspectable. + + LangGraph does not expose a public tool-enumeration API, so this reaches + through the compiled graph's conventional `nodes["tools"].bound` shape. + Returning `None` distinguishes an uninspectable graph (a remote agent, or a + local graph whose internals no longer match that convention) from a local + graph that validly binds zero tools (`[]`). + + Args: + agent: Active local or remote agent object. + + Returns: + Bound tools in graph order; `[]` for an inspectable local graph with no + tools; or `None` when the agent cannot be inspected locally. + """ + nodes = getattr(agent, "nodes", None) + if not isinstance(nodes, Mapping): + # No conventional node map: a remote agent or a non-graph object. Expected + # for remote agents, so debug rather than warning. + logger.debug("Agent %r has no inspectable node map", type(agent)) + return None + if "tools" not in nodes: + # LangChain omits the tool node when an otherwise valid local agent + # binds no tools. The graph is still inspectable; its tool set is empty. + return [] + node = nodes.get("tools") + tool_node = cast("ToolNode | None", getattr(node, "bound", None)) + tools_by_name = getattr(tool_node, "tools_by_name", None) + if not isinstance(tools_by_name, Mapping): + # A "tools" node exists but does not expose the expected + # `bound.tools_by_name` mapping — a LangGraph internal-shape change, not + # a remote agent. Warn so this drift is visible in logs even though the + # user-facing notice attributes it to an uninspectable agent. + logger.warning( + "Agent 'tools' node is not introspectable (bound=%r); " + "LangGraph internals may have changed", + type(tool_node), + ) + return None + tools: list[ToolEntry] = [] + for name, tool in tools_by_name.items(): + if not isinstance(name, str): + continue + description = getattr(tool, "description", None) + tools.append( + ToolEntry( + name=name, + description=_first_line( + description if isinstance(description, str) else None + ), + ) + ) + return tools + + +def collect_mcp_catalog( + *, + mcp_config_path: str | None = None, + trust_project_mcp: bool | None = None, +) -> tuple[list[ToolGroup], list[UnavailableServer], str | None]: + """Discover MCP servers, split into tool groups and unavailable servers. + + Best-effort: if discovery itself raises (no config, offline, load error), + the technical detail is logged and a generic `mcp_error` message is + returned so `dcode tools list` still renders the built-in tools while + telling the user discovery failed. Servers that loaded but expose no tools + are reported as `UnavailableServer`s (errored, needing login, or disabled) + rather than silently dropped — surfacing exactly what a user running this + command to debug a missing tool needs to see. + + Args: + mcp_config_path: Explicit MCP config path (`--mcp-config`), or `None` + to rely on auto-discovery. + trust_project_mcp: Project-level stdio trust decision + (`--trust-project-mcp`), forwarded to discovery unchanged. + + Returns: + `(groups, unavailable, mcp_error)`: per-server tool groups, discovered + servers exposing no tools, and a generic discovery-failure message + (`None` when discovery succeeded). + """ + try: + server_info = asyncio.run( + _load_mcp_server_info( + mcp_config_path=mcp_config_path, + trust_project_mcp=trust_project_mcp, + ) + ) + except Exception: + # Log the real cause for debugging, but return a generic message so no + # file path or stack trace leaks into CLI/JSON output. + logger.warning("MCP tool discovery failed for `tools list`", exc_info=True) + return [], [], "MCP discovery failed; showing built-in tools only." + + groups, unavailable = split_mcp_server_info(server_info) + return groups, unavailable, None + + +def split_mcp_server_info( + server_info: Sequence[MCPServerInfo], +) -> tuple[list[ToolGroup], list[UnavailableServer]]: + """Split loaded MCP server metadata into tool groups and unavailable servers. + + Pure function shared by the CLI discovery path (`collect_mcp_catalog`) and + the interactive `/tools` command, which passes the app's already-loaded + `MCPServerInfo` list rather than re-discovering (Textual's running event + loop forbids the `asyncio.run` discovery path). + + Servers that loaded but expose no tools are reported as `UnavailableServer`s + (errored, needing login, or disabled) rather than silently dropped — + surfacing exactly what a user debugging a missing tool needs to see. + + Args: + server_info: Loaded MCP server metadata. + + Returns: + `(groups, unavailable)`: per-server tool groups (only servers exposing + tools) and servers discovered with no tools and a non-`ok` status. + """ + groups: list[ToolGroup] = [] + unavailable: list[UnavailableServer] = [] + for server in server_info: + if server.tools: + entries = tuple( + ToolEntry(name=tool.name, description=_first_line(tool.description)) + for tool in server.tools + ) + groups.append(ToolGroup(label=server.name, source="mcp", tools=entries)) + elif server.status != "ok": + # A server that loaded but has no tools *and* is not "ok" is broken, + # unauthenticated, or disabled — report it so the omission is + # explained. A plainly-disabled server drops discovery's reason so + # the renderers show the generic "disabled by user" label; a + # just-re-enabled one (`pending_reconnect`) keeps its reconnect + # guidance so the renderer can distinguish it from a server the user + # left disabled. Other statuses retain discovery's reason string — + # not a stack trace, but config-load failures can include the local + # config file path — see `UnavailableServer.detail`. + detail = server.error or "" + if server.status == "disabled" and not server.pending_reconnect: + detail = "" + unavailable.append( + UnavailableServer( + name=server.name, + status=server.status, + detail=detail, + ) + ) + return groups, unavailable + + +def build_catalog_from_server_info( + built_in: Sequence[ToolEntry], + server_info: Sequence[MCPServerInfo], +) -> ToolCatalog: + """Assemble a `ToolCatalog` from pre-collected built-in tools and live MCP info. + + The interactive `/tools` command entry point: it avoids the `asyncio.run` + MCP discovery reached via `collect_catalog` (the `asyncio.run` call itself + lives in `collect_mcp_catalog`), which cannot run inside Textual's running + event loop, by reusing the MCP metadata the app already loaded. `mcp_error` + is always `None` here because discovery is not attempted — any load failures + are already reflected per-server in `server_info` as non-`ok` `MCPServerInfo` + entries, which `split_mcp_server_info` surfaces as `UnavailableServer`s. + + Args: + built_in: Built-in tools in bind order (from `collect_built_in_tools`). + server_info: The app's already-loaded MCP server metadata. + + Returns: + A `ToolCatalog` with the built-in group first, then any MCP groups, plus + unavailable servers. + """ + groups: list[ToolGroup] = [ + ToolGroup(label=BUILT_IN_GROUP, source="built-in", tools=tuple(built_in)) + ] + mcp_groups, unavailable = split_mcp_server_info(server_info) + groups.extend(mcp_groups) + return ToolCatalog(groups=tuple(groups), unavailable=tuple(unavailable)) + + +async def _load_mcp_server_info( + *, + mcp_config_path: str | None, + trust_project_mcp: bool | None, +) -> list[Any]: + """Load MCP server metadata, cleaning up any temporary sessions. + + Args: + mcp_config_path: Explicit MCP config path, or `None` for auto-discovery. + trust_project_mcp: Project-level stdio trust decision. + + Returns: + Discovered MCP server metadata, or an empty list when none load. + """ + from deepagents_code.mcp_tools import resolve_and_load_mcp_tools + from deepagents_code.plugins.adapters.mcp import discover_plugin_mcp_configs + from deepagents_code.project_utils import ProjectContext + + try: + project_context = ProjectContext.from_user_cwd(Path.cwd()) + except (OSError, RuntimeError): + # `Path.cwd()`/`.resolve()` raise OSError for a missing cwd and + # RuntimeError on a symlink loop (3.11-3.12); match the codebase's own + # convention in `project_utils` and fall back to no project context. + logger.warning("Could not determine working directory for MCP discovery") + project_context = None + project_dir = ( + project_context.project_root or project_context.user_cwd + if project_context is not None + else None + ) + + session_manager = None + try: + _tools, session_manager, server_info = await resolve_and_load_mcp_tools( + explicit_config_path=mcp_config_path, + no_mcp=False, + trust_project_mcp=trust_project_mcp, + project_context=project_context, + additional_configs=discover_plugin_mcp_configs(project_dir=project_dir), + ) + return server_info or [] + finally: + if session_manager is not None: + try: + await session_manager.cleanup() + except Exception: + logger.warning("MCP discovery cleanup failed", exc_info=True) + + +def collect_catalog( + *, + assistant_id: str = "agent", + enable_interpreter: bool = False, + fs_tools: list[FsToolName] | None = None, + include_mcp: bool = True, + mcp_config_path: str | None = None, + trust_project_mcp: bool | None = None, +) -> ToolCatalog: + """Collect everything `dcode tools list` renders. + + Args: + assistant_id: Resolved dcode agent identifier to compile for built-in + tools, including any agent-specific subagents. + enable_interpreter: Whether the default agent binds `js_eval`; forwarded + to `collect_built_in_tools`. + fs_tools: Filesystem tool allowlist; forwarded to + `collect_built_in_tools`, which filters the built-in enumeration so + it matches the configured session. + include_mcp: When `True`, discover MCP servers and append their groups + after the built-in group (best-effort). Pass `False` to mirror + `--no-mcp`. + mcp_config_path: Explicit MCP config path (`--mcp-config`). + trust_project_mcp: Project-level stdio trust decision + (`--trust-project-mcp`). + + Returns: + A `ToolCatalog` with the built-in group first, then any MCP groups, + plus unavailable servers and any discovery-failure notice. + """ + groups: list[ToolGroup] = [ + ToolGroup( + label=BUILT_IN_GROUP, + source="built-in", + tools=tuple( + collect_built_in_tools( + assistant_id=assistant_id, + enable_interpreter=enable_interpreter, + fs_tools=fs_tools, + ) + ), + ) + ] + unavailable: list[UnavailableServer] = [] + mcp_error: str | None = None + if include_mcp: + mcp_groups, unavailable, mcp_error = collect_mcp_catalog( + mcp_config_path=mcp_config_path, + trust_project_mcp=trust_project_mcp, + ) + groups.extend(mcp_groups) + return ToolCatalog( + groups=tuple(groups), + unavailable=tuple(unavailable), + mcp_error=mcp_error, + ) diff --git a/libs/cli/deepagents_cli/tool_display.py b/libs/code/deepagents_code/tool_display.py similarity index 75% rename from libs/cli/deepagents_cli/tool_display.py rename to libs/code/deepagents_code/tool_display.py index bd581c8e22..f0c364ae3a 100644 --- a/libs/cli/deepagents_cli/tool_display.py +++ b/libs/code/deepagents_code/tool_display.py @@ -1,4 +1,4 @@ -"""Formatting utilities for tool call display in the CLI. +"""Formatting utilities for tool call display in the app. This module handles rendering tool calls and tool messages for the TUI. @@ -7,17 +7,33 @@ """ import json +from collections.abc import Callable from contextlib import suppress from pathlib import Path from typing import Any -from deepagents_cli.config import MAX_ARG_LENGTH, get_glyphs -from deepagents_cli.unicode_security import strip_dangerous_unicode +from deepagents_code.config import MAX_ARG_LENGTH, get_glyphs +from deepagents_code.unicode_security import strip_dangerous_unicode _HIDDEN_CHAR_MARKER = " [hidden chars removed]" """Marker appended to display values that had dangerous Unicode stripped, so users know the value was modified for safety.""" +JS_EVAL_HEADER_MAX_LENGTH = 120 +"""Width at which the `js_eval` header truncates the first code line. + +Shared with `messages.py` so the "header truncates the first line" cutoff and +the "offer a collapsible code block" threshold stay in lock-step from a single +source of truth. +""" + +EXECUTE_HEADER_MAX_LENGTH = 120 +"""Width at which the `execute` header truncates the shell command. + +Shared with `messages.py` so the header cutoff and the "offer a collapsible +command block" threshold stay in lock-step from a single source of truth. +""" + def _format_timeout(seconds: int) -> str: """Format timeout in human-readable units (e.g., 300 -> '5m', 3600 -> '1h'). @@ -95,6 +111,38 @@ def _sanitize_display_value(value: object, *, max_length: int = MAX_ARG_LENGTH) return display +def _format_scope_path( + path_value: object, + abbreviate: Callable[[str], str], +) -> str: + """Format a glob/grep `path` argument as a display suffix. + + The glob tool defaults `path` to the backend root (`"/"`); the grep tool + defaults it to `None` (the backend's working directory). In either case the + default scope adds no information and is omitted. Only an explicit, non-root + path is rendered, so that two otherwise-identical calls scoped to different + directories are distinguishable in the UI. The rendered path is shortened + via the supplied `abbreviate` helper. + + Args: + path_value: The raw `path` argument, or `None` when not supplied. + abbreviate: Path-shortening helper from the calling scope. + + Returns: + A suffix like ` in langchain`, or an empty string for the default scope. + """ + if path_value is None: + return "" + raw = str(path_value) + if raw in {"", "/"}: + return "" + sanitized = strip_dangerous_unicode(raw) + display = abbreviate(sanitized) + if sanitized != raw: + display += _HIDDEN_CHAR_MARKER + return f" in {display}" + + def format_tool_display(tool_name: str, tool_args: dict) -> str: """Format tool calls for display with tool-specific smart formatting. @@ -108,7 +156,7 @@ def format_tool_display(tool_name: str, tool_args: dict) -> str: Formatted string for display (e.g., "(*) read_file(config.py)" in ASCII mode) Examples: - read_file(path="/long/path/file.py") → " read_file(file.py)" + read_file(file_path="/long/path/file.py") → " read_file(file.py)" web_search(query="how to code") → ' web_search("how to code")' execute(command="pip install foo") → ' execute("pip install foo")' """ @@ -148,7 +196,7 @@ def abbreviate_path(path_str: str, max_length: int = 60) -> str: return path.name # Tool-specific formatting - show the most important argument(s) - if tool_name in {"read_file", "write_file", "edit_file"}: + if tool_name in {"read_file", "write_file", "edit_file", "delete"}: # File operations: show the primary file path argument (file_path or path) path_value = tool_args.get("file_path") if path_value is None: @@ -167,15 +215,18 @@ def abbreviate_path(path_str: str, max_length: int = 60) -> str: return f'{prefix} {tool_name}("{query}")' elif tool_name == "grep": - # Grep: show the search pattern + # Grep: show the search pattern, and the scoped path when non-default if "pattern" in tool_args: pattern = _sanitize_display_value(tool_args["pattern"], max_length=70) - return f'{prefix} {tool_name}("{pattern}")' + scope = _format_scope_path(tool_args.get("path"), abbreviate_path) + return f'{prefix} {tool_name}("{pattern}"{scope})' elif tool_name == "execute": # Execute: show the command, and timeout only if non-default if "command" in tool_args: - command = _sanitize_display_value(tool_args["command"], max_length=120) + command = _sanitize_display_value( + tool_args["command"], max_length=EXECUTE_HEADER_MAX_LENGTH + ) timeout = _coerce_timeout_seconds(tool_args.get("timeout")) from deepagents.backends import DEFAULT_EXECUTE_TIMEOUT @@ -184,6 +235,23 @@ def abbreviate_path(path_str: str, max_length: int = 60) -> str: return f'{prefix} {tool_name}("{command}", timeout={timeout_str})' return f'{prefix} {tool_name}("{command}")' + elif tool_name == "js_eval": + # JS interpreter: show only the first non-blank line of the snippet so a + # multi-line program collapses to a single, scannable header line. The + # full code is available via the collapsible args block. + code = tool_args.get("code") + if isinstance(code, str) and code.strip(): + first_line = next( + (line for line in code.splitlines() if line.strip()), "" + ).strip() + multiline = sum(1 for line in code.splitlines() if line.strip()) > 1 + snippet = _sanitize_display_value( + first_line, max_length=JS_EVAL_HEADER_MAX_LENGTH + ) + ellipsis = get_glyphs().ellipsis if multiline else "" + return f'{prefix} {tool_name}("{snippet}{ellipsis}")' + return f"{prefix} {tool_name}()" + elif tool_name == "ls": # ls: show directory, or empty if current directory if tool_args.get("path"): @@ -195,10 +263,11 @@ def abbreviate_path(path_str: str, max_length: int = 60) -> str: return f"{prefix} {tool_name}()" elif tool_name == "glob": - # Glob: show the pattern + # Glob: show the pattern, and the scoped path when non-default if "pattern" in tool_args: pattern = _sanitize_display_value(tool_args["pattern"], max_length=80) - return f'{prefix} {tool_name}("{pattern}")' + scope = _format_scope_path(tool_args.get("path"), abbreviate_path) + return f'{prefix} {tool_name}("{pattern}"{scope})' elif tool_name == "fetch_url": # Fetch URL: show the URL being fetched diff --git a/libs/code/deepagents_code/tools.py b/libs/code/deepagents_code/tools.py new file mode 100644 index 0000000000..50d5e22350 --- /dev/null +++ b/libs/code/deepagents_code/tools.py @@ -0,0 +1,504 @@ +"""Custom tools for the agent.""" + +from __future__ import annotations + +import contextlib +import ipaddress +import logging +import socket +import threading +from html.parser import HTMLParser +from typing import TYPE_CHECKING, Annotated, Any, Literal +from urllib.parse import urljoin, urlparse + +from langchain_core.tools import tool +from langgraph.config import get_config +from pydantic import Field + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + + from tavily import TavilyClient + +logger = logging.getLogger(__name__) + +_UNSET = object() +_tavily_client: TavilyClient | object | None = _UNSET + +_ALLOWED_URL_SCHEMES = frozenset({"http", "https"}) +_MAX_FETCH_REDIRECTS = 5 + +# Maintainer note: `deepagents-talon` imports `web_search` and `fetch_url` +# directly from this module. Keep their names, signatures, and return/error dict +# shapes stable unless `deepagents-talon` is migrated in the same change. + +# Module-level lock guarding the urllib3 connection-factory monkeypatch used by +# `_pinned_dns`. The patch is process-global, so serializing fetches keeps +# concurrent calls from clobbering each other's pinned IP set. +_dns_pin_lock = threading.Lock() + + +class _UrlValidationError(ValueError): + """Raised by `_validate_url` for scheme/DNS/SSRF-blocked URLs. + + Distinguishes intentional SSRF-guard rejections from incidental + `ValueError`s raised elsewhere in the fetch path (e.g., markdown + conversion). + """ + + +def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + """Return True if `ip` belongs to a non-publicly-routable range. + + Rejects: private (RFC1918/ULA), loopback, link-local (including cloud + IMDS at `169.254.169.254`), reserved, multicast, unspecified + (`0.0.0.0`/`::`), and anything `ipaddress` does not consider globally + routable (catches benchmarking, documentation, and similar ranges the + explicit predicates miss). + + IPv4-mapped IPv6 (`::ffff:a.b.c.d`) and 6to4 (`2002::/16`) are unwrapped + to their underlying IPv4 address before the checks so that private + space tunneled inside an IPv6 wrapper is still caught — e.g., + `::ffff:127.0.0.1` and `2002:a9fe:a9fe::1` (6to4 over IMDS) both + evaluate as blocked. + """ + if isinstance(ip, ipaddress.IPv6Address): + if ip.ipv4_mapped is not None: + ip = ip.ipv4_mapped + elif ip.sixtofour is not None: + ip = ip.sixtofour + return ( + not ip.is_global + or ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + or ip.is_unspecified + ) + + +def _validate_url(url: str) -> list[str]: + """Reject URLs that target private/internal/metadata addresses. + + Resolves the URL's hostname and rejects any URL whose hostname resolves + to a private, loopback, link-local (includes cloud IMDS at + `169.254.169.254`), reserved, multicast, or unspecified IP — including + such addresses wrapped in IPv4-mapped IPv6 (`::ffff:...`) or 6to4 + (`2002::/16`). This is the SSRF guard required because the URL is + supplied by an LLM agent and may originate from prompt-injected content. + + Note: + This function resolves DNS once. The HTTP client must be pinned to + the returned IP list (see `_pinned_dns`) to close the TOCTOU window + against attacker-controlled DNS (rebinding). + + Args: + url: Candidate URL to validate. + + Returns: + The list of validated IP strings the hostname resolves to. + + Callers should pin the outgoing connection to one of these IPs. + + Raises: + _UrlValidationError: If the URL is malformed, uses a disallowed + scheme, fails DNS resolution, or resolves to a blocked address. + """ + parsed = urlparse(url) + if parsed.scheme not in _ALLOWED_URL_SCHEMES: + msg = f"URL scheme not allowed: {parsed.scheme!r} (must be http or https)" + raise _UrlValidationError(msg) + + hostname = parsed.hostname + if not hostname: + msg = "URL is missing a hostname" + raise _UrlValidationError(msg) + + try: + encoded_hostname = hostname.encode("idna").decode("ascii") + except UnicodeError as exc: + msg = f"Could not encode hostname {hostname!r} as IDNA: {exc}" + raise _UrlValidationError(msg) from exc + + try: + infos = socket.getaddrinfo( + encoded_hostname, + None, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + ) + except socket.gaierror as exc: + msg = f"Could not resolve hostname {hostname!r}: {exc}" + raise _UrlValidationError(msg) from exc + + validated_ips: list[str] = [] + for info in infos: + # `sockaddr[0]` may include an IPv6 scope id (`fe80::1%eth0`); strip + # it before parsing so `ipaddress.ip_address` never raises. + raw_ip = str(info[4][0]).split("%", 1)[0] + ip = ipaddress.ip_address(raw_ip) + if _is_blocked_ip(ip): + logger.warning( + "SSRF guard blocked URL %r: hostname %r resolves to %s", + url, + hostname, + ip, + ) + msg = ( + f"URL hostname {hostname!r} resolves to blocked address {ip} " + "(private, loopback, link-local, reserved, or non-global range)" + ) + raise _UrlValidationError(msg) + validated_ips.append(raw_ip) + + if not validated_ips: + msg = f"Hostname {hostname!r} resolved to no addresses" + raise _UrlValidationError(msg) + + return validated_ips + + +@contextlib.contextmanager +def _pinned_dns(hostname: str, allowed_ips: list[str]) -> Iterator[None]: + """Force outgoing urllib3 connections for `hostname` to use `allowed_ips`. + + Patches `urllib3.util.connection.create_connection` for the duration of + the context so that `requests` cannot re-resolve `hostname` to a + different IP than the one `_validate_url` vetted (defends against DNS + rebinding TOCTOU). The patch is process-global, so the module lock + serializes concurrent fetches. + + Args: + hostname: The exact hostname (already IDNA-encoded by the caller) + whose resolution must be pinned. + allowed_ips: The IPs `_validate_url` confirmed are safe to connect + to. Tried in order; the first that accepts the connection wins. + """ + from urllib3.util import connection as urllib3_connection + + with _dns_pin_lock: + original = urllib3_connection.create_connection + + def patched( + address: tuple[str, int], *args: Any, **kwargs: Any + ) -> socket.socket: + host, port = address[0], address[1] + if host != hostname: + return original(address, *args, **kwargs) + last_exc: OSError | None = None + for ip in allowed_ips: + try: + return original((ip, port), *args, **kwargs) + except OSError as exc: + last_exc = exc + assert last_exc is not None # noqa: S101 # loop body guarantees this + raise last_exc + + urllib3_connection.create_connection = patched # ty: ignore[invalid-assignment] # signature matches at runtime + try: + yield + finally: + urllib3_connection.create_connection = original + + +class _TextExtractor(HTMLParser): + """Extract text content from HTML as a markdownify fallback. + + The character data inside raw-text elements (`script`, `style`, + `noscript`, `template`) is skipped so the fallback never emits + JavaScript or CSS source from the fetched (untrusted) page as page + content. + """ + + # Tags whose character data is never page content. Suppressed via an + # explicit allowlist of skipped tags rather than trying to detect script + # payloads after the fact. + _SKIP_TAGS = frozenset({"script", "style", "noscript", "template"}) + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.parts: list[str] = [] + self._skip_depth = 0 + + def handle_starttag( + self, + tag: str, + attrs: list[tuple[str, str | None]], # noqa: ARG002 # required by HTMLParser override + ) -> None: + """Enter a raw-text element so its data is skipped.""" + if tag in self._SKIP_TAGS: + self._skip_depth += 1 + + def handle_endtag(self, tag: str) -> None: + """Leave a raw-text element.""" + if tag in self._SKIP_TAGS and self._skip_depth: + self._skip_depth -= 1 + + def handle_data(self, data: str) -> None: + """Collect non-empty, whitespace-collapsed text outside skipped tags.""" + if self._skip_depth: + return + text = " ".join(data.split()) + if text: + self.parts.append(text) + + def get_text(self) -> str: + """Return extracted text fragments separated by blank lines.""" + return "\n\n".join(self.parts) + + +def _html_to_markdown_content(html: str, markdownify: Callable[[str], str]) -> str: + """Convert HTML to markdown, falling back to plain text on recursion. + + Args: + html: Raw HTML to convert. + markdownify: The `markdownify.markdownify` callable, injected so this + module avoids an eager top-level import of the optional dependency. + + Returns: + Markdown content, or text extracted from the HTML if markdown + conversion exceeds the recursion limit. Returns an empty string if + the text-extraction fallback itself fails. + """ + try: + return markdownify(html) + except RecursionError: + logger.warning( + "markdownify hit recursion depth; falling back to text extraction", + exc_info=True, + ) + + # Best-effort plain-text extraction. Guard it so a failure here (e.g. the + # same pathological input that exhausted markdownify's recursion) cannot + # re-introduce the uncaught crash this fallback exists to prevent. + try: + parser = _TextExtractor() + parser.feed(html) + parser.close() + except Exception: # fallback is best-effort; must never propagate + logger.warning("text-extraction fallback failed", exc_info=True) + return "" + return parser.get_text() + + +def _get_tavily_client() -> TavilyClient | None: + """Get or initialize the lazy Tavily client singleton. + + Returns: + TavilyClient instance, or None if API key is not configured. + """ + global _tavily_client # noqa: PLW0603 # Module-level cache requires global statement + if _tavily_client is not _UNSET: + return _tavily_client # ty: ignore[invalid-return-type] # narrowed by sentinel check + + from deepagents_code.config import settings + + if settings.has_tavily: + from tavily import TavilyClient as _TavilyClient + + _tavily_client = _TavilyClient(api_key=settings.tavily_api_key) + else: + _tavily_client = None + return _tavily_client + + +@tool +def get_current_thread_id() -> str: + """Get the current Deep Agents thread ID for LangSmith or MCP tooling. + + Returns: + The current `configurable.thread_id`, or an explanatory message if missing. + """ + thread_id = get_config().get("configurable", {}).get("thread_id") + if isinstance(thread_id, str) and thread_id: + return thread_id + return "No current thread ID is available." + + +def web_search( # noqa: ANN201 # Return type depends on dynamic tool configuration + query: Annotated[ + str, + Field(description="The search query (be specific and detailed)."), + ], + max_results: Annotated[ + int, + Field(description="Number of results to return."), + ] = 5, + topic: Annotated[ + Literal["general", "news", "finance"], + Field( + description=( + 'Search topic type: "general" for most queries, "news" for ' + 'current events, or "finance".' + ) + ), + ] = "general", + include_raw_content: Annotated[ + bool, + Field( + description=( + "Include full page content (uses more tokens). Prefer `fetch_url` " + "for a single URL." + ) + ), + ] = False, +): + """Search the web for current information. + + Returns: + Search hits with title, URL, snippet, and score. + """ + try: + import requests + from tavily import ( + BadRequestError, + InvalidAPIKeyError, + MissingAPIKeyError, + UsageLimitExceededError, + ) + from tavily.errors import ForbiddenError, TimeoutError as TavilyTimeoutError + except ImportError as exc: + return {"error": f"Required package not installed: {exc.name}."} + + client = _get_tavily_client() + if client is None: + return { + "error": "Tavily API key not configured. " + "Please set TAVILY_API_KEY environment variable.", + "query": query, + } + + try: + return client.search( + query, + max_results=max_results, + include_raw_content=include_raw_content, + topic=topic, + ) + except ( + requests.exceptions.RequestException, + ValueError, + TypeError, + # Tavily-specific exceptions + BadRequestError, + ForbiddenError, + InvalidAPIKeyError, + MissingAPIKeyError, + TavilyTimeoutError, + UsageLimitExceededError, + ) as e: + return {"error": f"Web search error: {e!s}", "query": query} + + +def fetch_url( + url: Annotated[ + str, + Field(description="The URL to fetch (must be a valid HTTP/HTTPS URL)."), + ], + timeout: Annotated[ + int, + Field(description="Request timeout in seconds."), + ] = 30, +) -> dict[str, Any]: + """Fetch a URL and return the page content as markdown. + + Returns: + Fetched page markdown plus status metadata. + """ + try: + import requests + from markdownify import markdownify + except ImportError as exc: + return {"error": f"Required package not installed: {exc.name}."} + + try: + response = _fetch_with_redirects(url, timeout=timeout) + except _UrlValidationError as e: + return { + "error": f"Fetch URL error: {e!s}", + "url": url, + "category": "validation", + } + except requests.exceptions.TooManyRedirects as e: + return {"error": f"Fetch URL error: {e!s}", "url": url, "category": "redirects"} + except requests.exceptions.RequestException as e: + return {"error": f"Fetch URL error: {e!s}", "url": url, "category": "network"} + + markdown_content = _html_to_markdown_content(response.text, markdownify) + if not markdown_content: + logger.warning( + "fetch_url produced empty content for %s (status %s)", + response.url, + response.status_code, + ) + return { + "url": str(response.url), + "markdown_content": markdown_content, + "status_code": response.status_code, + "content_length": len(markdown_content), + } + + +def _fetch_with_redirects(url: str, *, timeout: int) -> Any: # noqa: ANN401 # requests.Response, but kept dynamic to avoid eager import + """Fetch `url`, re-validating each redirect hop against the SSRF guard. + + Each hop is validated by `_validate_url` and its connection pinned to + the validated IP via `_pinned_dns`. Caps at `_MAX_FETCH_REDIRECTS` + redirects (so up to `_MAX_FETCH_REDIRECTS + 1` total hops counting the + initial request). Network/HTTP errors propagate as + `requests.exceptions.RequestException` (or its subclasses). + + Args: + url: Initial URL to fetch. + timeout: Per-request timeout in seconds. + + Returns: + The final `requests.Response` for the non-redirect terminal hop. + + Raises: + _UrlValidationError: If any hop fails SSRF validation or returns a + 3xx without a `Location` header. + requests.exceptions.TooManyRedirects: If the redirect cap is exceeded. + """ + import requests + + current_url = url + session = requests.Session() + # DNS pinning only protects the direct target connection. Environment + # proxies resolve the target separately, so they must be disabled here. + session.trust_env = False + for _hop in range(_MAX_FETCH_REDIRECTS + 1): + validated_ips = _validate_url(current_url) + hostname = urlparse(current_url).hostname + # `_validate_url` raises if hostname is missing, so this is non-None. + assert hostname is not None # noqa: S101 # invariant from _validate_url + encoded_hostname = hostname.encode("idna").decode("ascii") + + with _pinned_dns(encoded_hostname, validated_ips): + response = session.get( + current_url, + timeout=timeout, + headers={"User-Agent": "Mozilla/5.0 (compatible; DeepAgents/1.0)"}, + allow_redirects=False, + ) + + # 300-399 covers every redirect class. `requests.Response.is_redirect` + # also checks for a `Location` header, which would hide malformed 3xx + # responses — so we check the raw status code instead. + if 300 <= response.status_code < 400: # noqa: PLR2004 # HTTP redirect class + location = response.headers.get("Location") + if not location: + msg = ( + f"Redirect response (status {response.status_code}) at " + f"{current_url!r} is missing a Location header" + ) + raise _UrlValidationError(msg) + current_url = urljoin(current_url, location) + continue + + response.raise_for_status() + return response + + msg = f"Exceeded {_MAX_FETCH_REDIRECTS} redirects starting from {url!r}" + raise requests.exceptions.TooManyRedirects(msg) diff --git a/libs/code/deepagents_code/tui/__init__.py b/libs/code/deepagents_code/tui/__init__.py new file mode 100644 index 0000000000..5fa461721f --- /dev/null +++ b/libs/code/deepagents_code/tui/__init__.py @@ -0,0 +1 @@ +"""Textual user interface package for `deepagents-code`.""" diff --git a/libs/code/deepagents_code/tui/modals/__init__.py b/libs/code/deepagents_code/tui/modals/__init__.py new file mode 100644 index 0000000000..3ecb5ebfce --- /dev/null +++ b/libs/code/deepagents_code/tui/modals/__init__.py @@ -0,0 +1 @@ +"""Modal UI components.""" diff --git a/libs/code/deepagents_code/tui/modals/plugin_manager/__init__.py b/libs/code/deepagents_code/tui/modals/plugin_manager/__init__.py new file mode 100644 index 0000000000..09921d4514 --- /dev/null +++ b/libs/code/deepagents_code/tui/modals/plugin_manager/__init__.py @@ -0,0 +1,1067 @@ +"""Interactive plugin manager screen.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, ClassVar + +from textual import work +from textual.binding import Binding, BindingType +from textual.containers import Horizontal, Vertical +from textual.content import Content +from textual.css.query import NoMatches +from textual.screen import ModalScreen +from textual.widgets import Input, OptionList, Rule, Static +from textual.widgets.option_list import Option, OptionDoesNotExist + +from deepagents_code.tui.widgets.loading import Spinner + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence, Set as AbstractSet + + from textual.app import ComposeResult + from textual.events import Key + from textual.timer import Timer + + from deepagents_code.mcp_tools import MCPServerInfo + from deepagents_code.plugins.models import PluginMarketplace + from deepagents_code.tui.modals.plugin_manager.models import ( + PluginManagerView, + _MarketplaceRow, + _PluginRow, + ) + +from deepagents_code import theme +from deepagents_code.config import get_glyphs, is_ascii_mode +from deepagents_code.model_config import _save_toml_field +from deepagents_code.plugins import ( + add_marketplace_source, + install_plugin, + remove_marketplace, + set_installed_plugin_enabled, + uninstall_plugin, +) +from deepagents_code.plugins.discovery import plugin_auto_update_setting +from deepagents_code.plugins.marketplace import MarketplaceError +from deepagents_code.tui.modals.plugin_manager.content import ( + _confirm_marketplace_removal_options, + _install_details_options, + _installed_details_options, + _installed_plugin_details_content, + _marketplace_details_content, + _marketplace_details_options, + _marketplace_label, + _marketplace_removal_content, + _plugin_details_content, + _plugin_options, +) +from deepagents_code.tui.modals.plugin_manager.models import ( + PluginTab, + _ManagerState, +) +from deepagents_code.tui.modals.plugin_manager.state import _load_manager_state +from deepagents_code.tui.modals.plugin_manager.tabs import ( + TAB_LABELS, + PluginTabLabel, + PluginTabSelected, +) + +logger = logging.getLogger(__name__) # noqa: RUF067 # module-level logger + + +class PluginManagerScreen(ModalScreen[None]): # noqa: RUF067 + """Arrow-key navigable plugin manager for `/plugins`. + + When plugin state changed while the manager was open, a reload prompt is + shown after this screen closes offering to apply the changes via `/reload`; + an unchanged close shows nothing. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "cancel", "Close", show=False, priority=True), + # Separate from tab/shift+tab so check_action can release arrows to a + # non-empty Input for caret movement while keeping Tab as tab cycling. + Binding( + "left", "arrow_previous_tab", "Previous tab", show=False, priority=True + ), + Binding("right", "arrow_next_tab", "Next tab", show=False, priority=True), + Binding("tab", "next_tab", "Next tab", show=False, priority=True), + Binding("shift+tab", "previous_tab", "Previous tab", show=False, priority=True), + Binding("up", "cursor_up", "Up", show=False, priority=True), + Binding("down", "cursor_down", "Down", show=False, priority=True), + Binding("/", "focus_search", "Search", show=False, priority=True), + ] + + CSS_PATH = "plugin_manager.tcss" + # Prefer the option list over the search Input so Enter activates rows on + # open; `/` still focuses search explicitly. + AUTO_FOCUS = "#plugin-manager-options" + + # Divider width used before the options list has been laid out (e.g. in unit + # tests that build options off-screen). At render time the divider is sized to + # the measured options width instead so it never wraps on a narrower modal. + _DIVIDER_FALLBACK_WIDTH: ClassVar[int] = 72 + + _tabs: ClassVar[tuple[PluginTab, ...]] = ( + "discover", + "installed", + "marketplaces", + "errors", + "settings", + ) + + def __init__( + self, + *, + mcp_server_info: Sequence[MCPServerInfo] = (), + loaded_plugin_ids: AbstractSet[str] | None = None, + on_auto_update_enabled: Callable[[], None] | None = None, + ) -> None: + """Initialize the plugin manager. + + Args: + mcp_server_info: Live MCP server metadata from the running session, + used to show connection status for plugins that declare MCP + servers. + loaded_plugin_ids: Plugin ids loaded into the current session. + Plugins whose enabled state differs from this set (enabled but + not loaded, or disabled but still loaded) are shown as pending + reload. + on_auto_update_enabled: Called after auto-update is enabled. + """ + super().__init__() + self._tab: PluginTab = "discover" + self._mode: PluginManagerView = "list" + self._mcp_server_info = mcp_server_info + self._loaded_plugin_ids: frozenset[str] = frozenset(loaded_plugin_ids or ()) + self._on_auto_update_enabled = on_auto_update_enabled + self._state = _ManagerState((), (), (), ()) + self._status: str | None = None + self._error: str | None = None + self._selected_plugin: _PluginRow | None = None + self._selected_marketplace: _MarketplaceRow | None = None + self._adding_marketplace = False + self._marketplace_spinner = Spinner() + self._marketplace_spinner_timer: Timer | None = None + self._search_query = "" + self._auto_update_enabled = False + self._auto_update_source = "default" + + def compose(self) -> ComposeResult: + """Compose the manager screen. + + Yields: + Widgets for the plugin manager UI. + """ + with Vertical(): + yield Static( + "Plugins", id="plugin-manager-title", classes="plugin-manager-title" + ) + with Horizontal(id="plugin-manager-tabs", classes="plugin-manager-tabs"): + for tab in self._tabs: + yield PluginTabLabel(tab, TAB_LABELS[tab]) + yield Rule( + line_style="heavy" if not is_ascii_mode() else "ascii", + id="plugin-manager-divider", + classes="plugin-manager-divider", + ) + yield Static( + "", + id="plugin-manager-status", + classes="plugin-manager-status", + markup=False, + ) + yield Static( + "", + id="plugin-manager-error", + classes="plugin-manager-error", + markup=False, + ) + yield Input( + placeholder="Search plugins...", + select_on_focus=False, + id="plugin-manager-search", + ) + yield OptionList(id="plugin-manager-options") + yield Input( + placeholder="", + id="plugin-marketplace-source", + ) + yield Static("", id="plugin-manager-help", classes="plugin-manager-help") + + async def on_mount(self) -> None: + """Apply initial render, then load plugin state off the UI thread.""" + if is_ascii_mode(): + container = self.query_one(Vertical) + colors = theme.get_theme_colors(self) + container.styles.border = ("ascii", colors.success) + self._status = "Loading plugins..." + self._refresh_view() + await self._refresh_state() + if self._status == "Loading plugins...": + self._status = None + self._refresh_view() + + def on_resize(self) -> None: + """Refit width-sized dividers when the modal resizes.""" + marketplace_divider_visible = ( + self._mode == "list" + and self._tab == "marketplaces" + and bool(self._state.marketplaces) + ) + details_divider_visible = ( + self._mode == "installed_details" and self._selected_plugin is not None + ) + if marketplace_divider_visible or details_divider_visible: + self._refresh_view() + + def _update_tab_labels(self) -> None: + """Refresh active styling on each clickable tab label.""" + for tab in self._tabs: + self.query_one(f"#plugin-tab-{tab}", PluginTabLabel).set_active( + tab == self._tab + ) + + def _select_tab(self, tab: PluginTab) -> None: + """Activate `tab`, exiting details into list mode when needed. + + Args: + tab: Tab to show. + """ + if self._mode == "add_marketplace": + return + if self._details_mode_active(): + self._mode = "list" + self._selected_plugin = None + self._selected_marketplace = None + if tab != self._tab: + # A query typed on one tab should not silently filter another. + self._search_query = "" + self._tab = tab + self._error = None + self._refresh_view() + + def _search_available(self) -> bool: + """Whether the plugin filter should be shown and focusable. + + Returns: + `True` when list mode has plugins that can be filtered. + """ + if self._mode != "list": + return False + if self._tab == "discover": + return bool(self._state.marketplaces and self._state.available_plugins) + if self._tab == "installed": + return bool(self._state.installed_plugins) + return False + + def _filtered_plugins(self, rows: Sequence[_PluginRow]) -> tuple[_PluginRow, ...]: + query = self._search_query.strip().casefold() + if not query: + return tuple(rows) + return tuple( + row + for row in rows + if query in row.plugin_id.casefold() + or query in row.label.casefold() + or query in row.description.casefold() + ) + + def _current_options(self) -> list[Option]: + glyphs = get_glyphs() + if self._tab == "discover": + if not self._state.marketplaces: + return [ + Option( + "No marketplaces installed. Add one to discover plugins.", + id="empty", + disabled=True, + ), + Option("+ Add marketplace", id="add-marketplace"), + ] + if not self._state.available_plugins: + return [Option("All available plugins are installed.", id="empty")] + rows = self._filtered_plugins(self._state.available_plugins) + if not rows: + return [ + Option("No plugins match your search.", id="empty", disabled=True) + ] + return _plugin_options(rows, action="detail", status=None) + if self._tab == "installed": + if not self._state.installed_plugins: + return [Option("No plugins installed.", id="empty")] + rows = self._filtered_plugins(self._state.installed_plugins) + if not rows: + return [ + Option( + "No installed plugins match your search.", + id="empty", + disabled=True, + ) + ] + return _plugin_options(rows, action="installed", status=None) + if self._tab == "marketplaces": + options = [Option("+ Add marketplace", id="add-marketplace")] + if self._state.marketplaces: + options.append( + Option( + Content.styled( + glyphs.box_horizontal * self._divider_width(), "dim" + ), + id="marketplace-divider", + disabled=True, + ) + ) + for index, row in enumerate(self._state.marketplaces): + if index > 0: + options.append( + Option(" ", id=f"marketplace-spacer:{index}", disabled=True) + ) + options.append( + Option( + _marketplace_label(row), + id=f"marketplace:{row.name}", + ) + ) + return options + if self._tab == "settings": + label = "enabled" if self._auto_update_enabled else "disabled" + env_override = self._auto_update_source.startswith("env (") + suffix = " (set by environment)" if env_override else "" + return [ + Option( + f"Auto-update plugins: {label}{suffix}", + id="action:toggle-auto-update", + disabled=env_override, + ) + ] + if not self._state.errors: + return [Option("No plugin errors.", id="empty")] + return [Option(Content(error), id="empty") for error in self._state.errors] + + def _divider_width(self) -> int: + """Width for option-list dividers, sized to the available content. + + The options list respects the modal's `max-width`, so a fixed width wraps on + terminals narrower than the full modal. Measure the laid-out content width when + available and fall back to a constant before the first layout (e.g. in tests). + + Returns: + The measured options content width, or `_DIVIDER_FALLBACK_WIDTH` if the + options list is not mounted or has not been laid out yet. + """ + try: + width = self.query_one( + "#plugin-manager-options", OptionList + ).content_size.width + except NoMatches: + return self._DIVIDER_FALLBACK_WIDTH + return width if width > 0 else self._DIVIDER_FALLBACK_WIDTH + + @staticmethod + def _nearest_enabled_index(options: OptionList, candidate: int) -> int | None: + """Return the nearest selectable option index to `candidate`. + + Scope-group header rows (and spacers) are disabled options, so a + highlighted index carried over from a previous tab/refresh can land + on one after the option count or grouping changes. Scans forward + first, then backward, so the cursor always rests on a real row. + + Args: + options: Option list to scan. + candidate: Preferred index (already clamped to bounds). + + Returns: + `candidate` if selectable, the nearest selectable index otherwise, + or `None` if every option (or the list itself) is disabled/empty. + """ + if not options.option_count: + return None + if not options.get_option_at_index(candidate).disabled: + return candidate + for index in range(candidate + 1, options.option_count): + if not options.get_option_at_index(index).disabled: + return index + for index in range(candidate - 1, -1, -1): + if not options.get_option_at_index(index).disabled: + return index + return None + + def _details_mode_active(self) -> bool: + return self._mode in { + "plugin_details", + "installed_details", + "marketplace_details", + "confirm_remove_marketplace", + } + + def _refresh_view(self) -> None: + title = self.query_one("#plugin-manager-title", Static) + tabs = self.query_one("#plugin-manager-tabs", Horizontal) + divider = self.query_one("#plugin-manager-divider", Rule) + self._update_tab_labels() + status_widget = self.query_one("#plugin-manager-status", Static) + if self._mode == "plugin_details" and self._selected_plugin is not None: + status_widget.update(_plugin_details_content(self._selected_plugin)) + elif self._mode == "installed_details" and self._selected_plugin is not None: + status_widget.update( + _installed_plugin_details_content(self._selected_plugin) + ) + elif ( + self._mode == "marketplace_details" + and self._selected_marketplace is not None + ): + status_widget.update( + _marketplace_details_content(self._selected_marketplace) + ) + elif ( + self._mode == "confirm_remove_marketplace" + and self._selected_marketplace is not None + ): + status_widget.update( + _marketplace_removal_content(self._selected_marketplace) + ) + else: + status_widget.update(self._status or "") + error = self._error or "" + self.query_one("#plugin-manager-error", Static).update(error) + + options = self.query_one("#plugin-manager-options", OptionList) + search_input = self.query_one("#plugin-manager-search", Input) + source_input = self.query_one("#plugin-marketplace-source", Input) + help_text = self.query_one("#plugin-manager-help", Static) + glyphs = get_glyphs() + + if self._mode == "add_marketplace": + title.update("Add Marketplace") + tabs.display = False + divider.display = False + if self._status is None: + status_widget.update( + "Enter marketplace source:\n" + "\n" + "Examples:\n" + f" {glyphs.bullet} owner/repo (GitHub)\n" + f" {glyphs.bullet} git@github.com:owner/repo.git (SSH)\n" + f" {glyphs.bullet} https://example.com/marketplace.json\n" + f" {glyphs.bullet} ./path/to/marketplace" + ) + options.display = False + search_input.display = False + source_input.display = True + source_input.focus() + help_text.update(f"Enter to add {glyphs.bullet} Esc to cancel") + return + + title.update("Plugins") + tabs.display = True + divider.display = True + + if self._details_mode_active(): + search_input.display = False + source_input.display = False + options.display = True + highlighted = options.highlighted + highlighted_id = ( + None + if highlighted is None + else options.get_option_at_index(highlighted).id + ) + options.clear_options() + detail_options = self._active_details_options() + for option in detail_options: + options.add_option(option) + if options.option_count: + candidate = 0 + if highlighted_id is not None: + try: + candidate = options.get_option_index(highlighted_id) + except OptionDoesNotExist: + candidate = 0 + options.highlighted = self._nearest_enabled_index(options, candidate) + options.focus() + help_text.update( + f"{glyphs.arrow_up}/{glyphs.arrow_down} select {glyphs.bullet} " + f"Enter choose {glyphs.bullet} Esc back" + ) + return + + source_input.display = False + options.display = True + search_input.display = self._search_available() + if search_input.display and search_input.value != self._search_query: + search_input.value = self._search_query + highlighted = options.highlighted + options.clear_options() + for option in self._current_options(): + options.add_option(option) + if options.option_count: + candidate = ( + 0 if highlighted is None else min(highlighted, options.option_count - 1) + ) + options.highlighted = self._nearest_enabled_index(options, candidate) + if not search_input.has_focus: + options.focus() + + if self._tab == "marketplaces": + help_text.update( + f"{glyphs.arrow_up}/{glyphs.arrow_down} select {glyphs.bullet} " + f"Enter add/view {glyphs.bullet} " + f"Left/Right tabs {glyphs.bullet} Esc close" + ) + elif self._tab in {"discover", "installed"}: + if self._tab == "installed": + action = "view" + elif not self._state.marketplaces: + action = "add marketplace" + else: + action = "install" + search_hint = ( + f"/ search {glyphs.bullet} " if self._search_available() else "" + ) + help_text.update( + f"{glyphs.arrow_up}/{glyphs.arrow_down} select {glyphs.bullet} " + f"Enter {action} {glyphs.bullet} {search_hint}Left/Right tabs " + f"{glyphs.bullet} Esc close" + ) + else: + help_text.update(f"Left/Right tabs {glyphs.bullet} Esc close") + + def _active_details_options(self) -> list[Option]: + if self._mode == "plugin_details": + return _install_details_options() + if self._mode == "installed_details" and self._selected_plugin is not None: + return _installed_details_options( + self._selected_plugin, divider_width=self._divider_width() + ) + if ( + self._mode == "marketplace_details" + and self._selected_marketplace is not None + ): + return _marketplace_details_options() + if ( + self._mode == "confirm_remove_marketplace" + and self._selected_marketplace is not None + ): + return _confirm_marketplace_removal_options(self._selected_marketplace) + return [Option("Back to plugin list", id="details-back")] + + async def _refresh_state(self) -> None: + # A mutating action (install/toggle/uninstall/marketplace change) reloads + # state and shows a fresh list, so a leftover query would hide results. + # Details round-trips use `_refresh_view` instead and keep the query. + self._search_query = "" + self._state = await asyncio.to_thread( + _load_manager_state, + self._mcp_server_info, + loaded_plugin_ids=self._loaded_plugin_ids, + ) + self._auto_update_enabled, self._auto_update_source = await asyncio.to_thread( + plugin_auto_update_setting + ) + if self._selected_plugin is not None: + refreshed = self._find_installed_plugin(self._selected_plugin.plugin_id) + if refreshed is None: + refreshed = self._find_available_plugin(self._selected_plugin.plugin_id) + self._selected_plugin = refreshed + if self._selected_marketplace is not None: + self._selected_marketplace = self._find_marketplace( + self._selected_marketplace.name + ) + self._refresh_view() + + def check_action( + self, + action: str, + parameters: tuple[object, ...], # noqa: ARG002 # required by Textual's DOMNode.check_action override signature + ) -> bool | None: + """Gate priority bindings that would otherwise steal Input keystrokes. + + `/` is enabled only when the plugin filter is visible and not focused, + so it remains typeable in the filter and Add Marketplace source field, + and cannot steal focus while the filter is hidden. + Left/right release to a focused Input once it has at least one character, + so caret movement works while empty-field arrows keep switching tabs. + + Args: + action: Textual action name being considered for dispatch. + parameters: Parameters associated with the action. + + Returns: + `False` to step a binding aside so the focused widget receives the + key; `True` to allow the action. + """ + if action in {"arrow_previous_tab", "arrow_next_tab"}: + focused = self.focused + return not (isinstance(focused, Input) and bool(focused.value)) + if action == "focus_search": + if not self._search_available(): + return False + try: + return not self.query_one("#plugin-manager-search", Input).has_focus + except NoMatches: + return True + return True + + def on_key(self, event: Key) -> None: + """Focus plugin search when a letter is typed from another control. + + Seed the character into the filter before focusing so the input never + paints empty with a caret flash (and so `select_on_focus` cannot leave + the inserted text selected for the next keypress). + """ + if not self._search_available(): + return + + search_input = self.query_one("#plugin-manager-search", Input) + if search_input.has_focus: + return + + character = event.character + if not character or not character.isalpha(): + return + + # Mutate first, then focus, so the first focused frame already shows the + # typed letter — matching type-to-search in the thread filter. + new_value = f"{search_input.value}{character}" + search_input.value = new_value + search_input.selection = type(search_input.selection).cursor(len(new_value)) + search_input.focus() + event.stop() + + def on_plugin_tab_selected(self, event: PluginTabSelected) -> None: + """Switch tabs from a mouse click on a tab label. + + Args: + event: Tab selection message from `PluginTabLabel`. + """ + self._select_tab(event.tab) + + def action_cancel(self) -> None: + """Clear a query, leave a prompt or details, or close the manager.""" + if self._adding_marketplace: + return + search_input = self.query_one("#plugin-manager-search", Input) + if search_input.has_focus: + if self._search_query: + self._search_query = "" + search_input.value = "" + self._refresh_view() + search_input.focus() + else: + self.query_one("#plugin-manager-options", OptionList).focus() + return + if self._mode == "add_marketplace": + self._mode = "list" + self._error = None + self._refresh_view() + return + if self._details_mode_active(): + if self._mode == "confirm_remove_marketplace": + self._mode = "marketplace_details" + self._error = None + self._refresh_view() + return + self._mode = "list" + self._selected_plugin = None + self._selected_marketplace = None + self._error = None + self._refresh_view() + return + self.dismiss(None) + + def action_focus_search(self) -> None: + """Focus the plugin filter when it is visible.""" + if self._search_available(): + self.query_one("#plugin-manager-search", Input).focus() + + def _cycle_details_option(self, step: int) -> None: + options = self.query_one("#plugin-manager-options", OptionList) + enabled = [ + index + for index in range(options.option_count) + if not options.get_option_at_index(index).disabled + ] + if not enabled: + return + current = options.highlighted + if current is not None and current in enabled: + position = enabled.index(current) + options.highlighted = enabled[(position + step) % len(enabled)] + else: + # Nothing highlighted yet: step forward to the first option, back to + # the last. + options.highlighted = enabled[0] if step > 0 else enabled[-1] + options.focus() + + def action_arrow_next_tab(self) -> None: + """Switch tabs via right arrow when the caret is not editing text.""" + self.action_next_tab() + + def action_arrow_previous_tab(self) -> None: + """Switch tabs via left arrow when the caret is not editing text.""" + self.action_previous_tab() + + def action_next_tab(self) -> None: + """Switch tabs or focus the next details option.""" + if self._details_mode_active(): + self._cycle_details_option(1) + return + if self._mode != "list": + return + index = self._tabs.index(self._tab) + self._select_tab(self._tabs[(index + 1) % len(self._tabs)]) + + def action_previous_tab(self) -> None: + """Switch tabs or focus the previous details option.""" + if self._details_mode_active(): + self._cycle_details_option(-1) + return + if self._mode != "list": + return + index = self._tabs.index(self._tab) + self._select_tab(self._tabs[(index - 1) % len(self._tabs)]) + + def action_cursor_down(self) -> None: + """Move the option-list cursor down.""" + if self._mode in { + "list", + "plugin_details", + "installed_details", + "marketplace_details", + "confirm_remove_marketplace", + }: + self.query_one("#plugin-manager-options", OptionList).action_cursor_down() + + def action_cursor_up(self) -> None: + """Move the option-list cursor up.""" + if self._mode in { + "list", + "plugin_details", + "installed_details", + "marketplace_details", + "confirm_remove_marketplace", + }: + self.query_one("#plugin-manager-options", OptionList).action_cursor_up() + + async def on_option_list_option_selected( + self, event: OptionList.OptionSelected + ) -> None: + """Handle row activation.""" + option_id = event.option.id + if option_id is None or option_id == "empty": + return + if option_id == "add-marketplace": + self._mode = "add_marketplace" + self._status = None + self._error = None + self.query_one("#plugin-marketplace-source", Input).value = "" + self._refresh_view() + return + if option_id.startswith("marketplace:"): + name = option_id.removeprefix("marketplace:") + row = self._find_marketplace(name) + if row is None: + return + self._selected_marketplace = row + self._selected_plugin = None + self._mode = "marketplace_details" + self._status = None + self._error = None + self._refresh_view() + return + if option_id.startswith("detail:"): + plugin_id = option_id.removeprefix("detail:") + row = self._find_available_plugin(plugin_id) + if row is None: + return + self._selected_plugin = row + self._mode = "plugin_details" + self._error = None + self._refresh_view() + return + if option_id.startswith("installed:"): + plugin_id = option_id.removeprefix("installed:") + row = self._find_installed_plugin(plugin_id) + if row is None: + return + self._selected_plugin = row + self._mode = "installed_details" + self._error = None + self._status = None + self._refresh_view() + return + if option_id == "action:toggle-auto-update": + await self._toggle_auto_update() + return + if option_id == "action:install": + await self._install_selected_plugin() + return + if option_id == "action:toggle-enabled": + await self._toggle_selected_plugin_enabled() + return + if option_id == "action:uninstall": + await self._uninstall_selected_plugin() + return + if option_id == "action:remove-marketplace": + self._mode = "confirm_remove_marketplace" + self._error = None + self._refresh_view() + return + if option_id == "action:confirm-remove-marketplace": + await self._remove_selected_marketplace() + return + if option_id == "details-back": + self._mode = ( + "marketplace_details" + if self._mode == "confirm_remove_marketplace" + else "list" + ) + self._selected_plugin = None + if self._mode == "list": + self._selected_marketplace = None + self._refresh_view() + + def _find_available_plugin(self, plugin_id: str) -> _PluginRow | None: + return next( + ( + row + for row in self._state.available_plugins + if row.plugin_id == plugin_id + ), + None, + ) + + def _find_installed_plugin(self, plugin_id: str) -> _PluginRow | None: + return next( + ( + row + for row in self._state.installed_plugins + if row.plugin_id == plugin_id + ), + None, + ) + + def _find_marketplace(self, name: str) -> _MarketplaceRow | None: + return next( + (row for row in self._state.marketplaces if row.name == name), + None, + ) + + async def _toggle_auto_update(self) -> None: + enabled = not self._auto_update_enabled + if not await asyncio.to_thread( + _save_toml_field, "plugins", "auto_update", enabled + ): + self._error = "Could not save the plugin auto-update setting." + self._status = None + self._refresh_view() + return + self._auto_update_enabled = enabled + self._auto_update_source = "config.toml" + self._status = f"Plugin auto-updates {'enabled' if enabled else 'disabled'}." + self._error = None + self._refresh_view() + if enabled and self._on_auto_update_enabled is not None: + self._on_auto_update_enabled() + + async def _install_selected_plugin(self) -> None: + row = self._selected_plugin + if row is None: + return + try: + instance = await asyncio.to_thread(install_plugin, row.plugin_id) + except (MarketplaceError, FileNotFoundError, OSError, ValueError) as exc: + self._error = str(exc) + self._status = None + self._refresh_view() + return + from deepagents_code.plugins.adapters.mcp import plugin_mcp_server_entries + + label = row.label + needs_login = any( + needs_login + for _server_label, _scoped, needs_login in plugin_mcp_server_entries( + instance + ) + ) + self.notify(f"Installed {label}", timeout=5, markup=False) + self._mode = "list" + self._tab = "installed" + self._selected_plugin = None + self._status = f"Installed {label}. Run /reload to activate." + if needs_login: + self._status += f" After reload, sign in to {label} via /mcp." + self._error = None + await self._refresh_state() + + async def _toggle_selected_plugin_enabled(self) -> None: + row = self._selected_plugin + if row is None: + return + try: + await asyncio.to_thread( + set_installed_plugin_enabled, row.plugin_id, enabled=not row.enabled + ) + if row.enabled: + self._status = f"Disabled {row.label}. Run /reload to unload." + self._mode = "list" + self._selected_plugin = None + else: + self._status = f"Enabled {row.label}. Run /reload to activate." + self._mode = "list" + self._tab = "installed" + self._selected_plugin = None + except OSError as exc: + self._error = f"Could not update plugin state: {exc}" + self._status = None + self._refresh_view() + return + self._error = None + await self._refresh_state() + + async def _uninstall_selected_plugin(self) -> None: + row = self._selected_plugin + if row is None: + return + try: + await asyncio.to_thread(uninstall_plugin, row.plugin_id) + except OSError as exc: + self._error = f"Could not uninstall plugin: {exc}" + self._status = None + self._refresh_view() + return + self._mode = "list" + self._selected_plugin = None + reload_hint = " Run /reload to unload." if row.enabled else "" + self._status = f"Uninstalled {row.label}.{reload_hint}" + self._error = None + await self._refresh_state() + + async def _remove_selected_marketplace(self) -> None: + row = self._selected_marketplace + if row is None: + return + self._status = f"Removing marketplace {row.name}..." + self._error = None + try: + removed = await asyncio.to_thread(remove_marketplace, row.name) + except OSError as exc: + self._status = None + self._error = f"Could not remove marketplace: {exc}" + self._refresh_view() + return + if not removed: + self._status = None + self._error = f"Marketplace {row.name} is no longer configured." + await self._refresh_state() + return + plugin_label = "plugin" if row.installed_count == 1 else "plugins" + self._mode = "list" + self._tab = "marketplaces" + self._selected_marketplace = None + self._status = ( + f"Removed marketplace {row.name} and uninstalled " + f"{row.installed_count} {plugin_label}." + ) + self._error = None + await self._refresh_state() + + def on_input_changed(self, event: Input.Changed) -> None: + """Filter the current plugin list as the search query changes.""" + if event.input.id != "plugin-manager-search": + return + self._search_query = event.value + self._refresh_view() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Activate a search result or start adding a marketplace.""" + if event.input.id == "plugin-manager-search": + event.stop() + options = self.query_one("#plugin-manager-options", OptionList) + highlighted = options.highlighted + if highlighted is None: + return + option_id = options.get_option_at_index(highlighted).id + if option_id is None or not option_id.startswith(("detail:", "installed:")): + return + options.focus() + options.action_select() + return + if event.input.id != "plugin-marketplace-source" or self._adding_marketplace: + return + source = event.value.strip() + if not source: + self._error = "Please enter a marketplace source." + self._refresh_view() + return + self._adding_marketplace = True + event.input.disabled = True + self._error = None + self._marketplace_spinner_timer = self.set_interval( + 0.1, self._tick_marketplace_spinner + ) + self._tick_marketplace_spinner() + self._add_marketplace(source) + + def _tick_marketplace_spinner(self) -> None: + self._status = f"{self._marketplace_spinner.next_frame()} Adding marketplace..." + self._refresh_view() + + @work(thread=True, exclusive=True, exit_on_error=False) + def _add_marketplace(self, source: str) -> None: + try: + marketplace = add_marketplace_source(source) + except (MarketplaceError, OSError, RuntimeError) as exc: + self.app.call_from_thread(self._finish_marketplace_add, None, str(exc)) + return + except Exception as exc: + # Any exception outside the expected set must still route through + # _finish_marketplace_add: with exit_on_error=False the worker + # crash is otherwise swallowed, leaving _adding_marketplace latched + # (spinner running, input disabled, Escape blocked by action_cancel) + # and the manager permanently unrecoverable. + logger.exception("Unexpected error adding marketplace source") + self.app.call_from_thread( + self._finish_marketplace_add, None, f"Unexpected error: {exc}" + ) + return + self.app.call_from_thread(self._finish_marketplace_add, marketplace, None) + + async def _finish_marketplace_add( + self, marketplace: PluginMarketplace | None, error: str | None + ) -> None: + if self._marketplace_spinner_timer is not None: + self._marketplace_spinner_timer.stop() + self._marketplace_spinner_timer = None + source_input = self.query_one("#plugin-marketplace-source", Input) + + def release_guard() -> None: + """Re-enable the input and unblock Escape now the add has settled.""" + self._adding_marketplace = False + source_input.disabled = False + + if error is not None: + release_guard() + self._status = None + self._error = f"Could not add marketplace: {error}" + self._refresh_view() + source_input.focus() + return + if marketplace is None: + release_guard() + return + self._mode = "list" + self._tab = "discover" + self._status = ( + f"Added marketplace {marketplace.name} " + f"({len(marketplace.plugins)} plugin(s))." + ) + self._error = None + # Keep the guard set until the refresh finishes so Escape stays blocked + # while _refresh_state() runs. + try: + await self._refresh_state() + finally: + release_guard() diff --git a/libs/code/deepagents_code/tui/modals/plugin_manager/content.py b/libs/code/deepagents_code/tui/modals/plugin_manager/content.py new file mode 100644 index 0000000000..a122a5481f --- /dev/null +++ b/libs/code/deepagents_code/tui/modals/plugin_manager/content.py @@ -0,0 +1,305 @@ +"""Pure plugin manager content builders.""" + +from typing import Literal + +from textual.content import Content +from textual.widgets.option_list import Option + +from deepagents_code.config import get_glyphs +from deepagents_code.tui.modals.plugin_manager.models import _MarketplaceRow, _PluginRow + + +def _plugin_options( + rows: tuple[_PluginRow, ...], + *, + action: Literal["detail", "installed"], + status: str | None, +) -> list[Option]: + options: list[Option] = [] + for index, row in enumerate(rows): + if index > 0: + options.append(Option(" ", id=f"spacer:{index}", disabled=True)) + options.append( + Option(_plugin_prompt(row, status=status), id=f"{action}:{row.plugin_id}") + ) + return options + + +def _load_state_label(row: _PluginRow) -> str | None: + glyphs = get_glyphs() + if row.load_state == "error": + return "error" + if row.load_state == "pending_reload": + return "pending /reload" + if row.load_state == "enabled": + return f"{glyphs.checkmark} enabled" + return None + + +def _plugin_prompt(row: _PluginRow, *, status: str | None) -> Content: + glyphs = get_glyphs() + _, _, marketplace = row.plugin_id.partition("@") + meta_parts = [Content.styled("Plugin", "dim"), Content.styled(marketplace, "dim")] + load_label = _load_state_label(row) + if load_label: + if row.load_state == "enabled": + meta_parts.append(Content.styled(load_label, "bold")) + elif row.load_state == "error": + meta_parts.append(Content.styled(load_label, "bold $error")) + else: + meta_parts.append(Content.styled(load_label, "dim")) + if row.skill_count: + skill_label = "skill" if row.skill_count == 1 else "skills" + meta_parts.append(Content.styled(f"{row.skill_count} {skill_label}", "dim")) + if row.load_state == "enabled": + if row.mcp_connected is True: + meta_parts.append(Content.styled(f"{glyphs.checkmark} connected", "dim")) + elif row.mcp_connected is False: + meta_parts.append(Content.styled("run /reload to connect", "bold $warning")) + elif ( + row.load_state == "pending_reload" + and row.session_loaded + and row.mcp_connected is True + ): + meta_parts.append(Content.styled(f"{glyphs.checkmark} connected", "dim")) + if status: + meta_parts.append(Content.styled(status, "dim")) + separator = Content.styled(" · ", "dim") + return Content.assemble( + row.label, + separator, + separator.join(meta_parts), + "\n ", + Content.styled(row.description or "No description provided.", "dim"), + ) + + +def _install_details_options() -> list[Option]: + return [ + Option("Install", id="action:install"), + Option("Back to plugin list", id="details-back"), + ] + + +def _installed_details_options(row: _PluginRow, *, divider_width: int) -> list[Option]: + glyphs = get_glyphs() + return [ + Option( + "Disable plugin" if row.enabled else "Enable plugin", + id="action:toggle-enabled", + ), + Option(Content.styled("Uninstall", "bold"), id="action:uninstall"), + Option( + Content.styled(glyphs.box_horizontal * divider_width, "dim"), + id="details-divider", + disabled=True, + ), + Option("Back to plugin list", id="details-back"), + ] + + +def _component_summary_lines(row: _PluginRow) -> list[str]: + lines: list[str] = [] + if row.skill_names: + lines.append(f"Skills: {', '.join(row.skill_names)}") + elif row.skill_count: + lines.append(f"Skills: {row.skill_count}") + if row.mcp_server_names: + lines.append(f"MCP: {', '.join(row.mcp_server_names)}") + if row.hook_events: + lines.append(f"Hooks: {', '.join(row.hook_events)}") + if row.unsupported_components: + names = ", ".join(f"{name}/" for name in row.unsupported_components) + lines.append(f"Unsupported (not loaded): {names}") + return lines + + +def _unsupported_summary(components: tuple[str, ...]) -> str: + return f"Found unsupported: {', '.join(f'{name}/' for name in components)}." + + +def _will_install_lines(row: _PluginRow) -> list[str]: + lines = _component_summary_lines(row) + if row.has_supported_components or row.skill_count: + return lines + if row.unsupported_components: + return [ + "No supported components (skills/MCP/hooks).", + _unsupported_summary(row.unsupported_components), + ] + if row.skill_count is None: + return [ + ( + "Skills, MCP servers, and hooks if present " + "(agents/ and commands/ are not loaded)." + ) + ] + return [ + "No supported components (skills/MCP/hooks).", + "agents/ and commands/ are not loaded by deepagents-code.", + ] + + +def _installed_component_lines(row: _PluginRow) -> list[str]: + lines = _component_summary_lines(row) + if lines: + if not row.has_supported_components and row.unsupported_components: + return [ + "No supported components (skills/MCP/hooks).", + _unsupported_summary(row.unsupported_components), + ] + return lines + return ["No components found."] + + +def _status_lines(row: _PluginRow) -> list[Content]: + glyphs = get_glyphs() + if row.load_state == "error": + detail = row.load_error or "Plugin failed to load." + return [ + Content.styled(f"Status: Error — {detail}", "dim"), + Content.styled("Fix the error, then run /reload.", "dim"), + ] + if row.load_state == "disabled": + return [ + Content.styled("Status: Disabled", "dim"), + Content.styled("Enable the plugin, then run /reload to load it.", "dim"), + ] + if row.load_state == "pending_reload": + if row.enabled: + return [ + Content.styled("Status: Installed · pending /reload", "dim"), + Content.styled( + "Run /reload to load this plugin into the current session.", "dim" + ), + ] + return [ + Content.styled("Status: Disabled · pending /reload", "dim"), + Content.styled( + "Run /reload to unload this plugin from the current session.", "dim" + ), + ] + lines = [Content.styled(f"Status: {glyphs.checkmark} Enabled", "$success")] + if row.mcp_connected is False: + lines.append( + Content.styled( + "Run /reload to rebuild the agent with this plugin's MCP tools.", + "dim", + ) + ) + return lines + + +def _plugin_details_content(row: _PluginRow) -> Content: + _, _, marketplace = row.plugin_id.partition("@") + parts: list[Content | str] = [ + Content.styled("Plugin details", "bold"), + "\n\n", + Content.styled(row.label, "bold"), + "\n", + Content.styled(f"from {marketplace}", "dim"), + ] + if row.version: + parts.extend(["\n", Content.styled(f"Version: {row.version}", "dim")]) + if row.description: + parts.extend(["\n\n", row.description]) + if row.author: + parts.extend(["\n\n", Content.styled(f"By: {row.author}", "dim")]) + parts.extend(["\n\n", Content.styled("Will install:", "bold")]) + for line in _will_install_lines(row): + parts.extend(["\n ", Content.styled(line, "dim")]) + parts.extend( + [ + "\n\n", + Content.styled( + "Make sure you trust a plugin before installing, updating, " + "or using it.", + "dim", + ), + ] + ) + return Content.assemble(*parts) + + +def _installed_plugin_details_content(row: _PluginRow) -> Content: + _, _, marketplace = row.plugin_id.partition("@") + parts: list[Content | str] = [ + Content.styled(f"{row.label} @ {marketplace}", "bold") + ] + if row.version: + parts.extend(["\n", Content.styled(f"Version: {row.version}", "dim")]) + if row.description: + parts.extend(["\n\n", row.description]) + if row.author: + parts.extend(["\n\n", Content.styled(f"Author: {row.author}", "dim")]) + parts.extend(["\n\n"]) + for index, line in enumerate(_status_lines(row)): + if index: + parts.append("\n") + parts.append(line) + parts.extend(["\n\n", Content.styled("Installed components:", "bold")]) + for line in _installed_component_lines(row): + parts.extend(["\n ", Content.styled(line, "dim")]) + return Content.assemble(*parts) + + +def _marketplace_label(row: _MarketplaceRow) -> Content: + glyphs = get_glyphs() + prefix = f"{row.name} {glyphs.bullet} {row.source} {glyphs.bullet} " + if row.has_error: + return Content.assemble( + prefix, + Content.styled(f"{glyphs.error} Error", "$error"), + ) + return Content.assemble(prefix, f"{row.plugin_count} available") + + +def _marketplace_details_options() -> list[Option]: + return [ + Option( + Content.styled("Remove marketplace", "bold"), id="action:remove-marketplace" + ), + Option("Back to marketplace list", id="details-back"), + ] + + +def _confirm_marketplace_removal_options(row: _MarketplaceRow) -> list[Option]: + label = "installed plugin" if row.installed_count == 1 else "installed plugins" + return [ + Option( + Content.styled( + f"Remove marketplace and {row.installed_count} {label}", "bold" + ), + id="action:confirm-remove-marketplace", + ), + Option("Cancel", id="details-back"), + ] + + +def _marketplace_details_content(row: _MarketplaceRow) -> Content: + available = "Unavailable" if row.has_error else f"{row.plugin_count} available" + return Content.assemble( + Content.styled(row.name, "bold"), + "\n", + Content.styled(f"Source: {row.source}", "dim"), + "\n", + Content.styled(f"Plugins: {available}", "dim"), + "\n", + Content.styled(f"Installed: {row.installed_count}", "dim"), + ) + + +def _marketplace_removal_content(row: _MarketplaceRow) -> Content: + suffix = "s" if row.installed_count != 1 else "" + if row.installed_count: + detail = ( + f"This removes the marketplace and uninstalls {row.installed_count} " + f"plugin{suffix} from it." + ) + else: + detail = "This removes the marketplace from your installed list." + return Content.assemble( + Content.styled(f"Remove marketplace {row.name}?", "bold"), + "\n\n", + Content.styled(detail, "dim"), + ) diff --git a/libs/code/deepagents_code/tui/modals/plugin_manager/models.py b/libs/code/deepagents_code/tui/modals/plugin_manager/models.py new file mode 100644 index 0000000000..5946f34b65 --- /dev/null +++ b/libs/code/deepagents_code/tui/modals/plugin_manager/models.py @@ -0,0 +1,86 @@ +"""Plugin manager view models.""" + +from dataclasses import dataclass +from typing import Literal + +from deepagents_code.plugins.models import UnsupportedComponent + +PluginTab = Literal["discover", "installed", "marketplaces", "errors", "settings"] +PluginManagerView = Literal[ + "list", + "add_marketplace", + "plugin_details", + "installed_details", + "marketplace_details", + "confirm_remove_marketplace", +] +PluginLoadState = Literal["disabled", "pending_reload", "enabled", "error"] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class _PluginRow: + plugin_id: str + description: str + enabled: bool + version: str | None + author: str | None + display_name: str = "" + skill_count: int | None = None + skill_names: tuple[str, ...] = () + mcp_connected: bool | None = None + mcp_server_names: tuple[str, ...] = () + mcp_login_servers: tuple[str, ...] = () + hook_events: tuple[str, ...] = () + unsupported_components: tuple[UnsupportedComponent, ...] = () + session_loaded: bool = False + load_error: str | None = None + + @property + def load_state(self) -> PluginLoadState: + """Session-aware plugin status for list and detail copy.""" + if self.load_error: + return "error" + if self.enabled != self.session_loaded: + return "pending_reload" + if self.enabled: + return "enabled" + return "disabled" + + @property + def has_supported_components(self) -> bool: + """Whether the plugin declares any component this client loads.""" + return bool(self.skill_names or self.mcp_server_names or self.hook_events) + + @property + def label(self) -> str: + """Human-readable plugin name for UI copy.""" + if self.display_name: + return self.display_name + return self.plugin_id.partition("@")[0] + + +@dataclass(frozen=True, slots=True) +class _MarketplaceRow: + name: str + source: str + plugin_count: int | None + installed_count: int + error: str | None = None + + @property + def has_error(self) -> bool: + """Whether the configured marketplace could not be loaded. + + Marketplace loading failures set `error` and produce an error status. Warnings + from a marketplace that loaded successfully appear on the Errors tab without + marking the marketplace itself as errored. + """ + return self.error is not None + + +@dataclass(frozen=True, slots=True) +class _ManagerState: + available_plugins: tuple[_PluginRow, ...] + installed_plugins: tuple[_PluginRow, ...] + marketplaces: tuple[_MarketplaceRow, ...] + errors: tuple[str, ...] diff --git a/libs/code/deepagents_code/tui/modals/plugin_manager/plugin_manager.tcss b/libs/code/deepagents_code/tui/modals/plugin_manager/plugin_manager.tcss new file mode 100644 index 0000000000..9ced696acd --- /dev/null +++ b/libs/code/deepagents_code/tui/modals/plugin_manager/plugin_manager.tcss @@ -0,0 +1,96 @@ +PluginManagerScreen { + align: center middle; +} + +PluginManagerScreen > Vertical { + width: 88; + max-width: 94%; + /* The bordered search field adds four rows to list views. */ + height: 90%; + min-height: 24; + max-height: 100%; + background: $surface; + border: solid $primary; + padding: 1 2; +} + +PluginManagerScreen .plugin-manager-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; +} + +PluginManagerScreen .plugin-manager-tabs { + height: 1; + width: 100%; + margin-bottom: 0; +} + +PluginManagerScreen .plugin-manager-tab { + width: 1fr; + height: 1; + color: $text-muted; + text-align: center; + margin-right: 1; + pointer: pointer; +} + +PluginManagerScreen .plugin-manager-tab:last-of-type { + margin-right: 0; +} + +PluginManagerScreen .plugin-manager-tab.active { + color: $text; +} + +PluginManagerScreen .plugin-manager-divider { + color: $text-muted; + margin: 0 0 1 0; + height: 1; +} + +PluginManagerScreen .plugin-manager-status { + height: auto; + color: $text-muted; + margin-bottom: 1; +} + +PluginManagerScreen .plugin-manager-error { + height: auto; + color: $error; + margin-bottom: 1; +} + +PluginManagerScreen #plugin-manager-search { + margin-bottom: 1; + border: solid $primary-lighten-2; +} + +PluginManagerScreen #plugin-manager-search:focus { + border: solid $primary; +} + +PluginManagerScreen #plugin-manager-options { + height: 1fr; + min-height: 5; + background: $background; +} + +PluginManagerScreen #plugin-marketplace-source { + margin-top: 1; + margin-bottom: 1; + border: solid $primary-lighten-2; +} + +PluginManagerScreen #plugin-marketplace-source:focus { + border: solid $primary; +} + +PluginManagerScreen .plugin-manager-help { + height: auto; + color: $text-muted; + text-style: italic; + text-align: center; + margin-top: 1; +} diff --git a/libs/code/deepagents_code/tui/modals/plugin_manager/state.py b/libs/code/deepagents_code/tui/modals/plugin_manager/state.py new file mode 100644 index 0000000000..d8cdd258df --- /dev/null +++ b/libs/code/deepagents_code/tui/modals/plugin_manager/state.py @@ -0,0 +1,346 @@ +"""Plugin manager state loading.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence, Set as AbstractSet + + from deepagents_code.mcp_tools import MCPServerInfo + from deepagents_code.plugins.models import ( + MarketplacePluginEntry, + PluginInstance, + PluginMarketplace, + ) + +from deepagents_code.plugins import discover_plugins +from deepagents_code.plugins.marketplace import ( + MarketplaceError, + load_marketplace_location, + materialize_plugin_source, + redact_marketplace_source, + redact_urls_in_text, +) +from deepagents_code.plugins.models import LocalPluginSource, split_plugin_id +from deepagents_code.plugins.store import ( + get_primary_install_entry, + load_enabled_plugin_ids, + load_installed_plugins, + load_marketplace_records, +) +from deepagents_code.tui.modals.plugin_manager.models import ( + _ManagerState, + _MarketplaceRow, + _PluginRow, +) + +logger = logging.getLogger(__name__) + + +def _extract_name(value: object) -> str | None: + if isinstance(value, str): + return value + if isinstance(value, dict): + name = value.get("name") + if isinstance(name, str): + return name + return None + + +def _list_plugin_skill_names(instance: PluginInstance) -> tuple[str, ...]: + from deepagents.backends.filesystem import FilesystemBackend + + from deepagents_code.plugins.adapters.skills import plugin_skill_sources + from deepagents_code.plugins.adapters.skills_middleware import ( + load_namespaced_skills, + ) + + names: list[str] = [] + for path, _label, namespace in plugin_skill_sources((instance,)): + try: + source = Path(path).resolve() + backend = FilesystemBackend(root_dir=str(source), virtual_mode=False) + names.extend( + skill["name"] + for skill in load_namespaced_skills(backend, str(source), namespace) + ) + except (OSError, RuntimeError): + logger.warning( + "Could not list skills for plugin %s", instance.plugin_id, exc_info=True + ) + return tuple(dict.fromkeys(names)) + + +def _plugin_mcp_server_names(instance: PluginInstance) -> tuple[str, ...]: + from deepagents_code.plugins.adapters.mcp import plugin_mcp_server_entries + + return tuple( + label for label, _scoped, _needs_login in plugin_mcp_server_entries(instance) + ) + + +def _plugin_mcp_login_servers(instance: PluginInstance) -> tuple[str, ...]: + from deepagents_code.plugins.adapters.mcp import plugin_mcp_server_entries + + return tuple( + scoped + for _label, scoped, needs_login in plugin_mcp_server_entries(instance) + if needs_login + ) + + +def _plugin_mcp_connected( + instance: PluginInstance, mcp_server_info: Sequence[MCPServerInfo] +) -> bool | None: + from deepagents_code.plugins.adapters.mcp import plugin_mcp_server_entries + + expected = frozenset( + scoped for _label, scoped, _needs_login in plugin_mcp_server_entries(instance) + ) + if not expected: + return None + connected = {info.name for info in mcp_server_info if info.status == "ok"} + return expected <= connected + + +def _plugin_display_name( + *, + marketplace_display_name: str | None, + instance: PluginInstance | None, + plugin_name: str, +) -> str: + if marketplace_display_name: + return marketplace_display_name + if instance is not None and instance.manifest is not None: + manifest_name = instance.manifest.display_name + if manifest_name: + return manifest_name + return plugin_name + + +def _instance_for_manager_row( + plugin_id: str, + *, + discovered: dict[str, PluginInstance], + is_installed: bool, + errors: list[str], +) -> PluginInstance | None: + instance = discovered.get(plugin_id) + if instance is not None: + return instance + if not is_installed: + return None + entry = get_primary_install_entry(plugin_id) + if entry is None: + errors.append(f"{plugin_id}: no install entry found; re-run install") + return None + root = Path(entry.install_path) + try: + installed = root.is_dir() + except (OSError, RuntimeError) as exc: + errors.append(f"{plugin_id}: could not inspect install path: {exc}") + return None + if not installed: + errors.append(f"{plugin_id}: install cache missing at {root}; re-run install") + return None + from deepagents_code.plugins.discovery import _plugin_from_install_path + + # plugin_id is built as `{name}@{marketplace}`, so split_plugin_id cannot fail. + plugin_name, marketplace_name = split_plugin_id(plugin_id) + try: + loaded, warnings = _plugin_from_install_path( + plugin_id=plugin_id, + root=root, + marketplace_name=marketplace_name, + fallback_name=plugin_name, + ) + except (OSError, RuntimeError) as exc: + errors.append(f"{plugin_id}: {exc}") + return None + errors.extend(warnings) + return loaded + + +def _preview_local_plugin_instance( + marketplace: PluginMarketplace, + plugin: MarketplacePluginEntry, + *, + plugin_id: str, + errors: list[str], +) -> PluginInstance | None: + """Build a preview instance from a local marketplace source (no network). + + Returns: + A plugin instance when the local source resolves, otherwise `None`. + """ + if not isinstance(plugin.source, LocalPluginSource): + return None + root = materialize_plugin_source(marketplace, plugin) + if root is None: + return None + try: + exists = root.is_dir() + except (OSError, RuntimeError) as exc: + errors.append(f"{plugin_id}: could not inspect source path: {exc}") + return None + if not exists: + return None + from deepagents_code.plugins.discovery import _plugin_from_install_path + + # plugin_id is built as `{name}@{marketplace}`, so split_plugin_id cannot fail. + plugin_name, marketplace_name = split_plugin_id(plugin_id) + try: + loaded, warnings = _plugin_from_install_path( + plugin_id=plugin_id, + root=root, + marketplace_name=marketplace_name, + fallback_name=plugin_name, + ) + except (OSError, RuntimeError) as exc: + errors.append(f"{plugin_id}: {exc}") + return None + # Preview is best-effort: inventory warnings are intentionally not surfaced + # for un-installed plugins (they reach Errors once the plugin is installed). + _ = warnings + return loaded + + +def _row_from_instance( + *, + plugin_id: str, + description: str, + author: str | None, + is_enabled: bool, + instance: PluginInstance | None, + mcp_server_info: Sequence[MCPServerInfo], + loaded_plugin_ids: AbstractSet[str], + load_error: str | None = None, + display_name: str = "", +) -> _PluginRow: + from deepagents_code.plugins.adapters.hooks import plugin_hook_event_names + + skill_names = _list_plugin_skill_names(instance) if instance else () + mcp_names = _plugin_mcp_server_names(instance) if instance else () + login_servers = _plugin_mcp_login_servers(instance) if instance else () + hook_events = plugin_hook_event_names(instance) if instance else () + unsupported = instance.inventory.unsupported if instance else () + session_loaded = plugin_id in loaded_plugin_ids + return _PluginRow( + plugin_id=plugin_id, + description=description, + enabled=is_enabled, + version=instance.version if instance else None, + author=author, + display_name=display_name, + skill_count=len(skill_names) if instance else None, + skill_names=skill_names, + mcp_connected=_plugin_mcp_connected(instance, mcp_server_info) + if instance and session_loaded + else None, + mcp_server_names=mcp_names, + mcp_login_servers=login_servers, + hook_events=hook_events, + unsupported_components=unsupported, + session_loaded=session_loaded, + load_error=load_error, + ) + + +def _load_manager_state( + mcp_server_info: Sequence[MCPServerInfo] = (), + *, + loaded_plugin_ids: AbstractSet[str] = frozenset(), +) -> _ManagerState: + records = load_marketplace_records() + enabled = load_enabled_plugin_ids() + installed = load_installed_plugins() + errors: list[str] = [] + plugin_result = discover_plugins() + errors.extend(plugin_result.warnings) + discovered = {instance.plugin_id: instance for instance in plugin_result.plugins} + available_plugins: list[_PluginRow] = [] + installed_plugins: list[_PluginRow] = [] + marketplaces: list[_MarketplaceRow] = [] + for name, record in sorted(records.items()): + try: + marketplace = load_marketplace_location(Path(record.install_location)) + except MarketplaceError as exc: + detail = redact_urls_in_text(str(exc)) + if record.source_type not in {"directory", "file"}: + detail = detail.replace(record.install_location, "") + errors.append(f"{name}: {detail}") + marketplaces.append( + _MarketplaceRow( + name, + redact_marketplace_source(record.source), + None, + sum(plugin_id.endswith(f"@{name}") for plugin_id in installed), + detail, + ) + ) + continue + marketplaces.append( + _MarketplaceRow( + marketplace.name, + redact_marketplace_source(record.source), + len(marketplace.plugins), + sum( + plugin_id.endswith(f"@{marketplace.name}") + for plugin_id in installed + ), + ) + ) + errors.extend( + f"{marketplace.name}: {warning}" for warning in marketplace.warnings + ) + for plugin in marketplace.plugins: + plugin_id = f"{plugin.name}@{marketplace.name}" + is_enabled = plugin_id in enabled + is_installed = plugin_id in installed + row_errors: list[str] = [] + instance = _instance_for_manager_row( + plugin_id, + discovered=discovered, + is_installed=is_installed, + errors=row_errors, + ) + if instance is None and not is_installed: + instance = _preview_local_plugin_instance( + marketplace, + plugin, + plugin_id=plugin_id, + errors=row_errors, + ) + errors.extend(row_errors) + load_error: str | None = None + if is_installed and instance is None: + load_error = ( + row_errors[0] + if row_errors + else "installed plugin could not be loaded" + ) + row = _row_from_instance( + plugin_id=plugin_id, + description=plugin.description or "", + author=_extract_name(plugin.author), + is_enabled=is_enabled, + instance=instance, + mcp_server_info=mcp_server_info, + loaded_plugin_ids=loaded_plugin_ids, + load_error=load_error, + display_name=_plugin_display_name( + marketplace_display_name=plugin.display_name, + instance=instance, + plugin_name=plugin.name, + ), + ) + (installed_plugins if is_installed else available_plugins).append(row) + return _ManagerState( + tuple(available_plugins), + tuple(installed_plugins), + tuple(marketplaces), + tuple(dict.fromkeys(errors)), + ) diff --git a/libs/code/deepagents_code/tui/modals/plugin_manager/tabs.py b/libs/code/deepagents_code/tui/modals/plugin_manager/tabs.py new file mode 100644 index 0000000000..1bec4a66cb --- /dev/null +++ b/libs/code/deepagents_code/tui/modals/plugin_manager/tabs.py @@ -0,0 +1,72 @@ +"""Clickable tab labels for the plugin manager header.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from textual.message import Message +from textual.widgets import Static + +if TYPE_CHECKING: + from textual.events import Click + + from deepagents_code.tui.modals.plugin_manager.models import PluginTab + +TAB_LABELS: Final[dict[PluginTab, str]] = { + "discover": "Plugins", + "installed": "Installed", + "marketplaces": "Marketplaces", + "errors": "Errors", + "settings": "Settings", +} + + +class PluginTabSelected(Message): + """Posted when a plugin manager tab label is clicked.""" + + def __init__(self, tab: PluginTab) -> None: + """Initialize with the selected tab id. + + Args: + tab: Tab to activate. + """ + super().__init__() + self.tab = tab + + +class PluginTabLabel(Static): + """Mouse-clickable tab label in the plugin manager header.""" + + def __init__(self, tab: PluginTab, label: str) -> None: + """Create a tab label. + + Args: + tab: Tab id this label activates. + label: Display text for the tab. + """ + super().__init__( + f" {label} ", + id=f"plugin-tab-{tab}", + classes="plugin-manager-tab", + markup=False, + ) + self._tab = tab + self._label = label + + def set_active(self, active: bool) -> None: + """Update the active marker and style. + + Args: + active: Whether this tab is the current tab. + """ + self.update(f"> {self._label} <" if active else f" {self._label} ") + self.set_class(active, "active") + + def on_click(self, event: Click) -> None: + """Select this tab on click. + + Args: + event: The click event. + """ + event.stop() + self.post_message(PluginTabSelected(self._tab)) diff --git a/libs/code/deepagents_code/tui/modals/resume_compact.py b/libs/code/deepagents_code/tui/modals/resume_compact.py new file mode 100644 index 0000000000..58e7cbce32 --- /dev/null +++ b/libs/code/deepagents_code/tui/modals/resume_compact.py @@ -0,0 +1,121 @@ +"""Prompt for compacting a large resumed thread.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Static + +from deepagents_code._session_stats import format_token_count +from deepagents_code.config import get_glyphs + +if TYPE_CHECKING: + from textual.app import ComposeResult + + +class ResumeCompactPromptScreen(ModalScreen[bool]): + """Ask whether to compact a just-resumed thread before the next turn.""" + + can_focus = True + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "compact", "Compact", show=False, priority=True), + Binding("escape", "skip", "Skip", 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), + ] + + CSS = """ + ResumeCompactPromptScreen { + align: center middle; + } + + ResumeCompactPromptScreen > Vertical { + width: 72; + max-width: 90%; + height: auto; + background: $surface; + border: solid $warning; + padding: 1 2; + } + + ResumeCompactPromptScreen .resume-compact-title { + text-style: bold; + color: $warning; + text-align: center; + margin-bottom: 1; + } + + ResumeCompactPromptScreen .resume-compact-body { + height: auto; + color: $text; + margin-bottom: 1; + } + + ResumeCompactPromptScreen .resume-compact-help { + height: auto; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def __init__(self, *, context_tokens: int, threshold: int) -> None: + """Initialize the prompt. + + Args: + context_tokens: Latest model-reported context size for the thread. + threshold: Configured token count that triggered the suggestion. + """ + super().__init__() + self._context_tokens = context_tokens + self._threshold = threshold + + def compose(self) -> ComposeResult: + """Compose the confirmation dialog. + + Yields: + Title, explanation, and keyboard help. + """ + with Vertical(): + yield Static( + "Compact this thread?", + classes="resume-compact-title", + markup=False, + ) + yield Static( + f"This thread uses {format_token_count(self._context_tokens)} context " + "tokens, above the configured " + f"{format_token_count(self._threshold)} token threshold. Compacting " + "summarizes older messages so later turns cost less.", + classes="resume-compact-body", + markup=False, + ) + yield Static( + f" {get_glyphs().bullet} ".join( + ("Enter: compact now", "Esc: keep full context") + ), + classes="resume-compact-help", + markup=False, + ) + + def on_mount(self) -> None: + """Focus the modal so its bindings receive keyboard input.""" + self.focus() + + def action_compact(self) -> None: + """Compact the thread before the next turn.""" + self.dismiss(True) + + def action_skip(self) -> None: + """Leave the thread's context untouched.""" + self.dismiss(False) diff --git a/libs/code/deepagents_code/tui/screens/__init__.py b/libs/code/deepagents_code/tui/screens/__init__.py new file mode 100644 index 0000000000..598e2c6c19 --- /dev/null +++ b/libs/code/deepagents_code/tui/screens/__init__.py @@ -0,0 +1 @@ +"""Screen UI components.""" diff --git a/libs/code/deepagents_code/tui/textual_adapter.py b/libs/code/deepagents_code/tui/textual_adapter.py new file mode 100644 index 0000000000..2d6f2cbf15 --- /dev/null +++ b/libs/code/deepagents_code/tui/textual_adapter.py @@ -0,0 +1,3606 @@ +"""Textual UI adapter for agent execution.""" + +from __future__ import annotations + +import asyncio +import contextlib +import inspect +import logging +import math +import time +import uuid +from typing import TYPE_CHECKING, Any, NamedTuple, cast + +import httpx + +if TYPE_CHECKING: + from collections.abc import ( + AsyncIterator, + Awaitable, + Callable, + Iterable, + Mapping, + Sequence, + ) + from pathlib import Path + from typing import Protocol + + from langchain.agents.middleware.human_in_the_loop import ( + ActionRequest, + ApproveDecision, + EditDecision, + HITLRequest, + RejectDecision, + ) + from langchain_core.messages import AIMessage + from langchain_core.runnables import RunnableConfig + from langgraph.types import Command, Interrupt + from pydantic import TypeAdapter + + from deepagents_code._ask_user_types import AskUserWidgetResult, Question + from deepagents_code.hooks.models.domain import ToolCallData + from deepagents_code.resume_state import RubricResult + + # Type alias matching HITLResponse["decisions"] element type + HITLDecision = ApproveDecision | EditDecision | RejectDecision + + class _TokensUpdateCallback(Protocol): + """Callback signature for `_on_tokens_update`.""" + + def __call__(self, count: int, *, approximate: bool = False) -> None: ... + + class _TokensShowCallback(Protocol): + """Callback signature for `_on_tokens_show`.""" + + def __call__(self, *, approximate: bool = False) -> None: ... + + class _SessionCostCallback(Protocol): + """Callback signature for `_on_session_cost`. + + Positional-only: the total is always passed positionally, so a consumer + is free to name the parameter for its own domain (a restored checkpoint + total, say) rather than matching this one. `thread_id` is keyword-only + and may be `""` when the event did not name a thread. `pricing_ok` is + `None` when the event did not report pricing health. + """ + + def __call__( + self, + total_usd: float, + /, + *, + thread_id: str = "", + pricing_ok: bool | None = None, + ) -> None: ... + + class _ProvisionalCostCallback(Protocol): + """Callback signature for `_on_provisional_cost`. + + Positional-only for the same reason as `_SessionCostCallback`. + """ + + def __call__(self, cost_usd: float, /) -> None: ... + + +from deepagents_code import _session_stats +from deepagents_code._ask_user_types import ( + ASK_USER_ANSWERED_NO_RESULT_SUMMARY, + ASK_USER_ANSWERED_NOT_DELIVERED_SUMMARY, + ASK_USER_ANSWERED_SUMMARY, + ASK_USER_CANCELLED_SUMMARY, + ASK_USER_FAILED_SUMMARY, + AskUserRequest, + AskUserRowSummary, +) +from deepagents_code._cli_context import CLIContext +from deepagents_code._constants import SYSTEM_MESSAGE_PREFIX +from deepagents_code._tool_stream import ( + UNRENDERABLE_TOOL_OUTPUT, + ToolCallBuffer, + ToolCallBufferKey, + ToolStatus, + build_tool_error_payload, + build_tool_result_payload, + build_tool_use_payload, + count_unemitted_tool_calls, + normalize_tool_status, + tool_call_buffer_key, +) +from deepagents_code.config import build_stream_config, get_glyphs +from deepagents_code.file_ops import FileOpTracker, record_display_caveat +from deepagents_code.hooks import ( + dispatch_hook, + dispatch_hook_fire_and_forget, +) +from deepagents_code.hooks.manager import PromptOutcome +from deepagents_code.hooks.permissions import merge_permission_decisions +from deepagents_code.input import MediaTracker, parse_file_mentions +from deepagents_code.media_utils import create_multimodal_content +from deepagents_code.tool_display import format_tool_message_content +from deepagents_code.tui.widgets.messages import ( + AppMessage, + AssistantMessage, + DiffMessage, + RubricResultMessage, + SummarizationMessage, + ToolCallMessage, +) + +logger = logging.getLogger(__name__) + +_hitl_adapter_cache: TypeAdapter | None = None +"""Lazy singleton for the HITL request validator.""" + +_ASK_USER_UNSUPPORTED_ERROR = "ask_user not supported by this UI" + +_REJECT_REASON_PREFIX = "User rejected the tool call with reason: " +"""Synthetic framing prepended to a user-typed HITL rejection reason.""" + + +def _permission_tool_calls( + interrupt_id: str, + action_requests: Sequence[ActionRequest], + current_tool_messages: Mapping[str, ToolCallMessage], +) -> list[ToolCallData | None]: + """Pair each gated action request with the tool id its row already carries. + + HITL action requests do not expose tool-call ids, so a mounted row whose + name and arguments match is claimed at most once to recover the real id. + Unmatched requests fall back to a positional id derived from the interrupt. + + Args: + interrupt_id: LangGraph interrupt owning this batch. + action_requests: Gated tool calls, in request order. + current_tool_messages: Mounted tool rows keyed by tool-call id. + + Returns: + One hook tool call per action request, in the same order. `None` marks + a request the graph did not describe well enough to hand to a hook. + """ + from deepagents_code.hooks.models.domain import ToolCallData + + candidates = list(current_tool_messages.items()) + claimed: set[str] = set() + calls: list[ToolCallData | None] = [] + for index, request in enumerate(action_requests): + name = request.get("name") + args = request.get("args") + if not isinstance(name, str) or not isinstance(args, dict): + calls.append(None) + continue + tool_id = f"{interrupt_id}:{index}" + for candidate_id, tool_message in candidates: + if candidate_id in claimed: + continue + if tool_message.tool_name == name and tool_message.args == args: + tool_id = candidate_id + claimed.add(candidate_id) + break + calls.append(ToolCallData(id=tool_id, name=name, args=args)) + return calls + + +def _dispatch_tool_use_hook( + tool_name: str, tool_id: str, tool_args: dict[str, Any] +) -> None: + """Dispatch a `tool.use` hook with the payload documented in `hooks`.""" + dispatch_hook_fire_and_forget( + "tool.use", build_tool_use_payload(tool_name, tool_id, tool_args) + ) + + +def _dispatch_tool_error_hook(tool_name: str) -> None: + """Dispatch a `tool.error` hook with the payload documented in `hooks`.""" + dispatch_hook_fire_and_forget("tool.error", build_tool_error_payload(tool_name)) + + +def _is_ask_user_transcript(body: str) -> bool: + """Whether a string is a `Q:`/`A:` transcript carrying user-typed answers. + + Matches the exact shape `format_ask_user_transcript` generates, rather than + allow-listing the permitted bodies: several legitimate `ask_user` hook bodies + are free-text widget-failure messages (`_ASK_USER_UNSUPPORTED_ERROR`, the + invalid-payload text), and an allowlist would silently rewrite the next one + someone adds. The transcript is the one thing that must never be sent, and it + is machine-generated, so its shape is reliable. + + This is a send-side refusal, not a parse: it never interprets answer content, + and a false positive costs a summary in a hook body rather than leaking one. + + Args: + body: Candidate `tool.result` body. + + Returns: + True if `body` looks like a generated Q&A transcript. + """ + return body.startswith("Q: ") and "\nA: " in body + + +def _dispatch_tool_result_hook( + tool_name: str, + tool_id: str | None, + tool_args: dict[str, Any], + tool_status: ToolStatus, + tool_output: str, +) -> None: + """Dispatch a `tool.result` hook with the payload documented in `hooks`. + + `tool_output` is truncated to `HOOK_TOOL_OUTPUT_LIMIT` inside the shared + payload builder. + + For `ask_user`, a body that is a Q&A transcript is replaced with a summary. + Each call site already passes a summary, but that correctness is positional — + it depends on a live `deferred_tool_result_hooks` entry, which is turn-local, so + a `ToolMessage` arriving on a later turn (or via a future branch) would + otherwise fall through to a path that dispatches the raw transcript. Enforcing + it here by tool name makes "user-typed answers never reach `tool.result`" hold + structurally rather than per-branch. + """ + if tool_name == "ask_user" and _is_ask_user_transcript(tool_output): + logger.error( + "Refusing to send an ask_user answer transcript to hooks " + "(tool_id=%s, status=%s); substituting a summary", + tool_id, + tool_status, + ) + tool_output = ( + ASK_USER_FAILED_SUMMARY + if tool_status == "error" + else ASK_USER_ANSWERED_SUMMARY + ) + dispatch_hook_fire_and_forget( + "tool.result", + build_tool_result_payload( + tool_name, tool_id, tool_args, tool_status, tool_output + ), + ) + + +class DeferredToolResultHook(NamedTuple): + """A `tool.result` payload held back until the authoritative result arrives. + + Used for an answered `ask_user`: the middleware owns the final status, and the + hook body must be the sanitized summary rather than the transcript of the + user's answers. + """ + + tool_args: dict[str, Any] + """Args from the interrupt, since the streamed message carries none.""" + + tool_output: AskUserRowSummary + """Sanitized `tool_output`; never the answers. + + Typed as `AskUserRowSummary` rather than `str` so the "never the transcript" + constraint in the class docstring is checked rather than merely documented. + """ + + +def _dispatch_terminal_tool_result_hooks( + tool_messages: dict[str, ToolCallMessage], + tool_output: str, +) -> list[str]: + """Emit terminal `tool.error`/`tool.result` for still-pending tool widgets. + + Every widget in `tool_messages` already had its `tool.use` dispatched (that + is when the widget is mounted), so any tool that reaches a terminal outcome + *without* a streamed `ToolMessage` — a HITL rejection, a cancelled turn, or + an aborted stream — would otherwise leave its `tool.use` unterminated. This + closes each one with a `tool_status="error"` result carrying the widget's + real `tool_name`/`args`, so the "every `tool.use` is closed by a matching + terminal event" guarantee holds on those paths too. + + A row carrying a deferred success (`ToolCallMessage.defer_success`) is the + exception: it already reached a successful outcome, so it is reported as + `tool_status="success"` with `ASK_USER_ANSWERED_NO_RESULT_SUMMARY` instead of + `tool_output`, and no `tool.error` is emitted for it. This matches a row that + has already fallen back to its summary as well as one still awaiting its + result — see `ToolCallMessage.deferred_success_output`. + + TUI-only: the headless surface reaches the equivalent state through + `_run_agent_loop`'s orphan drain rather than widgets. + + Args: + tool_messages: Map of tool-call id to its widget for the pending tools. + tool_output: Terminal output string recorded on each `tool.result`, except + for rows with a deferred success (see above). + + Returns: + The tool-call ids that received terminal hooks. Callers track these + (via `completed_tool_result_ids`) so a later synthetic `ToolMessage` + — when the turn still resumes, e.g. alongside an answered `ask_user` + — does not double-dispatch. + """ + dispatched: list[str] = [] + for tool_id, tool_msg in list(tool_messages.items()): + if tool_msg.deferred_success_output is not None: + # The tool already succeeded (an answered `ask_user`). Reporting the + # generic failure here would tell audit hooks a question errored that + # the user answered normally — and `ask_user` results double as + # authorization records. But every caller that gets here is a teardown + # (crash, torn stream, cancel), so the result never arrived: report a + # body that says so rather than the plain answered summary, and never + # the answers themselves. + # + # The answers did reach the graph on these paths. Where they provably + # did not, the caller settles the row itself with + # `ASK_USER_ANSWERED_NOT_DELIVERED_SUMMARY` before this sweep runs. + _dispatch_tool_result_hook( + tool_msg.tool_name, + tool_id, + tool_msg.args, + "success", + ASK_USER_ANSWERED_NO_RESULT_SUMMARY, + ) + dispatched.append(tool_id) + continue + _dispatch_tool_error_hook(tool_msg.tool_name) + _dispatch_tool_result_hook( + tool_msg.tool_name, + tool_id, + tool_msg.args, + "error", + tool_output, + ) + dispatched.append(tool_id) + return dispatched + + +def _pop_rows_not_awaiting_deferred_result( + tool_messages: dict[str, ToolCallMessage], +) -> dict[str, ToolCallMessage]: + """Remove rows a rejection sweep may terminate immediately. + + An answered `ask_user` remains tracked while the resumed graph produces its + authoritative `ToolMessage`. A co-occurring bare HITL rejection still resumes + when an answer is pending, so consuming that row here would discard the full + transcript or a validation error that arrives on the resumed stream. + + Gates on `is_awaiting_deferred_result`, deliberately *not* the + `deferred_success_output is not None` used by + `_dispatch_terminal_tool_result_hooks`: an already-settled row has nothing left + to wait for, so a rejection sweep may consume it. + + Args: + tool_messages: Mutable map of currently tracked tool rows. + + Returns: + Rows not awaiting a deferred result, removed from `tool_messages`. + """ + popped: dict[str, ToolCallMessage] = {} + for tool_id in list(tool_messages): + if not tool_messages[tool_id].is_awaiting_deferred_result: + popped[tool_id] = tool_messages.pop(tool_id) + return popped + + +def _pop_rows_awaiting_deferred_result( + tool_messages: dict[str, ToolCallMessage], +) -> dict[str, ToolCallMessage]: + """Remove the rows still waiting on a deferred result. + + The complement of `_pop_rows_not_awaiting_deferred_result`, for the one caller + that must terminate exactly those rows: an abort that discards the resume + payload, so the `ToolMessage` they wait for provably never comes. + + Args: + tool_messages: Mutable map of currently tracked tool rows. + + Returns: + Rows awaiting a deferred result, removed from `tool_messages`. + """ + popped: dict[str, ToolCallMessage] = {} + for tool_id in list(tool_messages): + if tool_messages[tool_id].is_awaiting_deferred_result: + popped[tool_id] = tool_messages.pop(tool_id) + return popped + + +def _set_running_unless_deferred(tool_msg: ToolCallMessage) -> None: + """Show the running spinner, unless the row already has its own outcome. + + An answered `ask_user` is not an ungated sibling waiting to run: it is tracked + only until its `ToolMessage` lands, and a spinner would visibly un-answer the + row in the meantime. Every `set_running` sweep over `_current_tool_messages` + must go through here, because those sweeps run *after* the `ask_user` + resolution loop in the same `pending_interrupts` pass and are not namespace + scoped for the main agent — so a batch mixing a question with a gated or + hook-resolved tool reaches the answered row. + + Args: + tool_msg: Row to move into the running state. + """ + if tool_msg.is_awaiting_deferred_result: + return + tool_msg.set_running() + + +def _reject_tracked_rows( + adapter: TextualUIAdapter, + *, + reason: str | None = None, +) -> list[str]: + """Terminally reject every tracked row a rejection sweep may consume. + + Gives each row a terminal state before teardown so none is left frozen on a + stale "Running...", then closes its `tool.use` with a terminal hook. Rows + awaiting a deferred result are left tracked: an answered `ask_user` makes the + turn resume, so it still expects its authoritative `ToolMessage` — see + `_pop_rows_not_awaiting_deferred_result`. + + Args: + adapter: Adapter owning the tracked rows. + reason: Optional free-text rejection reason rendered on each row. + + Returns: + The tool-call ids that received terminal hooks, for the caller's + `completed_tool_result_ids` tracking. + """ + rejected = _pop_rows_not_awaiting_deferred_result(adapter._current_tool_messages) + for tool_msg in rejected.values(): + # DOM teardown may fail; cleanup must not mask the originating exception. + with contextlib.suppress(Exception): + tool_msg.set_rejected(reason=reason) + adapter._sync_tool_widget(tool_msg) + return _dispatch_terminal_tool_result_hooks(rejected, "Tool approval rejected") + + +def _frame_reject_reason(reason: str) -> str: + """Frame a user-typed rejection reason for the model. + + Stock HITL uses the supplied message as the *entire* synthetic + `ToolMessage`, replacing its canned "user rejected the tool call" wording. + A bare reason ("no", "wrong file") therefore reaches the model with no + indication of who produced it or why the tool never ran, so the framing is + reattached here while the raw text is what the tool row renders. + + Args: + reason: Non-empty reason typed into the rejection reason field. + + Returns: + The reason prefixed with the synthetic rejection framing. + """ + return f"{_REJECT_REASON_PREFIX}{reason}" + + +def _get_hitl_request_adapter(hitl_request_type: type) -> TypeAdapter: + """Return a cached `TypeAdapter(HITLRequest)`. + + Avoids re-compiling the pydantic schema on every `execute_task_textual` call. + + Args: + hitl_request_type: The `HITLRequest` class (passed in because + it is imported locally by the caller). + + Returns: + Shared `TypeAdapter` instance. + """ + global _hitl_adapter_cache # noqa: PLW0603 + if _hitl_adapter_cache is None: + from pydantic import TypeAdapter + + _hitl_adapter_cache = TypeAdapter(hitl_request_type) + return _hitl_adapter_cache + + +_ask_user_adapter_cache: TypeAdapter | None = None +"""Lazy singleton for the `ask_user` interrupt validator.""" + + +def _get_ask_user_adapter() -> TypeAdapter: + """Return a cached `TypeAdapter(AskUserRequest)`. + + Returns: + Shared `TypeAdapter` instance. + """ + global _ask_user_adapter_cache # noqa: PLW0603 + if _ask_user_adapter_cache is None: + from pydantic import TypeAdapter + + _ask_user_adapter_cache = TypeAdapter(AskUserRequest) + return _ask_user_adapter_cache + + +def _is_summarization_chunk(metadata: dict | None) -> bool: + """Check if a message chunk is from summarization middleware. + + The summarization model is invoked with + `config={"metadata": {"lc_source": "summarization"}}` + (see `langchain.agents.middleware.summarization`), which + LangChain's callback system merges into the stream metadata dict. + + Args: + metadata: The metadata dict from the stream chunk. + + Returns: + Whether the chunk is from summarization and should be filtered. + """ + if metadata is None: + return False + return metadata.get("lc_source") == "summarization" + + +def _is_auto_mode_classifier_chunk(metadata: dict | None) -> bool: + """Check if a message chunk is internal Auto mode classifier output. + + The Auto mode authorization classifier is invoked with + `config={"metadata": {"lc_source": "auto_mode_classifier"}}` + (see `AutoModeHITLMiddleware` in `deepagents_code.auto_mode`), which + LangChain's callback system merges into the stream metadata dict. + + Args: + metadata: The metadata dict from the stream chunk. + + Returns: + Whether the chunk should be hidden from the conversation transcript. + """ + if metadata is None: + return False + return metadata.get("lc_source") == "auto_mode_classifier" + + +class RubricEvaluationEnd(NamedTuple): + """A validated `rubric_evaluation_end` event forwarded to the caller. + + Bundling the two fields as named attributes (rather than two positional + strings) makes the grading-run correlation self-documenting and removes the + risk of transposing the run ID and the verdict at a call site. + """ + + grading_run_id: str + """Correlation ID minted by `RubricMiddleware` for this grading run.""" + + result: RubricResult + """Terminal/loop verdict carried by the event.""" + + +def _format_rubric_event(data: dict[str, Any]) -> str | None: + """Format a concise rubric custom-stream event for the transcript. + + Args: + data: Custom-stream rubric event payload. + + Returns: + A user-visible summary for rubric events, or `None` for custom-stream + events that are not rubric events. + """ + glyphs = get_glyphs() + event_type = data.get("type") + if event_type == "rubric_evaluation_start": + iteration = data.get("iteration", 0) + show_iteration = data.get("show_iteration") is True + label = ( + f" (iteration {iteration + 1})" + if show_iteration and isinstance(iteration, int) + else "" + ) + return ( + f"{glyphs.hourglass} Checking acceptance criteria{label}{glyphs.ellipsis}" + ) + if event_type != "rubric_evaluation_end": + return None + + result = data.get("result") + if result is None: + return None + if result == "satisfied": + return f"{glyphs.checkmark} Acceptance criteria satisfied" + if result == "needs_revision": + return f"{glyphs.retry} Acceptance criteria not yet satisfied" + if result == "max_iterations_reached": + return ( + f"{glyphs.warning} Acceptance criteria not yet satisfied " + "(iteration limit reached)" + ) + if result == "failed": + return f"{glyphs.warning} Rubric is invalid or cannot be evaluated" + if result == "grader_error": + return f"{glyphs.warning} Acceptance criteria check failed" + # A `rubric_evaluation_end` with an unrecognized result is still a terminal + # grading event; surface it rather than silently dropping it (e.g. if the + # SDK adds a new verdict the chat would otherwise go quiet mid-turn). + return f"{glyphs.warning} Acceptance criteria check ended" + + +def _format_rubric_details(data: dict[str, Any], *, goal_active: bool = False) -> str: + """Format complete grader details without serializing or truncating payloads. + + Args: + data: Custom-stream rubric event payload. + goal_active: Whether the rubric belongs to an unfinished `/goal`. + + Returns: + Plain text containing the full explanation, unmet criteria, and next step. + """ + result = data.get("result") + if result in {None, "satisfied"}: + return "" + + sections: list[str] = [] + explanation = str(data.get("explanation") or "").strip() + if explanation: + sections.append(f"Explanation\n{explanation}") + + criteria = data.get("criteria") + failing: list[tuple[str, str]] = [] + if isinstance(criteria, list): + for criterion in criteria: + if isinstance(criterion, dict) and criterion.get("passed") is False: + name = str(criterion.get("name") or "Unnamed criterion").strip() + gap = str(criterion.get("gap") or "").strip() + failing.append((name, gap)) + if failing: + lines = ["Unmet criteria"] + for name, gap in failing: + lines.append(f"- {name}" + (f"\n {gap}" if gap else "")) + sections.append("\n".join(lines)) + + if result == "max_iterations_reached" and goal_active: + next_step = ( + "The goal remains active. Continue with another prompt to resume or " + "retry, use `/goal ` to amend it, or `/goal clear` to clear it." + ) + elif result in {"needs_revision", "max_iterations_reached"}: + next_step = "Address every unmet criterion, then retry the check." + elif result == "failed": + next_step = "Review or replace the rubric before grading again." + elif result == "grader_error": + next_step = "Retry the check, or choose a different grader model." + else: + next_step = "Review the grader details before continuing." + sections.append(f"Next step\n{next_step}") + return "\n\n".join(sections) + + +class TextualUIAdapter: + """Adapter for rendering agent output to Textual widgets. + + This adapter provides an abstraction layer between the agent execution and the + Textual UI, allowing streaming output to be rendered as widgets. + """ + + def __init__( + self, + # Returns whether the widget reached the screen; most callers ignore it, + # but the diff path needs it to tell a rendered caveat from one dropped + # by a torn-down transcript. + mount_message: Callable[..., Awaitable[bool]], + update_status: Callable[[str], None], + request_approval: Callable[..., Awaitable[Any]], + on_auto_approve_enabled: Callable[[], Awaitable[bool] | bool | None] + | None = None, + on_switch_to_manual: Callable[[], Awaitable[bool] | bool] | None = None, + set_spinner: Callable[[_session_stats.SpinnerStatus], Awaitable[None]] + | None = None, + set_active_message: Callable[[str | None], None] | None = None, + on_user_visible_output_started: Callable[[], None] | None = None, + sync_message_content: Callable[[str, str], None] | None = None, + sync_tool_message: Callable[[ToolCallMessage], None] | None = None, + request_ask_user: ( + Callable[ + [list[Question]], + Awaitable[asyncio.Future[AskUserWidgetResult] | None], + ] + | None + ) = None, + on_tool_complete: Callable[[], None] | None = None, + on_subagent_event: Callable[[dict[str, Any]], None] | None = None, + on_auto_mode_event: ( + Callable[[dict[str, Any]], Awaitable[None] | None] | None + ) = None, + on_approval_mode_fallback: Callable[[str], None] | None = None, + *, + show_diff_line_numbers: bool = True, + ) -> None: + """Initialize the adapter.""" + self._mount_message = mount_message + """Async callback to mount a message widget to the chat.""" + + self._update_status = update_status + """Callback to update the status bar text.""" + + self._request_approval = request_approval + """Async callback that returns a Future for HITL approval.""" + + self._on_auto_approve_enabled = on_auto_approve_enabled + """Callback invoked before a Manual approval enables Auto.""" + + self._on_switch_to_manual = on_switch_to_manual + """Callback that persists Manual before an Auto fallback resumes.""" + + self._set_spinner = set_spinner + """Callback to show/hide loading spinner.""" + + self._set_active_message = set_active_message + """Callback to set the active streaming message ID (pass `None` to clear).""" + + self._on_user_visible_output_started = on_user_visible_output_started + """Callback fired after the first model text or tool-call widget renders. + + Hidden model and subagent output does not trigger it. A turn interrupted + before any user-visible model output produces zero firings. + """ + + self._sync_message_content = sync_message_content + """Callback to sync final message content back to the store after streaming.""" + + self._sync_tool_message = sync_tool_message + """Callback to sync a tool widget's mutable state back to the store.""" + + self._request_ask_user = request_ask_user + """Async callback for `ask_user` interrupts. + + When awaited, returns a `Future` that resolves to user answers. + """ + + self._on_tool_complete = on_tool_complete + """Sync callback fired after each `ToolMessage` is processed. + + The app uses this to refresh the footer's git branch as soon as an + agent-executed tool (e.g. `git checkout`) returns, instead of waiting + for the full turn to finish. + """ + + self._on_subagent_event = on_subagent_event + """Sync callback fired for each validated `subagent` custom-stream event.""" + + self._on_auto_mode_event = on_auto_mode_event + """Callback for compact sanitized Auto denial and fallback events.""" + + self._on_approval_mode_fallback = on_approval_mode_fallback + """Callback that synchronizes a fail-closed startup fallback to Manual.""" + + self._show_diff_line_numbers = show_diff_line_numbers + """Whether file-relative line numbers are shown in diff hunks.""" + + # State tracking + self._current_tool_messages: dict[str, ToolCallMessage] = {} + """Map of tool call IDs to their message widgets.""" + + # Token display callbacks (set by the app after construction) + self._on_tokens_update: _TokensUpdateCallback | None = None + """Called with total context tokens after each LLM response.""" + + self._on_tokens_pending: Callable[[], None] | None = None + """Called to show an unknown token count during streaming.""" + + self._on_tokens_show: _TokensShowCallback | None = None + """Called to restore the token display with the cached value.""" + + self._on_session_cost: _SessionCostCallback | None = None + """Called with the graph's absolute cumulative thread cost. + + The graph owns the durable total and streams it after each step, so this + is the only input the displayed lifetime figure is built from. + """ + + self._on_provisional_cost: _ProvisionalCostCallback | None = None + """Called with a streamed request's estimate for the live display only. + + Keeps the status bar moving during work whose cost the graph has not + checkpointed yet — a long subagent run, say — without making the client + a second authority: every server total replaces what this accumulated. + """ + + self._on_usage_update: Callable[[], None] | None = None + """Called after streamed request usage changes.""" + + self._on_stream_complete: Callable[[], None] | None = None + """Called only after the agent stream reaches a clean end.""" + + def _sync_tool_widget(self, tool_msg: ToolCallMessage) -> None: + """Sync a tool widget when the app provided a store callback. + + Total by contract: never raises. Call sites are scattered across the + turn loop, some outside try/except, so a sync failure must not abort + the turn — it is logged and swallowed here. + """ + if self._sync_tool_message is None: + return + try: + self._sync_tool_message(tool_msg) + except Exception: + logger.exception("Failed to sync tool widget state to store") + + def finalize_pending_tools_with_error(self, error: str) -> None: + """Mark all pending/running tool widgets as error and clear tracking. + + This is used as a safety net when an unexpected exception aborts + streaming before matching `ToolMessage` results are received. + + Args: + error: Error text to display in each pending tool widget. + """ + # Each pending widget already had its `tool.use` dispatched at mount, so + # emit terminal hooks before dropping them — otherwise an aborted stream + # leaves those `tool.use` events unterminated for audit consumers. Runs + # before the widget updates so a `set_error` failure can't skip it. + _dispatch_terminal_tool_result_hooks(self._current_tool_messages, error) + for tool_msg in list(self._current_tool_messages.values()): + # Guarded per row: this is the last-resort backstop, so one widget + # failing to render must not abort the sweep and leave the remaining + # rows tracked across turns (the `clear()` below would be skipped too). + try: + tool_msg.set_error(error) + self._sync_tool_widget(tool_msg) + except Exception: + logger.exception( + "Failed to finalize pending %s row with an error", + tool_msg.tool_name, + ) + self._current_tool_messages.clear() + + # Clear active streaming message to avoid stale "active" state in the store. + if self._set_active_message: + self._set_active_message(None) + + +def _build_interrupted_ai_message( + pending_text_by_namespace: dict[tuple, str], + current_tool_messages: dict[str, Any], +) -> AIMessage | None: + """Build an AIMessage capturing interrupted state (text + tool calls). + + Args: + pending_text_by_namespace: Dict of accumulated text by namespace + current_tool_messages: Dict of tool_id -> ToolCallMessage widget + + Returns: + AIMessage with accumulated content and tool calls, or None if empty. + """ + from langchain_core.messages import AIMessage + + main_ns_key = () + accumulated_text = pending_text_by_namespace.get(main_ns_key, "").strip() + + # Reconstruct tool_calls from displayed tool messages + tool_calls = [] + for tool_id, tool_widget in list(current_tool_messages.items()): + if tool_widget.deferred_success_output is not None: + # An answered `ask_user` stays tracked until its `ToolMessage` + # arrives, so a cancel lands here with the row still present. The + # graph already owns this tool call in its checkpoint, so adding it + # would append a second `tool_use` with no matching `tool_result` — + # which the provider rejects, surfacing turns later as an opaque 400 + # with nothing pointing back to the cancelled question. + # + # Gated on `deferred_success_output`, not `is_awaiting_deferred_result`: + # the hazard is that the graph owns the call, which stays true once the + # row has fallen back to its summary. A settled row can still be + # tracked here (a permission hook returning `plan.interrupted` settles + # it without popping it), and it must be omitted too. + logger.info( + "Omitting tool call %s from interrupted AIMessage; the graph " + "already owns it via its deferred result", + tool_id, + ) + continue + tool_calls.append( + { + "id": tool_id, + "name": tool_widget._tool_name, + "args": tool_widget._args, + } + ) + + if not accumulated_text and not tool_calls: + return None + + return AIMessage( + content=accumulated_text, + tool_calls=tool_calls or [], + ) + + +def _interrupt_owned_tool_rows( + action_requests: Iterable[Mapping[str, Any]], + current_tool_messages: Mapping[str, ToolCallMessage], +) -> list[ToolCallMessage]: + """Return the tracked tool rows a nested interrupt's action requests own. + + Used by `_interrupt_tool_rows` for a nested (non-main-agent) checkpoint, + whose pause/resume must touch only the specific tool calls it carries so + unrelated outer `task` rows keep running. Because a `HITLRequest`'s + `ActionRequest` carries no tool-call id, ownership is matched by tool name + plus argument value-equality (order-independent `dict` comparison). Each + candidate row is claimed at most once, so two identical calls map to two + distinct rows. + + Two caveats follow from matching on args value rather than an id: + + - It relies on the human-in-the-loop middleware surfacing the tool call's + `args` unchanged in the action request (true as of the pinned + `langchain` middleware). If that ever diverges — normalization, a JSON + round-trip, redaction — the match degrades silently to returning fewer + rows; `test_matches_row_by_name_and_args` guards the current contract. + - A nested action request that happens to share a name and args with a + concurrently tracked row (e.g. an identical `execute` call at another + nesting level) can misattribute that row. This is strictly rarer than + pausing every row and self-corrects, since the same helper drives both + pause and resume. + + A nested subagent's own child tool call is not tracked in + `current_tool_messages` — message-stream tool rows are gated to the main + agent (see the `is_main_agent` check) — so a purely nested interrupt + normally matches nothing and leaves every outer row untouched, keeping the + still-running `task` timers monotonic across the checkpoint. + + Args: + action_requests: The interrupt's action requests (`name` + `args`). + current_tool_messages: Live map of tool-call id to tracked tool row. + + Returns: + The subset of tracked rows owned by these action requests, in + request order. + """ + candidates = list(current_tool_messages.values()) + claimed_ids: set[int] = set() + owned: list[ToolCallMessage] = [] + for request in action_requests: + name = request.get("name") + args = request.get("args", {}) + for tool_msg in candidates: + if id(tool_msg) in claimed_ids: + continue + if tool_msg.tool_name == name and tool_msg.args == args: + owned.append(tool_msg) + claimed_ids.add(id(tool_msg)) + break + return owned + + +def _interrupt_tool_rows( + namespace: tuple[Any, ...], + action_requests: Iterable[Mapping[str, Any]], + current_tool_messages: Mapping[str, ToolCallMessage], +) -> list[ToolCallMessage]: + """Return rows blocked by an interrupt at `namespace`. + + A main-agent checkpoint prevents its entire parallel tool batch from + reaching the tool node, including ungated siblings omitted from the HITL + action requests. Nested checkpoints must remain scoped to their own action + requests so unrelated outer `task` rows keep running. + + Args: + namespace: Stream namespace that emitted the interrupt. + action_requests: The interrupt's reviewed tool calls. + current_tool_messages: Live map of tool-call id to tracked tool row. + + Returns: + Every tracked row for a main-agent interrupt, otherwise only rows owned + by the nested interrupt's action requests. + """ + if not namespace: + return list(current_tool_messages.values()) + return _interrupt_owned_tool_rows(action_requests, current_tool_messages) + + +def _read_mentioned_file(file_path: Path, max_embed_bytes: int) -> str: + """Read a mentioned file for inline embedding (sync, for use with to_thread). + + Args: + file_path: Resolved path to the file. + max_embed_bytes: Size threshold; larger files get a reference only. + + Returns: + Markdown snippet with the file content or a size-exceeded reference. + """ + file_size = file_path.stat().st_size + if file_size > max_embed_bytes: + size_kb = file_size // 1024 + return ( + f"\n### {file_path.name}\n" + f"Path: `{file_path}`\n" + f"Size: {size_kb}KB (too large to embed, " + "use read_file tool to view)" + ) + content = file_path.read_text(encoding="utf-8") + return f"\n### {file_path.name}\nPath: `{file_path}`\n```text\n{content}\n```" + + +def _is_renderable_subagent_event(data: Any, *, is_main_agent: bool) -> bool: # noqa: ANN401 # custom-stream payload is dynamic + """Whether a `custom` payload is a subagent event this UI can render. + + Guards the live panel against unrelated/malformed custom events and against + nested (subagent-to-subagent) emissions. + + Args: + data: The `custom` stream payload. + is_main_agent: Whether the event came from the main agent's namespace + (the empty namespace). Nested emissions are ignored. + + Returns: + True only for a well-formed subagent event from the main agent. + """ + return is_main_agent and isinstance(data, dict) and data.get("type") == "subagent" + + +def _session_cost_total(data: Any, *, is_main_agent: bool) -> float | None: # noqa: ANN401 # custom-stream payload is dynamic + """Return the absolute thread cost carried by a session-cost event. + + Args: + data: The `custom` stream payload. + is_main_agent: Whether the payload came from the top-level namespace. + Only the main agent owns the cost channel, so a nested emit is + treated as malformed rather than applied to the displayed total. + + Returns: + The finite non-negative total in US dollars, or `None` when the payload + is not a well-formed session-cost event from the main agent. + """ + from deepagents_code.cost_tracking import SESSION_COST_EVENT_TYPE + + if ( + not is_main_agent + or not isinstance(data, dict) + or data.get("type") != SESSION_COST_EVENT_TYPE + ): + return None + total = data.get("total") + if isinstance(total, bool) or not isinstance(total, int | float): + return None + total_usd = float(total) + if not math.isfinite(total_usd) or total_usd < 0: + return None + return total_usd + + +def _session_cost_thread_id(data: Any) -> str: # noqa: ANN401 # custom-stream payload is dynamic + """Return the thread a session-cost event belongs to. + + Args: + data: The `custom` stream payload, already validated as a cost event. + + Returns: + The event's thread ID, or `""` when the payload omits one. An empty + result means the total cannot be attributed, so the client applies + it rather than discarding a legitimate update. + """ + if not isinstance(data, dict): + return "" + thread_id = data.get("thread_id") + return thread_id if isinstance(thread_id, str) else "" + + +def _session_cost_pricing_ok(data: Any) -> bool | None: # noqa: ANN401 # custom-stream payload is dynamic + """Return whether the pricing process reported healthy price data. + + Args: + data: The `custom` stream payload, already validated as a cost event. + + Returns: + The event's `pricing_ok` flag, or `None` when the payload omits it or + states a non-boolean. `None` means "unknown", which leaves the + client's own view of pricing health untouched rather than + overriding it with a guess. + """ + if not isinstance(data, dict): + return None + pricing_ok = data.get("pricing_ok") + return pricing_ok if isinstance(pricing_ok, bool) else None + + +def _require_approval_mode_key(value: str | None) -> str: + """Return a written Store key for fail-closed startup. + + Raises: + RuntimeError: If the remote agent has no Store writer. + """ + if value is None: + msg = "Approval-mode Store writer is unavailable" + raise RuntimeError(msg) + return value + + +def _is_renderable_auto_mode_event(data: Any, *, is_main_agent: bool) -> bool: # noqa: ANN401 + """Return whether a custom event is a sanitized top-level Auto event.""" + if ( + not is_main_agent + or not isinstance(data, dict) + or data.get("type") != "auto_mode" + ): + return False + event = data.get("event") + reason = data.get("reason") + mode = data.get("mode") + return ( + event in {"denial", "unavailable", "fallback", "warning"} + and (reason is None or isinstance(reason, str)) + and (mode is None or (event == "fallback" and mode == "manual")) + ) + + +async def _finalize_usage_round( + stream: AsyncIterator[Any], + recorded_requests: dict[str, _session_stats.RecordedRequest], +) -> AsyncIterator[Any]: + """Close streamed usage records when one graph stream pass ends. + + Args: + stream: One invocation of the graph's event stream. + recorded_requests: Turn ledger shared across resume passes. + + Yields: + Each graph event from the wrapped stream. + """ + try: + async for chunk in stream: + yield chunk + finally: + _session_stats.finalize_recorded_requests(recorded_requests) + + +async def _mount_diff_note(adapter: Any, text: str) -> None: # noqa: ANN401 # adapter type is the TUI callback bundle + """Mount a standalone transcript note about a diff that could not be shown. + + A last resort for the cases where no tool row and no diff body survived to + carry the message. The transcript is the surface these statements were + written for; a log line reaches only a user who already suspects something + is wrong and knows to open the Debug Console. + + Guarded because it runs on the turn loop: failing to render a note about a + rendering failure must not abort the turn and drop the remaining tools' + hooks. + + Args: + adapter: The stream adapter holding the mount callback. + text: The sentence to display. + """ + try: + await adapter._mount_message(AppMessage(text)) + except Exception: + logger.exception("Failed to mount diff note: %s", text) + + +async def execute_task_textual( + user_input: str, + agent: Any, # noqa: ANN401 # Dynamic agent graph type + assistant_id: str | None, + session_state: Any, # noqa: ANN401 # Dynamic session state type + adapter: TextualUIAdapter, + backend: Any = None, # noqa: ANN401 # Dynamic backend type + image_tracker: MediaTracker | None = None, + context: CLIContext | None = None, + *, + sandbox_type: str | None = None, + message_kwargs: dict[str, Any] | None = None, + graph_input: dict[str, Any] | None = None, + rubric: str | None = None, + goal_active: bool = False, + on_rubric_evaluation_end: Callable[[RubricEvaluationEnd], None] | None = None, + turn_stats: _session_stats.SessionStats | None = None, +) -> _session_stats.SessionStats: + """Execute a task with output directed to Textual UI. + + This is the Textual-compatible version of execute_task() that uses + the TextualUIAdapter for all UI operations. + + Args: + user_input: The user's input message + agent: The LangGraph agent to execute + assistant_id: The agent identifier + session_state: Session state with a typed approval mode. + adapter: The TextualUIAdapter for UI operations. + backend: Optional backend for file operations. + image_tracker: Optional tracker for images. + context: Optional `CLIContext` with model override and params. The current + mode is persisted and copied into runtime context before every stream + iteration. + sandbox_type: Sandbox provider name for trace metadata, or `None` + if no sandbox is active. + message_kwargs: Extra fields merged into the stream input message + dict (e.g., `additional_kwargs` for persisting skill metadata + in the checkpoint). + graph_input: Prepared non-conversation input for a server-side graph + operation. When provided, no user message or media is constructed. + rubric: Acceptance criteria supplied to `RubricMiddleware` via graph + input state. + goal_active: Whether the rubric belongs to an unfinished `/goal`. + on_rubric_evaluation_end: Optional callback receiving a validated + `RubricEvaluationEnd` (grading run ID and verdict) for each + main-agent `rubric_evaluation_end` event. + turn_stats: Pre-created `SessionStats` to accumulate into. + + When the caller holds a reference to the same object, stats are + available even if this coroutine is cancelled before it can return. + + If `None`, a new instance is created internally. + + Returns: + Stats accumulated over this turn (request count, token counts, + wall-clock time). + + Raises: + ClientHookStopError: If a compact lifecycle hook stops processing. + ValidationError: If HITL request validation fails (re-raised). + RuntimeError: If Manual cannot be persisted before graph execution. + """ + from langchain.agents.middleware.human_in_the_loop import ( + ApproveDecision, + HITLRequest, + RejectDecision, + ) + from langchain_core.messages import HumanMessage, ToolMessage + from langgraph.types import Command + from pydantic import ValidationError + + from deepagents_code.approval_mode import ApprovalMode, awrite_approval_mode + from deepagents_code.auto_mode import USER_PROMPT_METADATA_KEY, user_prompt_metadata + from deepagents_code.hooks.client_lifecycle import ClientHookStopError + from deepagents_code.hooks.models.domain import HookEvent + + hitl_request_adapter = _get_hitl_request_adapter(HITLRequest) + ask_user_adapter = _get_ask_user_adapter() + + message_content: str | list[dict[str, Any]] | None = None + if graph_input is None: + prompt_text, mentioned_files = await asyncio.to_thread( + parse_file_mentions, user_input + ) + max_embed_bytes = 256 * 1024 + + if mentioned_files: + context_parts = [prompt_text, "\n\n## Referenced Files\n"] + for file_path in mentioned_files: + try: + part = await asyncio.to_thread( + _read_mentioned_file, file_path, max_embed_bytes + ) + context_parts.append(part) + except Exception as e: # noqa: BLE001 # Resilient adapter error handling + context_parts.append( + f"\n### {file_path.name}\n[Error reading file: {e}]" + ) + final_input = "\n".join(context_parts) + else: + final_input = prompt_text + + images_to_send = [] + videos_to_send = [] + if image_tracker: + images_to_send = image_tracker.get_images() + videos_to_send = image_tracker.get_videos() + if images_to_send or videos_to_send: + message_content = create_multimodal_content( + final_input, images_to_send, videos_to_send + ) + else: + message_content = final_input + + thread_id = session_state.thread_id + # Advance the per-thread turn markers (coding-agent-v1 turn_id/turn_number) + # once per user prompt, before building the stream config. `session_state` + # is duck-typed (`Any`): the production `TextualSessionState` always has + # `advance_turn`, but lightweight callers/test doubles may not, so probe for + # it and degrade to no turn markers rather than raising. + advance_turn = getattr(session_state, "advance_turn", None) + if graph_input is None and callable(advance_turn): + turn_id, turn_number = advance_turn() + else: + turn_id, turn_number = None, None + # `build_stream_config` does blocking git filesystem reads and may shell out + # to `git`; offload it so the Textual event loop stays responsive. Advancing + # the turn markers above is pure/cheap and stays on the loop. + # + # `auto_approve` is sampled once here, at turn start, so it labels the trace + # with the mode the turn began in. A mid-turn Shift+Tab toggle still changes + # execution behavior (via `context`) but does not relabel this turn's trace. + config = await asyncio.to_thread( + build_stream_config, + thread_id, + assistant_id, + sandbox_type=sandbox_type, + turn_id=turn_id, + turn_number=turn_number, + auto_approve=bool(session_state.auto_approve), + ) + + captured_input_tokens = 0 + captured_output_tokens = 0 + recorded_usage_requests: dict[str, _session_stats.RecordedRequest] = {} + if turn_stats is None: + turn_stats = _session_stats.SessionStats() + start_time = time.monotonic() + + # Warn if token display callbacks are only partially wired — all three + # should be set together to avoid inconsistent status-bar behavior. + token_cbs = ( + adapter._on_tokens_update, + adapter._on_tokens_pending, + adapter._on_tokens_show, + ) + if any(token_cbs) and not all(token_cbs): + logger.warning( + "Token callbacks partially wired (update=%s, pending=%s, show=%s); " + "token display may behave inconsistently", + adapter._on_tokens_update is not None, + adapter._on_tokens_pending is not None, + adapter._on_tokens_show is not None, + ) + + # Show unknown token count during streaming; the accurate count arrives at turn end. + if adapter._on_tokens_pending: + adapter._on_tokens_pending() + + file_op_tracker = FileOpTracker(assistant_id=assistant_id, backend=backend) + # Fires at most once per turn, after the first main-agent text or tool-call + # widget becomes visible, so hidden model activity cannot block prompt restore. + user_visible_output_started = False + + def _notify_user_visible_output_started() -> None: + """Fire the output-started callback once, on the first visible output. + + Call only from main-agent, post-filter paths: the "hidden output does + not count" guarantee lives in the placement of the call sites (all sit + after the subagent and summarization `continue`s), not in any check + here — this helper only dedupes. + """ + nonlocal user_visible_output_started + if user_visible_output_started: + return + user_visible_output_started = True + if adapter._on_user_visible_output_started: + try: + adapter._on_user_visible_output_started() + except Exception: + # A prompt-restore gate update must never abort agent + # streaming — log and keep going (mirrors `_on_tool_complete`). + logger.warning( + "on_user_visible_output_started callback failed", + exc_info=True, + ) + + displayed_tool_ids: set[str] = set() + tool_call_buffers: dict[ToolCallBufferKey, ToolCallBuffer] = {} + # Tool-call ids that already received terminal hooks before a resumed + # `ToolMessage` can stream. When the turn still resumes, middleware + # synthetic messages would otherwise re-dispatch `tool.result`; this set + # suppresses those duplicates. + completed_tool_result_ids: set[str] = set() + # `ask_user` answers are private user input, so its terminal hook carries a + # sanitized summary rather than the transcript. Wait for the authoritative + # ToolMessage before dispatching it so the hook status matches the result + # persisted to the thread and sent to the model. + # + # Popped only when that ToolMessage arrives; an entry here is simply abandoned + # if it never does. Abandoning it does not leave the `tool.use` unterminated: + # the teardown sweeps close the row out, reading the outcome `defer_success` + # recorded on the widget itself. + # + # Turn-local, so nothing leaks across turns. The intent is that this dict and + # that widget flag stay in step — set both when deferring, and let the same + # ToolMessage clear both — but they can legitimately diverge: the entry is + # added unconditionally while `defer_success` needs a mounted row, so a torn- + # down DOM leaves an entry with no flag (logged at the deferral site, and + # handled by the no-widget branch in the `ToolMessage` handler). + deferred_tool_result_hooks: dict[str, DeferredToolResultHook] = {} + + # Track pending text and assistant messages PER NAMESPACE to avoid interleaving + # when multiple subagents stream in parallel + pending_text_by_namespace: dict[tuple, str] = {} + assistant_message_by_namespace: dict[tuple, Any] = {} + hooks = session_state.hooks + transcript = hooks.recorder(thread_id) + + if image_tracker and graph_input is None: + image_tracker.clear() + + if graph_input is None: + user_msg: dict[str, Any] = {"role": "user", "content": message_content} + if message_kwargs: + user_msg.update(message_kwargs) + additional_kwargs = user_msg.get("additional_kwargs") + trusted_kwargs = ( + dict(additional_kwargs) if isinstance(additional_kwargs, dict) else {} + ) + trusted_kwargs[USER_PROMPT_METADATA_KEY] = user_prompt_metadata( + user_input, + [str(path) for path in mentioned_files], + turn_id=turn_id, + ) + user_msg["additional_kwargs"] = trusted_kwargs + messages: list[dict[str, Any]] = [] + transcript.append([HumanMessage(content=message_content or "")]) + if hooks.has_handlers(HookEvent.USER_PROMPT_SUBMIT): + prompt_outcome = await hooks.on_user_prompt(user_input) + if not prompt_outcome.ok: + from deepagents_code.hooks.client_lifecycle import ClientHookStopError + + raise ClientHookStopError( + prompt_outcome.stop_reason + or "User prompt submission stopped by hook" + ) + else: + prompt_outcome = PromptOutcome() + await dispatch_hook("session.start", {"thread_id": thread_id}) + await dispatch_hook("user.prompt", {}) + session_context = hooks.take_pending_context(thread_id=thread_id) + if session_context: + messages.append({"role": "system", "content": "\n\n".join(session_context)}) + if prompt_outcome.context: + messages.append( + {"role": "system", "content": "\n\n".join(prompt_outcome.context)} + ) + if not prompt_outcome.suppress_original_prompt: + messages.append(user_msg) + stream_input: dict | Command = { + "messages": messages, + "goal_criteria_request": None, + } + if rubric: + stream_input["rubric"] = rubric + else: + stream_input = dict(graph_input) + recover_interrupted_turn = not ( + graph_input is not None and graph_input.get("goal_criteria_request") is not None + ) + + # Track summarization lifecycle so spinner status and notification stay in sync. + summarization_in_progress = False + completed_compaction_ids: set[str] = set() + + async def _after_automatic_compact() -> None: + from deepagents_code.config import settings + from deepagents_code.hooks.client_lifecycle import ClientHookStopError + from deepagents_code.hooks.models.domain import SessionStartCause + + outcome = await hooks.on_session_start( + SessionStartCause.COMPACT, + model=settings.model_name or None, + ) + if not outcome.ok: + raise ClientHookStopError( + outcome.stop_reason or "Compact session start stopped by hook" + ) + + try: + while True: + interrupt_occurred = False + suppress_resumed_output = False + pending_interrupts: dict[str, tuple[tuple[Any, ...], HITLRequest]] = {} + pending_ask_user: dict[str, AskUserRequest] = {} + pending_hook_resumes: dict[str, dict[str, Any]] = {} + + if context is None: + context = CLIContext() + context["thread_id"] = thread_id + if turn_id is not None: + context["turn_id"] = turn_id + else: + context.pop("turn_id", None) + raw_mode = getattr(session_state, "approval_mode", None) + if raw_mode is None: + raw_mode = ( + ApprovalMode.YOLO + if getattr(session_state, "auto_approve", False) + else ApprovalMode.MANUAL + ) + try: + selected_mode = ApprovalMode(raw_mode) + except (TypeError, ValueError): + selected_mode = ApprovalMode.MANUAL + context["approval_mode"] = selected_mode.value + context["auto_approve"] = selected_mode is not ApprovalMode.MANUAL + try: + live_key = _require_approval_mode_key( + await awrite_approval_mode( + agent, + thread_id, + mode=selected_mode, + ) + ) + except Exception: + logger.warning( + "Failed to persist selected approval mode; forcing Manual", + exc_info=True, + ) + try: + live_key = _require_approval_mode_key( + await awrite_approval_mode( + agent, + thread_id, + mode=ApprovalMode.MANUAL, + ) + ) + except Exception as exc: + context["approval_mode"] = ApprovalMode.MANUAL.value + context["auto_approve"] = False + context.pop("approval_mode_key", None) + session_state.approval_mode = ApprovalMode.MANUAL + session_state.approval_mode_key = None + if adapter._on_approval_mode_fallback is not None: + adapter._on_approval_mode_fallback(ApprovalMode.MANUAL.value) + adapter._update_status("Approval mode fell back to Manual") + msg = ( + "Manual approval mode could not be persisted; graph execution " + "is blocked until the Store is available." + ) + raise RuntimeError(msg) from exc + selected_mode = ApprovalMode.MANUAL + session_state.approval_mode = ApprovalMode.MANUAL + context["approval_mode"] = ApprovalMode.MANUAL.value + context["auto_approve"] = False + if adapter._on_approval_mode_fallback is not None: + adapter._on_approval_mode_fallback(ApprovalMode.MANUAL.value) + adapter._update_status("Approval mode fell back to Manual") + context["approval_mode_key"] = live_key + session_state.approval_mode_key = live_key + + from deepagents_code.hooks.interrupt import is_hook_interrupt_payload + from deepagents_code.hooks.models.domain import HookEvent + + hooks.apply_graph_context(context) + + # Show the Thinking spinner before each astream iteration so + # both the first turn and HITL/ask_user resumes surface feedback + # while the model processes input. Skip when + # `_current_tool_messages` is non-empty so running-tool + # indicators remain the dominant signal. + if adapter._set_spinner and not adapter._current_tool_messages: + await adapter._set_spinner("Thinking") + + stream = agent.astream( + stream_input, + stream_mode=["messages", "updates", "custom"], + subgraphs=True, + config=config, + context=context, + durability="exit", + ) + async for chunk in _finalize_usage_round( + stream, + recorded_usage_requests, + ): + if not isinstance(chunk, tuple) or len(chunk) != 3: # noqa: PLR2004 # stream chunk is a 3-tuple (namespace, mode, data) + logger.debug("Skipping non-3-tuple chunk: %s", type(chunk).__name__) + continue + + namespace, current_stream_mode, data = chunk + + # Convert namespace to hashable tuple for dict keys + ns_key = tuple(namespace) if namespace else () + + # Filter out subagent outputs - only show main agent (empty + # namespace). Subagents run via Task tool and should only + # report back to the main agent + is_main_agent = ns_key == () + + # Handle CUSTOM stream - live subagent fan-out events emitted by + # the QuickJS task() bridge during a js_eval call. Validate at + # this boundary before forwarding so unrelated/malformed or + # nested custom events never reach the panel; forwarding must + # never raise into the stream loop. + if current_stream_mode == "custom": + # The graph owns the cumulative thread cost and streams the + # new absolute total after each step it charges, because the + # channel is schema-private and never reaches the state + # stream. Applying it outright keeps the client a reader. + session_cost_total = _session_cost_total( + data, is_main_agent=is_main_agent + ) + if session_cost_total is not None: + if adapter._on_session_cost is not None: + try: + adapter._on_session_cost( + session_cost_total, + thread_id=_session_cost_thread_id(data), + pricing_ok=_session_cost_pricing_ok(data), + ) + except Exception: + logger.warning( + "on_session_cost callback failed", exc_info=True + ) + continue + + rubric_message = data if isinstance(data, dict) else None + formatted_rubric_event = ( + _format_rubric_event(rubric_message) if rubric_message else None + ) + if ( + formatted_rubric_event is not None + and rubric_message is not None + and is_main_agent + ): + details = ( + _format_rubric_details( + rubric_message, + goal_active=goal_active, + ) + if rubric_message.get("type") == "rubric_evaluation_end" + else "" + ) + message = ( + RubricResultMessage(formatted_rubric_event, details) + if details + else AppMessage(formatted_rubric_event) + ) + await adapter._mount_message(message) + if ( + on_rubric_evaluation_end is not None + and rubric_message.get("type") == "rubric_evaluation_end" + ): + grading_run_id = rubric_message.get("grading_run_id") + result = rubric_message.get("result") + if ( + isinstance(grading_run_id, str) + and grading_run_id.strip() + and isinstance(result, str) + ): + # Structurally validated here; the verdict is + # cast to `RubricResult` at this boundary and the + # consumer re-checks it against the known set. + try: + on_rubric_evaluation_end( + RubricEvaluationEnd( + grading_run_id=grading_run_id.strip(), + result=cast("RubricResult", result), + ) + ) + except Exception: + logger.warning( + "on_rubric_evaluation_end callback failed", + exc_info=True, + ) + continue + if formatted_rubric_event is not None: + # Rubric events come from the main agent today; a + # non-main namespace would be dropped by the gate above, + # so leave a breadcrumb if that ever changes. + logger.debug( + "Dropping rubric event from non-main namespace %r", + ns_key, + ) + if ( + adapter._on_subagent_event is not None + and _is_renderable_subagent_event( + data, is_main_agent=is_main_agent + ) + ): + try: + adapter._on_subagent_event(data) + except Exception: + logger.exception("subagent panel event handler failed") + if ( + adapter._on_auto_mode_event is not None + and _is_renderable_auto_mode_event( + data, is_main_agent=is_main_agent + ) + ): + try: + callback_result = adapter._on_auto_mode_event(data) + if callback_result is not None: + await callback_result + except Exception: + logger.exception("Auto mode event handler failed") + continue + + # Handle UPDATES stream - for interrupts and todos + if current_stream_mode == "updates": + if not isinstance(data, dict): + continue + + # Check for interrupts + if "__interrupt__" in data: + interrupts: list[Interrupt] = data["__interrupt__"] + if interrupts: + for interrupt_obj in interrupts: + iv = interrupt_obj.value + if is_hook_interrupt_payload(iv): + resume_value = await hooks.fulfill_interrupt(iv) + pending_hook_resumes[interrupt_obj.id] = ( + resume_value + ) + interrupt_occurred = True + continue + if ( + isinstance(iv, dict) + and iv.get("type") == "ask_user" + ): + try: + validated_ask_user = ( + ask_user_adapter.validate_python(iv) + ) + pending_ask_user[interrupt_obj.id] = ( + validated_ask_user + ) + tool_id = validated_ask_user["tool_call_id"] + if tool_id not in displayed_tool_ids: + if adapter._set_spinner: + await adapter._set_spinner(None) + tool_args = { + "questions": validated_ask_user[ + "questions" + ] + } + tool_msg = ToolCallMessage( + "ask_user", + tool_args, + ) + try: + await adapter._mount_message(tool_msg) + except Exception: + # Mount failed (e.g. a torn-down + # DOM during shutdown). tool.use + # is dispatched only on mount + # success (below), so a failed + # mount leaves no unterminated + # tool.use to orphan if the turn + # is then cancelled before the + # ask_user resolution loop runs. + # The id is left unlatched so a + # re-observed interrupt can retry + # the mount; the question is still + # asked and closed by the + # resolution loop, which + # dispatches the terminal + # tool.result independently of + # this widget. + logger.exception( + "Failed to mount ask_user " + "tool row for %s", + tool_id, + ) + else: + _notify_user_visible_output_started() + # Fire tool.use and latch the id + # together, only once the widget + # is mounted, so the "every + # tool.use is closed" guarantee + # holds with no widget-less orphan + # on the mount-failure path. + # Gating on mount success also + # keeps tool.use fire-once: a + # failed mount never fires it, and + # a successful mount latches the + # id so a re-observed interrupt is + # skipped. + _dispatch_tool_use_hook( + "ask_user", tool_id, tool_args + ) + displayed_tool_ids.add(tool_id) + adapter._current_tool_messages[ + tool_id + ] = tool_msg + interrupt_occurred = True + if not hooks.has_handlers( + HookEvent.NOTIFICATION + ): + await dispatch_hook("input.required", {}) + except ValidationError: + logger.exception( + "Invalid ask_user interrupt payload" + ) + raise + else: + try: + validated_request = ( + hitl_request_adapter.validate_python(iv) + ) + pending_interrupts[interrupt_obj.id] = ( + ns_key, + validated_request, + ) + interrupt_occurred = True + if not hooks.has_handlers( + HookEvent.NOTIFICATION + ): + await dispatch_hook("input.required", {}) + except ValidationError: # noqa: TRY203 # Re-raise preserves exception context in handler + raise + + # Check for todo updates (not yet implemented in Textual UI) + chunk_data = next(iter(data.values())) if data else None + if ( + chunk_data + and isinstance(chunk_data, dict) + and "todos" in chunk_data + ): + pass # Future: render todo list widget + + # Handle MESSAGES stream - for content and tool calls + elif current_stream_mode == "messages": + if not isinstance(data, tuple) or len(data) != 2: # noqa: PLR2004 # message stream data is a 2-tuple (message, metadata) + logger.debug( + "Skipping non-2-tuple message data: type=%s", + type(data).__name__, + ) + continue + + message, metadata = data + if transcript is not None: + transcript.record( + message, + metadata if isinstance(metadata, dict) else None, + main_agent=is_main_agent, + ) + logger.debug( + "Processing message: type=%s id=%s has_content_blocks=%s", + type(message).__name__, + getattr(message, "id", None), + hasattr(message, "content_blocks"), + ) + + # Account cost/tokens before render filters. Subagent + # namespaces and summarization/auto-classifier calls still + # spend money even though their text stays out of the chat. + recorded_usage = None + if getattr(message, "usage_metadata", None): + from deepagents_code.config import settings + + recorded_usage = _session_stats.record_message_usage( + turn_stats, + message, + fallback_model=settings.model_name or "", + fallback_provider=settings.model_provider or "", + request_metadata=( + metadata if isinstance(metadata, dict) else None + ), + kind=_session_stats.classify_usage_kind( + is_main_agent=is_main_agent, + metadata=( + metadata if isinstance(metadata, dict) else None + ), + ), + recorded_requests=recorded_usage_requests, + ) + if recorded_usage is not None and adapter._on_usage_update: + adapter._on_usage_update() + if recorded_usage is not None and ( + recorded_usage.cost_usd is not None + and adapter._on_provisional_cost + ): + # Display-only: the graph checkpoints the same spend + # and streams the authoritative total, which + # supersedes this estimate. + try: + adapter._on_provisional_cost(recorded_usage.cost_usd) + except Exception: + logger.warning( + "on_provisional_cost callback failed", exc_info=True + ) + + # Skip subagent outputs - only render main agent content in chat + if not is_main_agent: + logger.debug("Skipping subagent message ns=%s", ns_key) + continue + + # Filter out summarization model output, but keep UI feedback. + # The summarization model streams AIMessage chunks tagged + # with lc_source="summarization" in the callback metadata. + # These are hidden from the user; only the spinner and a + # notification widget provide feedback. + if _is_summarization_chunk(metadata): + if not summarization_in_progress: + summarization_in_progress = True + if adapter._set_spinner: + await adapter._set_spinner("Offloading") + continue + + # The Auto mode authorization classifier is a nested model + # call. Its structured JSON is internal policy machinery, + # not assistant output for the conversation transcript. + if _is_auto_mode_classifier_chunk(metadata): + continue + + # Only a visible top-level model call represents the active + # conversation context. Hidden usage was still recorded above. + if recorded_usage is not None: + captured_input_tokens = max( + captured_input_tokens, + recorded_usage.request_tokens, + ) + + # Regular (non-summarization) chunks resumed — summarization + # has finished. Mount the notification and reset the spinner. + if summarization_in_progress: + summarization_in_progress = False + if isinstance(message, ToolMessage): + raw_id = getattr(message, "tool_call_id", None) + if ( + isinstance(raw_id, str) + and raw_id + and getattr(message, "name", None) + == "compact_conversation" + and str(message.content).startswith( + "Conversation compacted." + ) + ): + completed_compaction_ids.add(raw_id) + await _after_automatic_compact() + try: + await adapter._mount_message(SummarizationMessage()) + except Exception: + logger.debug( + "Failed to mount summarization notification", + exc_info=True, + ) + if adapter._set_spinner and not adapter._current_tool_messages: + await adapter._set_spinner("Thinking") + + if isinstance(message, HumanMessage): + content = message.text + # Flush pending text for this namespace + pending_text = pending_text_by_namespace.get(ns_key, "") + if content and pending_text: + await _flush_assistant_text_ns( + adapter, + pending_text, + ns_key, + assistant_message_by_namespace, + ) + pending_text_by_namespace[ns_key] = "" + # Drop the cached assistant bubble too, not just the + # pending text: a mid-turn HumanMessage (e.g. the + # rubric revision loop re-prompting the agent) means + # the next assistant text is a fresh response and + # must start a new bubble rather than appending to + # the pre-revision one. + assistant_message_by_namespace.pop(ns_key, None) + continue + + if isinstance(message, ToolMessage): + tool_name = getattr(message, "name", "") + # Normalize to the two-value hook domain, fail-closed: an + # unexpected provider status is logged and treated as an + # error (see `normalize_tool_status`) rather than silently + # reported as success. + tool_status: ToolStatus = normalize_tool_status( + getattr(message, "status", "success"), tool_name + ) + # Guard formatting *and* the str() coercion so a + # pathological __str__ on the content can't re-raise and + # skip the tool.result dispatch below. On failure use a + # sentinel rather than re-touching the offending content, + # so the terminal dispatch is genuinely unconditional. + try: + tool_content = format_tool_message_content(message.content) + output_str = str(tool_content) if tool_content else "" + except Exception: + logger.exception("Failed to format tool output") + output_str = UNRENDERABLE_TOOL_OUTPUT + compaction_id = getattr(message, "tool_call_id", None) + if ( + isinstance(compaction_id, str) + and compaction_id + and compaction_id not in completed_compaction_ids + and tool_name == "compact_conversation" + and output_str.startswith("Conversation compacted.") + ): + completed_compaction_ids.add(compaction_id) + await _after_automatic_compact() + record = file_op_tracker.complete_with_message(message) + # Computed once, ahead of the four branches below, so a + # caveat cannot depend on which of them this result takes + # — the diff mounts outside all four, so a torn-down row + # used to yield a `DiffMessage` and no explanation. + caveat = record_display_caveat(record) + caveat_shown = False + + # Update tool call status with output + tool_id = getattr(message, "tool_call_id", None) + deferred_hook = ( + deferred_tool_result_hooks.pop(tool_id, None) + if tool_id + else None + ) + # This streamed result owns the status; the deferral only + # replaces the hook body to keep answers out of hook + # scripts. A failure reports the constant failure summary + # rather than the `(error: ...)` transcript. + hook_output: str + if deferred_hook is None: + hook_output = output_str + elif tool_status == "error": + hook_output = ASK_USER_FAILED_SUMMARY + else: + hook_output = deferred_hook.tool_output + tool_msg: ToolCallMessage | None = None + if tool_id and tool_id in adapter._current_tool_messages: + # Pop before the widget calls so the dict drains even + # if set_success/set_error raises. + tool_msg = adapter._current_tool_messages.pop(tool_id) + # This result is authoritative, so it supersedes any + # deferred outcome — including with an error, which + # `set_error` would otherwise redirect back to the + # deferred success. + tool_msg.clear_deferred_success() + # Dispatch the terminal hooks *before* touching the + # widget: a render failure must never drop this tool's + # tool.result/tool.error (which would leave its + # tool.use unterminated). The headless path likewise + # dispatches without depending on any widget. + if tool_status == "error": + _dispatch_tool_error_hook(tool_msg.tool_name) + _dispatch_tool_result_hook( + tool_msg.tool_name, + tool_id, + tool_msg.args, + tool_status, + hook_output, + ) + # Update the widget last, guarded: a set_success/ + # set_error failure must not abort the turn and drop + # the remaining tools' hooks. + try: + if tool_status == "success": + # One call so the caveat text and the flag + # that keeps this row out of a group summary + # cannot be set apart — see + # `set_success_with_caveat`. + caveat_shown = tool_msg.set_success_with_caveat( + caveat, output_str + ) + else: + tool_msg.set_error(output_str or "Error") + adapter._sync_tool_widget(tool_msg) + except Exception: + logger.exception( + "Failed to update tool row for %s", tool_id + ) + elif tool_id and tool_id in completed_tool_result_ids: + # This is a middleware synthetic ToolMessage for a + # tool whose terminal hooks already fired while the + # turn was resolving interrupts. Its widget was + # cleared, so it lands here — consume the id and skip + # re-dispatch to avoid a duplicate tool.result (with + # mismatched `{}` args). + if deferred_hook is not None: + # Contradictory: a deferred row is kept out of the + # sweeps that populate `completed_tool_result_ids`, + # so its terminal hook cannot already have fired. + # Skipping is still right (a second dispatch would + # duplicate), but the invariant broke — say so + # rather than dropping the popped hook in silence. + logger.error( + "ask_user tool_id %s had both a deferred hook " + "and an already-dispatched terminal result; " + "skipping re-dispatch", + tool_id, + ) + completed_tool_result_ids.discard(tool_id) + elif tool_id and deferred_hook is not None: + # No widget: the row never mounted (a torn-down DOM), + # so `tool_msg.args` is unavailable and the generic + # `else` below would report `{}` args plus the raw + # transcript. Use the interrupt's own args and the + # sanitized output instead. + if tool_status == "error": + _dispatch_tool_error_hook(tool_name) + _dispatch_tool_result_hook( + tool_name, + tool_id, + deferred_hook.tool_args, + tool_status, + hook_output, + ) + else: + # The tool call was never mounted — either it has no + # tool_call_id, or its streamed args never parsed so + # no tool.use fired and no widget exists. Still emit + # tool.result (with {} args, since without a widget + # we lack the parsed args) so audit hooks observe + # every executed tool, matching the headless path. + # tool_id may be None here, mirroring headless. + # Reciprocal: headless always dispatches tool.result + # from `_process_message_chunk` since it has no + # widget concept; see `non_interactive.py`. The + # parity contract is documented in `_tool_stream`. + if tool_id: + # Warning, not info/debug: a real-id result with + # no mounted widget (its args never parsed, so no + # tool.use fired) means a hook consumer sees a + # `tool.result` with empty args for a tool that + # actually executed — degraded audit fidelity worth + # surfacing at default log levels, matching the + # headless path. + logger.warning( + "ToolMessage tool_call_id=%s not in " + "_current_tool_messages; no correlated " + "tool.use, sending empty tool_args", + tool_id, + ) + if tool_status == "error": + _dispatch_tool_error_hook(tool_name) + _dispatch_tool_result_hook( + tool_name, tool_id, {}, tool_status, output_str + ) + + # Show file operation results - always show diffs in + # chat. + if record: + pending_text = pending_text_by_namespace.get(ns_key, "") + if pending_text: + await _flush_assistant_text_ns( + adapter, + pending_text, + ns_key, + assistant_message_by_namespace, + ) + pending_text_by_namespace[ns_key] = "" + # Hiding the row makes the diff the sole record of + # the edit, so only a diff that can stand in for it + # earns that — `shown` is the only outcome that + # qualifies, for the reasons in `DiffOutcome`. An + # empty body never qualifies either: with nothing to + # show, nothing needs hiding, and a widget asserting + # "no changes" would leave any inaccuracy in the + # read-back as the only surviving account. + replaces_row = ( + ToolCallMessage.can_be_superseded(record.tool_name) + and record.status == "success" + and record.diff_outcome == "shown" + and bool(record.diff) + ) + if record.diff: + # Guarded for the same reason as the row update + # above: mounting and highlighting a diff is + # cosmetic, and a failure here must not abort the + # turn and drop the remaining tools' hooks. + try: + diff_msg = DiffMessage( + record.diff, + record.display_path, + tool_name=record.tool_name, + before=record.before_content or "", + after=record.after_content or "", + stats=record.diff_stats, + outcome=record.diff_outcome, + # Skip the caveat only when the row + # already displays the identical + # sentence and cannot be folded away. + # For `edit_file` both are guaranteed + # on screen — it is excluded from + # grouping, and a non-`shown` outcome + # blocks supersession — so without this + # the same sentence renders twice, + # adjacent. + show_caveat=not caveat_shown, + show_numbers=adapter._show_diff_line_numbers, + ) + mounted = await adapter._mount_message(diff_msg) + # Read from the widget rather than assuming + # a non-`shown` outcome put the caveat on + # screen: it also suppresses its own caveat + # when told the row has it. Conjoined with + # the mount result because `renders_caveat` + # describes how the widget was built, not + # where it ended up — a transcript torn down + # mid-stream makes the mount a silent no-op, + # and crediting it here would skip the + # fallback below and leave the caveat on no + # surface at all. + caveat_shown = caveat_shown or ( + mounted and diff_msg.renders_caveat + ) + except Exception: + logger.exception( + "Failed to mount diff for %s", + record.display_path, + ) + # The diff was expected and never appeared. + # Say so on screen — a silently absent diff + # reads as "nothing changed", and under + # `shown` there is no caveat to fall back + # on. + await _mount_diff_note( + adapter, + f"The diff for {record.display_path} " + "could not be rendered.", + ) + else: + # Hiding the row is a separate step with its + # own failure: the diff is already on screen, + # so reporting "could not be rendered" here + # would contradict what the user can see. + # Only the row stayed visible, which is the + # safe direction and needs no transcript + # note. + if tool_msg is not None and replaces_row: + try: + tool_msg.mark_superseded_by_diff() + adapter._sync_tool_widget(tool_msg) + except Exception: + logger.exception( + "Failed to hide superseded row for %s", + record.display_path, + ) + if caveat and not caveat_shown: + # No row took the caveat (its widget was torn + # down) and no diff mounted to carry it — a + # `delete` with a lost pre-image is the live + # case. Put it in the transcript, which is the + # surface the caveat was written for; a log line + # alone leaves a destructive change looking + # routine to anyone not watching the Debug + # Console. + logger.warning( + "No surface carried the display caveat for %s: %s", + record.display_path, + caveat, + ) + await _mount_diff_note(adapter, caveat) + + # Reshow spinner only when all in-flight tools have + # completed (avoids premature "Thinking..." when + # parallel tool calls are active). Must happen after + # the diff is mounted so the spinner stays at the + # bottom of the messages container. + if adapter._set_spinner and not adapter._current_tool_messages: + await adapter._set_spinner("Thinking") + + if adapter._on_tool_complete is not None: + try: + adapter._on_tool_complete() + except Exception: + # A footer refresh failure must never abort + # agent streaming — log and keep going. + logger.warning( + "on_tool_complete callback failed", + exc_info=True, + ) + continue + + # Check if this is an AIMessageChunk with content + if not hasattr(message, "content_blocks"): + logger.debug( + "Message has no content_blocks: type=%s", + type(message).__name__, + ) + continue + + # Process content blocks + blocks = message.content_blocks + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "content_blocks count=%d blocks=%s", + len(blocks), + repr(blocks)[:500], + ) + for block in blocks: + block_type = block.get("type") + + if block_type == "text": + text = block.get("text", "") + if text: + # Track accumulated text for reference + pending_text = pending_text_by_namespace.get(ns_key, "") + pending_text += text + pending_text_by_namespace[ns_key] = pending_text + + # Get or create assistant message for this namespace + current_msg = assistant_message_by_namespace.get(ns_key) + if current_msg is None: + msg_id = f"asst-{uuid.uuid4().hex}" + # Mark active BEFORE mounting so pruning + # (triggered by mount) won't remove it + # (_mount_message can trigger + # _prune_old_messages if the window exceeds + # WINDOW_SIZE.) + if adapter._set_active_message: + adapter._set_active_message(msg_id) + current_msg = AssistantMessage(id=msg_id) + await adapter._mount_message(current_msg) + assistant_message_by_namespace[ns_key] = current_msg + # Keep the Thinking spinner visible after + # the streaming message so the user still + # sees activity if the model pauses between + # finishing text and emitting its next + # action (e.g. a tool call). The mount + # above placed the new message at the end + # of the container; this re-anchors the + # spinner after it. + if ( + adapter._set_spinner + and not adapter._current_tool_messages + ): + await adapter._set_spinner("Thinking") + + # Append just the new text chunk for smoother + # streaming (uses MarkdownStream internally for + # better performance) + await current_msg.append_content(text) + _notify_user_visible_output_started() + + elif block_type in {"tool_call_chunk", "tool_call"}: + chunk_name = block.get("name") + chunk_args = block.get("args") + chunk_id = block.get("id") + chunk_index = block.get("index") + + buffer_key = tool_call_buffer_key( + chunk_index, chunk_id, len(tool_call_buffers) + ) + buffer = tool_call_buffers.setdefault( + buffer_key, ToolCallBuffer() + ) + buffer.ingest( + name=chunk_name, tool_id=chunk_id, args=chunk_args + ) + + buffer_name = buffer.name + buffer_id = buffer.tool_id + if buffer_name is None: + continue + + # `parse_args` reassembles streamed JSON string + # fragments, deferring the parse until the value + # looks complete — which avoids re-parsing the whole + # prefix on every fragment (costly on the UI event + # loop for large `edit_file` blobs) — and returns + # None while still incomplete. Each `continue` leaves + # the buffer in `tool_call_buffers` so the next + # fragment keeps accumulating; it is popped only after + # a successful parse + mount below. + parsed_args = buffer.parse_args() + if parsed_args is None: + continue + + # Flush pending text before tool call + pending_text = pending_text_by_namespace.get(ns_key, "") + if pending_text: + await _flush_assistant_text_ns( + adapter, + pending_text, + ns_key, + assistant_message_by_namespace, + ) + pending_text_by_namespace[ns_key] = "" + assistant_message_by_namespace.pop(ns_key, None) + + logger.debug( + "Tool call buffer: name=%s id=%s args=%s", + buffer_name, + buffer_id, + repr(parsed_args)[:200], + ) + if ( + buffer_id is not None + and buffer_id not in displayed_tool_ids + ): + displayed_tool_ids.add(buffer_id) + file_op_tracker.start_operation( + buffer_name, parsed_args, buffer_id + ) + + # Keep the global "Thinking" spinner visible + # across tool calls rather than hiding it per + # tool: it's a stable turn-level indicator, and + # the tool's own progress now shows in its + # collapsed group row. Re-assert it so it stays + # pinned at the bottom as the new row mounts + # above it. + if adapter._set_spinner: + await adapter._set_spinner("Thinking") + + # Mount tool call message + logger.debug( + "Mounting ToolCallMessage: %s(%s)", + buffer_name, + repr(parsed_args)[:200], + ) + # Dispatch tool.use once the streamed call has a + # resolved id and parsed args. The headless + # surface dispatches from the stream loop + # instead; see the "Gate tool.use" comment in + # `non_interactive._process_ai_message`. Both + # gate on a resolved tool-call id and fire at + # most once per id — the parity contract is + # documented in `_tool_stream`. + _dispatch_tool_use_hook( + buffer_name, buffer_id, parsed_args + ) + tool_msg = ToolCallMessage(buffer_name, parsed_args) + try: + await adapter._mount_message(tool_msg) + except Exception: + # tool.use already fired. If the mount raises + # (e.g. mounting into a torn-down DOM during + # shutdown), still track the pending call so + # the later real ToolMessage remains + # authoritative for tool.result status/output. + # If the stream ends first, the terminal + # drains close this tool.use from the same + # pending map. + logger.exception( + "Failed to mount tool widget for %s", + buffer_id, + ) + else: + _notify_user_visible_output_started() + # Mark running so the group row reflects live + # progress; the row itself is hidden inside + # the group, so this drives state, not a + # visible per-tool spinner. + tool_msg.set_running() + adapter._sync_tool_widget(tool_msg) + adapter._current_tool_messages[buffer_id] = tool_msg + + if buffer_id is not None: + tool_call_buffers.pop(buffer_key, None) + + if getattr(message, "chunk_position", None) == "last": + pending_text = pending_text_by_namespace.get(ns_key, "") + if pending_text: + await _flush_assistant_text_ns( + adapter, + pending_text, + ns_key, + assistant_message_by_namespace, + ) + pending_text_by_namespace[ns_key] = "" + assistant_message_by_namespace.pop(ns_key, None) + + # Reset summarization state if stream ended mid-summarization + # (e.g. middleware error, stream exhausted before regular chunks). + if summarization_in_progress: + summarization_in_progress = False + await _after_automatic_compact() + try: + await adapter._mount_message(SummarizationMessage()) + except Exception: + logger.debug( + "Failed to mount summarization notification", + exc_info=True, + ) + if adapter._set_spinner and not adapter._current_tool_messages: + await adapter._set_spinner("Thinking") + # Flush any remaining text from all namespaces + for ns_key, pending_text in list(pending_text_by_namespace.items()): + if pending_text: + await _flush_assistant_text_ns( + adapter, pending_text, ns_key, assistant_message_by_namespace + ) + pending_text_by_namespace.clear() + assistant_message_by_namespace.clear() + + # Handle HITL after stream completes + if interrupt_occurred: + any_rejected = False + ask_user_cancelled = False + dismissed_question_count = 0 + resume_payload: dict[str, Any] = dict(pending_hook_resumes) + + # Tools mounted above start their spinner immediately, but a + # tool blocked on HITL approval or `ask_user` input is not + # actually running. A main-agent checkpoint blocks its complete + # parallel batch, including ungated siblings absent from the + # action requests. Nested interrupts remain scoped so unrelated + # outer or sibling `task` rows keep running. The approve branches + # below call `set_running` on the same rows to resume them. + # Crucially, an unrelated in-flight row — a still-running outer + # `task`, or a sibling subagent's `task` whose child did not + # interrupt — is left running so its elapsed timer stays + # monotonic across the nested checkpoint. Guard each row + # individually so a single bad widget can't abort the whole + # interrupt handler (mirrors `clear_awaiting_approval` below). + paused_tool_msgs: list[ToolCallMessage] = [] + paused_ids: set[int] = set() + for namespace, hitl_request in pending_interrupts.values(): + for tool_msg in _interrupt_tool_rows( + namespace, + hitl_request["action_requests"], + adapter._current_tool_messages, + ): + if id(tool_msg) not in paused_ids: + paused_ids.add(id(tool_msg)) + paused_tool_msgs.append(tool_msg) + for ask_req in pending_ask_user.values(): + ask_tool_msg = adapter._current_tool_messages.get( + ask_req["tool_call_id"] + ) + if ask_tool_msg is not None and id(ask_tool_msg) not in paused_ids: + paused_ids.add(id(ask_tool_msg)) + paused_tool_msgs.append(ask_tool_msg) + for tool_msg in paused_tool_msgs: + try: + tool_msg.pause_running() + adapter._sync_tool_widget(tool_msg) + except Exception: + logger.exception( + "Failed to pause running state on tool widget %s", + tool_msg.tool_name, + ) + + for interrupt_id, ask_req in list(pending_ask_user.items()): + questions = ask_req["questions"] + tool_args = {"questions": questions} + + if adapter._request_ask_user: + from deepagents_code.hooks.models.domain import ( + DcodeNotificationKind, + ) + + await hooks.notify( + DcodeNotificationKind.AGENT_NEEDS_INPUT, + "Agent needs input", + ) + if adapter._set_spinner: + await adapter._set_spinner(None) + result: AskUserWidgetResult | dict[str, str] = { + "type": "error", + "error": "ask_user callback returned no response", + } + try: + future = await adapter._request_ask_user(questions) + except Exception: + logger.exception("Failed to mount ask_user widget") + result = { + "type": "error", + "error": "failed to display ask_user prompt", + } + future = None + + if future is None: + logger.error( + "ask_user callback returned no Future; " + "reporting as error" + ) + else: + try: + future_result = await future + if isinstance(future_result, dict): + result = future_result + else: + logger.error( + "ask_user future returned non-dict result: %s", + type(future_result).__name__, + ) + result = { + "type": "error", + "error": "invalid ask_user widget result", + } + except Exception: + logger.exception( + "ask_user future resolution failed; " + "reporting as error" + ) + result = { + "type": "error", + "error": "failed to receive ask_user response", + } + + result_type = result.get("type") + tool_id = ask_req["tool_call_id"] + if result_type == "answered": + answers = result.get("answers", []) + if isinstance(answers, list): + resume_payload[interrupt_id] = {"answers": answers} + # Keep the row alive until the middleware emits + # the ToolMessage that is persisted and sent to + # the model. It owns validation and final status; + # only the hook body is replaced to keep answers + # out of hook scripts. + deferred_tool_result_hooks[tool_id] = ( + DeferredToolResultHook( + tool_args=tool_args, + tool_output=ASK_USER_ANSWERED_SUMMARY, + ) + ) + ask_row = adapter._current_tool_messages.get(tool_id) + if ask_row is not None: + # Record the outcome on the row too, so the + # teardown sweeps — which treat any tracked + # row as a failure, and which this deferral + # newly exposes it to — settle it as the + # success it earned. Only the constant + # summary, never the answers. + ask_row.defer_success(ASK_USER_ANSWERED_SUMMARY) + else: + logger.warning( + "ask_user tool_id %s missing from " + "_current_tool_messages on answered", + tool_id, + ) + else: + output = "invalid ask_user answers payload" + logger.error( + "ask_user answered payload had non-list " + "answers: %s", + type(answers).__name__, + ) + resume_payload[interrupt_id] = { + "status": "error", + "error": output, + "answers": ["" for _ in questions], + } + any_rejected = True + tool_msg = adapter._current_tool_messages.pop( + tool_id, None + ) + _dispatch_tool_error_hook("ask_user") + _dispatch_tool_result_hook( + "ask_user", tool_id, tool_args, "error", output + ) + completed_tool_result_ids.add(tool_id) + if tool_msg is not None: + try: + tool_msg.set_error(output) + adapter._sync_tool_widget(tool_msg) + except Exception: + logger.exception( + "Failed to update ask_user row for %s", + tool_id, + ) + elif result_type == "cancelled": + resume_payload[interrupt_id] = { + "status": "cancelled", + "answers": ["" for _ in questions], + } + any_rejected = True + # Halt the turn on cancel; error branches still + # resume so the agent can react to the failure. + ask_user_cancelled = True + # Counts questions, not calls, purely so the banner + # below can pick a singular or plural subject — the + # halt reads the flag above, never this. A widget + # dismisses its whole prompt, so every question in a + # cancelled call went with it. + dismissed_question_count += len(questions) + tool_msg = adapter._current_tool_messages.pop(tool_id, None) + output = ASK_USER_CANCELLED_SUMMARY + _dispatch_tool_error_hook("ask_user") + _dispatch_tool_result_hook( + "ask_user", tool_id, tool_args, "error", output + ) + completed_tool_result_ids.add(tool_id) + if tool_msg is not None: + try: + tool_msg.set_rejected() + adapter._sync_tool_widget(tool_msg) + except Exception: + logger.exception( + "Failed to update ask_user row for %s", + tool_id, + ) + else: + logger.warning( + "ask_user tool_id %s missing from " + "_current_tool_messages on cancelled", + tool_id, + ) + else: + error_text = result.get("error") + if not isinstance(error_text, str) or not error_text: + error_text = "ask_user interaction failed" + resume_payload[interrupt_id] = { + "status": "error", + "error": error_text, + "answers": ["" for _ in questions], + } + any_rejected = True + tool_msg = adapter._current_tool_messages.pop(tool_id, None) + _dispatch_tool_error_hook("ask_user") + _dispatch_tool_result_hook( + "ask_user", tool_id, tool_args, "error", error_text + ) + completed_tool_result_ids.add(tool_id) + if tool_msg is not None: + try: + tool_msg.set_error(error_text) + adapter._sync_tool_widget(tool_msg) + except Exception: + logger.exception( + "Failed to update ask_user row for %s", + tool_id, + ) + else: + logger.warning( + "ask_user interrupt received but no UI callback is " + "registered; reporting as error" + ) + resume_payload[interrupt_id] = { + "status": "error", + "error": _ASK_USER_UNSUPPORTED_ERROR, + "answers": ["" for _ in questions], + } + tool_id = ask_req["tool_call_id"] + tool_msg = adapter._current_tool_messages.pop(tool_id, None) + _dispatch_tool_error_hook("ask_user") + _dispatch_tool_result_hook( + "ask_user", + tool_id, + tool_args, + "error", + _ASK_USER_UNSUPPORTED_ERROR, + ) + completed_tool_result_ids.add(tool_id) + if tool_msg is not None: + try: + tool_msg.set_error(_ASK_USER_UNSUPPORTED_ERROR) + adapter._sync_tool_widget(tool_msg) + except Exception: + logger.exception( + "Failed to update ask_user row for %s", tool_id + ) + + for interrupt_id, (namespace, hitl_request) in list( + pending_interrupts.items() + ): + action_requests = hitl_request["action_requests"] + + if session_state.approval_mode is ApprovalMode.YOLO and ( + not hooks.has_handlers(HookEvent.PERMISSION_REQUEST) + ): + decisions: list[HITLDecision] = [ + ApproveDecision(type="approve") for _ in action_requests + ] + resume_payload[interrupt_id] = {"decisions": decisions} + for tool_msg in _interrupt_tool_rows( + namespace, + action_requests, + adapter._current_tool_messages, + ): + _set_running_unless_deferred(tool_msg) + adapter._sync_tool_widget(tool_msg) + else: + all_action_requests = action_requests + plan = await hooks.on_permission_request( + _permission_tool_calls( + interrupt_id, + all_action_requests, + adapter._current_tool_messages, + ) + ) + if plan.interrupted: + decisions = merge_permission_decisions( + plan.as_interrupted(), + [], + ) + for tool_msg in _interrupt_tool_rows( + namespace, + all_action_requests, + adapter._current_tool_messages, + ): + tool_msg.set_rejected(reason="Permission interrupted") + adapter._sync_tool_widget(tool_msg) + resume_payload[interrupt_id] = {"decisions": decisions} + any_rejected = True + break + + action_requests = [ + all_action_requests[index] + for index in plan.unresolved_indices + ] + resolved_row_ids: set[int] = set() + for request, outcome in zip( + all_action_requests, + plan.outcomes, + strict=True, + ): + hook_decision = outcome.decision + if hook_decision is None: + continue + rows = _interrupt_owned_tool_rows( + [request], + adapter._current_tool_messages, + ) + for tool_msg in rows: + resolved_row_ids.add(id(tool_msg)) + if hook_decision["type"] == "approve": + _set_running_unless_deferred(tool_msg) + tool_name = request.get("name") + args = request.get("args") + if tool_name in { + "write_file", + "edit_file", + "delete", + } and isinstance(args, dict): + file_op_tracker.mark_hitl_approved( + tool_name, + args, + ) + else: + tool_msg.set_rejected( + reason=hook_decision.get("message") + ) + adapter._sync_tool_widget(tool_msg) + + if plan.fully_resolved: + decisions = merge_permission_decisions(plan, []) + for tool_msg in adapter._current_tool_messages.values(): + if id(tool_msg) not in resolved_row_ids: + _set_running_unless_deferred(tool_msg) + adapter._sync_tool_widget(tool_msg) + resume_payload[interrupt_id] = {"decisions": decisions} + continue + + if session_state.approval_mode is ApprovalMode.YOLO: + reviewed = [ + ApproveDecision(type="approve") for _ in action_requests + ] + decisions = merge_permission_decisions(plan, reviewed) + resume_payload[interrupt_id] = {"decisions": decisions} + for tool_msg in _interrupt_tool_rows( + namespace, + action_requests, + adapter._current_tool_messages, + ): + if id(tool_msg) in resolved_row_ids: + continue + _set_running_unless_deferred(tool_msg) + adapter._sync_tool_widget(tool_msg) + continue + + review_namespace = ( + namespace + if len(action_requests) == len(all_action_requests) + else ("permission_hook",) + ) + from deepagents_code.hooks.models.domain import ( + DcodeNotificationKind, + ) + + await hooks.notify( + DcodeNotificationKind.PERMISSION_REQUIRED, + "Permission required", + ) + # Batch approval - one dialog for all parallel tool calls + await dispatch_hook( + "permission.request", + { + "tool_names": [ + r.get("name", "") for r in action_requests + ] + }, + ) + # Hide shell tool widgets while the approval renders + # the same command; restore before processing the + # decision so subsequent status updates render on the + # visible widget. Only applies to single-tool + # approvals — the batch dialog doesn't render + # per-tool commands, so hiding the rows would leave + # the user with no preview of what's being approved. + suppressed_tool_msgs = ( + [ + tool_msg + for tool_msg in _interrupt_owned_tool_rows( + action_requests, adapter._current_tool_messages + ) + if tool_msg.tool_name == "execute" + ] + if len(action_requests) == 1 + else [] + ) + for tool_msg in suppressed_tool_msgs: + tool_msg.set_awaiting_approval() + try: + while True: + future = await adapter._request_approval( + action_requests, assistant_id + ) + decision = await future + if ( + isinstance(decision, dict) + and decision.get("type") == "auto_approve_all" + and adapter._on_auto_approve_enabled is not None + ): + callback_result = adapter._on_auto_approve_enabled() + enabled = ( + await callback_result + if inspect.isawaitable(callback_result) + else callback_result + ) + if enabled is None: + enabled = True + if enabled is False: + continue + break + finally: + for tool_msg in suppressed_tool_msgs: + try: + tool_msg.clear_awaiting_approval() + except Exception: + logger.exception( + "Failed to clear awaiting-approval " + "state on tool widget %s", + tool_msg.tool_name, + ) + + if isinstance(decision, dict): + decision_type = decision.get("type") + + if decision_type == "auto_approve_all": + decisions = [ + ApproveDecision(type="approve") + for _ in action_requests + ] + tool_msgs = _interrupt_tool_rows( + review_namespace, + action_requests, + adapter._current_tool_messages, + ) + for tool_msg in tool_msgs: + _set_running_unless_deferred(tool_msg) + adapter._sync_tool_widget(tool_msg) + for action_request in action_requests: + tool_name = action_request.get("name") + if tool_name in { + "write_file", + "edit_file", + "delete", + }: + args = action_request.get("args", {}) + if isinstance(args, dict): + file_op_tracker.mark_hitl_approved( + tool_name, args + ) + + elif decision_type == "switch_manual": + if adapter._on_switch_to_manual is None: + msg = "Manual mode callback is unavailable" + raise RuntimeError(msg) + callback_result = adapter._on_switch_to_manual() + switched = ( + await callback_result + if inspect.isawaitable(callback_result) + else callback_result + ) + if not switched: + msg = "Manual mode could not be persisted" + raise RuntimeError(msg) + decisions = [ + cast("HITLDecision", {"type": "switch_manual"}) + for _ in action_requests + ] + + elif decision_type == "approve": + decisions = [ + ApproveDecision(type="approve") + for _ in action_requests + ] + tool_msgs = _interrupt_tool_rows( + review_namespace, + action_requests, + adapter._current_tool_messages, + ) + for tool_msg in tool_msgs: + _set_running_unless_deferred(tool_msg) + adapter._sync_tool_widget(tool_msg) + for action_request in action_requests: + tool_name = action_request.get("name") + if tool_name in { + "write_file", + "edit_file", + "delete", + }: + args = action_request.get("args", {}) + if isinstance(args, dict): + file_op_tracker.mark_hitl_approved( + tool_name, args + ) + + elif decision_type == "reject": + reject_message = decision.get("message") + reject_message = ( + reject_message + if isinstance(reject_message, str) + and reject_message.strip() + else None + ) + reject_decision: RejectDecision = ( + RejectDecision( + type="reject", + message=_frame_reject_reason(reject_message), + ) + if reject_message + else RejectDecision(type="reject") + ) + decisions = [reject_decision for _ in action_requests] + # Bare reject aborts an ordinary conversation + # turn and shows the canned "Command rejected" + # banner. Server operations must receive every + # decision so their nested agent can finish + # without the rejected context. A supplied + # reason likewise resumes either kind of run. + if reject_message is None and graph_input is None: + # The whole turn aborts. + completed_tool_result_ids.update( + _reject_tracked_rows( + adapter, reason=reject_message + ) + ) + any_rejected = True + else: + # The run resumes, so only reviewed calls are + # terminally rejected. A main-agent checkpoint + # also paused ungated siblings in the parallel + # batch; resume those because they can still run + # after the rejected calls are replaced with + # synthetic ToolMessages. + tracked_tool_msgs = adapter._current_tool_messages + rejected_tool_msgs = _interrupt_owned_tool_rows( + action_requests, + tracked_tool_msgs, + ) + rejected_ids = { + id(tool_msg) for tool_msg in rejected_tool_msgs + } + for tool_msg in rejected_tool_msgs: + tool_msg.set_rejected(reason=reject_message) + adapter._sync_tool_widget(tool_msg) + if not namespace: + for tool_msg in tracked_tool_msgs.values(): + if id(tool_msg) in rejected_ids: + continue + _set_running_unless_deferred(tool_msg) + adapter._sync_tool_widget(tool_msg) + else: + logger.warning( + "Unexpected HITL decision type: %s", + decision_type, + ) + decisions = [ + RejectDecision(type="reject") + for _ in action_requests + ] + completed_tool_result_ids.update( + _reject_tracked_rows(adapter) + ) + any_rejected = True + else: + logger.warning( + "HITL decision was not a dict: %s", + type(decision).__name__, + ) + decisions = [ + RejectDecision(type="reject") for _ in action_requests + ] + completed_tool_result_ids.update( + _reject_tracked_rows(adapter) + ) + any_rejected = True + + decisions = merge_permission_decisions(plan, decisions) + resume_payload[interrupt_id] = {"decisions": decisions} + + if any_rejected: + break + + suppress_resumed_output = any_rejected + + if interrupt_occurred and resume_payload: + if suppress_resumed_output and ( + ask_user_cancelled or not pending_ask_user + ): + # An answered `ask_user` can still be tracked here when a + # *separate* `ask_user` call in the same batch was cancelled + # (one widget cancels its whole prompt, never one question of + # it, so this needs two parallel `ask_user` tool calls — which + # `ASK_USER_SYSTEM_PROMPT` discourages but nothing forbids). This + # `return` happens *before* `Command(resume=resume_payload)` + # below, so those answers are discarded: they never reach the + # graph, and the inline widget is already unmounted, making them + # unrecoverable. Settle each row as a delivery failure rather + # than letting the `finally` backstop record the ordinary + # answered success — `ask_user` results double as authorization + # records, and this authorization never took effect. + undelivered = _pop_rows_awaiting_deferred_result( + adapter._current_tool_messages + ) + for tool_id, tool_msg in undelivered.items(): + _dispatch_tool_error_hook(tool_msg.tool_name) + _dispatch_tool_result_hook( + tool_msg.tool_name, + tool_id, + tool_msg.args, + "error", + ASK_USER_ANSWERED_NOT_DELIVERED_SUMMARY, + ) + completed_tool_result_ids.add(tool_id) + try: + # Clear first: `set_error` would otherwise redirect + # back to the deferred success. + tool_msg.clear_deferred_success() + tool_msg.set_error(ASK_USER_ANSWERED_NOT_DELIVERED_SUMMARY) + adapter._sync_tool_widget(tool_msg) + except Exception: + logger.exception( + "Failed to settle undelivered ask_user row %s", + tool_id, + ) + + dismissed_subject = ( + "Questions" if dismissed_question_count > 1 else "Question" + ) + message = ( + f"{dismissed_subject} dismissed. Tell the agent what you'd " + "like instead." + if ask_user_cancelled + else "Command rejected. Tell the agent what you'd like instead." + ) + if undelivered: + # The user typed answers and they are now gone; saying so + # is the only way they learn not to wait for a response. + # Which event destroyed them differs: a dismissal in this + # batch, or — when `pending_ask_user` is empty because it + # resets each stream iteration — a rejection in a later + # iteration discarding an earlier one's answered row. + cause = ( + f"{dismissed_subject} dismissed" + if ask_user_cancelled + else "Command rejected" + ) + message = ( + f"{cause}, so answers to the other question(s) in this " + "batch were not sent. Tell the agent what you'd like " + "instead." + ) + await adapter._mount_message(AppMessage(message)) + turn_stats.wall_time_seconds = time.monotonic() - start_time + # Model call already completed (HITL interrupt fires after + # the model node); `ResumeStateMiddleware.after_model` + # persisted the count, so only refresh UI here. + _report_tokens( + adapter, + captured_input_tokens, + captured_output_tokens, + ) + return turn_stats + + stream_input = Command(resume=resume_payload) + else: + # Clean stream end. Any tool still in `_current_tool_messages` + # had its `tool.use` dispatched at mount but never received a + # `ToolMessage` (e.g. a custom/remote graph that ends the turn + # after emitting an unexecuted tool call). Close each one with a + # terminal hook so the "every `tool.use` is terminated" guarantee + # does not depend on the graph raising. This mirrors the headless + # `_dispatch_orphaned_tool_result_hooks`, which likewise closes + # orphans hooks-only (no widget mutation) on every loop exit — + # the widget keeps its rendered state; only the audit stream and + # the cross-turn `_current_tool_messages` tracking are settled. + if adapter._current_tool_messages: + logger.info( + "Stream ended with %d un-resulted tool call(s); " + "closing with terminal hooks", + len(adapter._current_tool_messages), + ) + _dispatch_terminal_tool_result_hooks( + adapter._current_tool_messages, + "Stream ended before tool result", + ) + # Hooks-only above, per the contract in the comment: a row + # keeps whatever it rendered. A deferred row rendered + # *nothing* terminal though — an answered `ask_user` is still + # showing its paused-pending look — so settle those, or the + # row stays pending for the rest of the session, showing + # neither the answers nor a failure. + for tool_id, tool_msg in list( + adapter._current_tool_messages.items() + ): + try: + if tool_msg.settle_deferred_success(): + adapter._sync_tool_widget(tool_msg) + except Exception: + logger.exception( + "Failed to settle deferred %s row %s at stream end", + tool_msg.tool_name, + tool_id, + ) + # `clear()` below drops this row for good, so nothing + # will retry: without a fallback it stays frozen on its + # paused-pending look for the rest of the session. + try: + tool_msg.clear_deferred_success() + tool_msg.set_error("Stream ended before tool result") + adapter._sync_tool_widget(tool_msg) + except Exception: + logger.exception( + "Fallback terminal render also failed for %s " + "row %s; surfacing to the user", + tool_msg.tool_name, + tool_id, + ) + # A permanently stuck row is user-visible damage; + # a file-only log would leave them waiting on a + # spinner that never resolves. + await adapter._mount_message( + AppMessage( + f"A {tool_msg.tool_name} row could not be " + "updated and may stay stuck; its result was " + "still recorded." + ) + ) + adapter._current_tool_messages.clear() + # The end-of-stream diagnostic for buffered tool calls that never + # fired a `tool.use` runs in the `finally` below, not here, so it + # fires on cancel and mid-stream error too (not only this clean + # end) — mirroring the headless surface, whose identical + # diagnostic lives in `_run_agent_loop`'s `finally`. + from deepagents_code.hooks.models.domain import ( + DcodeNotificationKind, + ) + + try: + await hooks.notify( + DcodeNotificationKind.AGENT_COMPLETED, + "Agent completed", + ) + except ClientHookStopError as exc: + await adapter._mount_message( + AppMessage(f"Operation stopped by hook: {exc}") + ) + if not hooks.has_handlers(HookEvent.NOTIFICATION): + await dispatch_hook("task.complete", {"thread_id": thread_id}) + break + + except ClientHookStopError: + _reject_tracked_rows(adapter) + raise + except (asyncio.CancelledError, KeyboardInterrupt): + await _handle_interrupt_cleanup( + adapter=adapter, + agent=agent, + config=config, + pending_text_by_namespace=pending_text_by_namespace, + assistant_message_by_namespace=assistant_message_by_namespace, + captured_input_tokens=captured_input_tokens, + captured_output_tokens=captured_output_tokens, + turn_stats=turn_stats, + start_time=start_time, + recover_interrupted_turn=recover_interrupted_turn, + ) + return turn_stats + finally: + # Streamed text is coalesced in each AssistantMessage's `_pending_append` + # buffer and flushed on a throttled timer, so up to one flush interval of + # tokens can be in flight at any moment. Normal completion (the flush loop + # above) and interrupt cleanup both clear the namespace dict, leaving this + # a no-op there. The path that matters is a non-cancel mid-stream error + # propagating to the caller: without this drain those buffered tokens are + # never written and the user sees a silently truncated reply. + try: + await _stop_assistant_streams(adapter, assistant_message_by_namespace) + except Exception: # drain must not mask the original error + logger.exception("Failed to drain assistant streams on exit") + + # Self-contained backstop for the "every `tool.use` is terminated" hook + # guarantee. The clean-end branch, HITL-reject branches, and interrupt + # cleanup each already drained `_current_tool_messages` and cleared it, so + # this is a no-op on those paths. The one path it covers is a non-cancel + # mid-stream error propagating to the caller: without it, the tools that + # fired `tool.use` would be terminated only by the caller's + # `finalize_pending_tools_with_error`, leaving the hook guarantee dependent + # on the caller rather than owned here (a future second caller, or a + # missing adapter, would leak an unterminated `tool.use`). Runs before the + # exception reaches the caller, whose `finalize_pending_tools_with_error` + # then finds an empty dict and no-ops, so no `tool.result` is dispatched + # twice. Fail-loud and guarded so a dispatch problem can never mask the + # error propagating from the stream. + if adapter._current_tool_messages: + logger.warning( + "Turn exited with %d un-terminated tool call(s); closing with " + "terminal hooks as a backstop", + len(adapter._current_tool_messages), + ) + try: + adapter.finalize_pending_tools_with_error( + "Agent error before tool result" + ) + except Exception: + logger.warning( + "Backstop terminal tool close failed unexpectedly", + exc_info=True, + ) + + # Surface any buffered tool call that never mounted and never fired a + # `tool.use`, so it would otherwise vanish with `tool_call_buffers` at turn + # end with no trace. Two distinct cases (args that never parsed, and args + # that parsed but carried no tool-call id) are classified by the shared + # `count_unemitted_tool_calls`. In the `finally` so it fires on every exit + # path — clean end, cancel, and mid-stream error — matching the headless + # surface. Info, not warning: nothing executed for these and the + # precondition (exiting mid-tool-call) is unusual; it only needs to be + # greppable. Guarded so a logging failure can never mask a propagating + # exception (`parse_args`, re-run inside the count, can raise on the + # invariant-violating both-fields-set buffer). + try: + unemitted = count_unemitted_tool_calls(tool_call_buffers.values()) + if unemitted.unparsed: + logger.info( + "Stream ended with %d tool call(s) whose arguments never " + "parsed; no tool.use was emitted for them", + unemitted.unparsed, + ) + if unemitted.idless_parsed: + logger.info( + "Stream ended with %d tool call(s) whose arguments parsed " + "but carried no tool-call id; no tool.use was emitted for " + "them", + unemitted.idless_parsed, + ) + except Exception: + logger.warning( + "Unparsed tool-call buffer check failed unexpectedly", + exc_info=True, + ) + + # Update token count and return stats. Persistence is handled inside the + # graph by `ResumeStateMiddleware.after_model`, so this only refreshes UI. + turn_stats.wall_time_seconds = time.monotonic() - start_time + _report_tokens( + adapter, + captured_input_tokens, + captured_output_tokens, + ) + if adapter._on_stream_complete: + try: + adapter._on_stream_complete() + except Exception: + logger.warning("on_stream_complete callback failed", exc_info=True) + return turn_stats + + +async def _stop_assistant_streams( + adapter: TextualUIAdapter, + assistant_message_by_namespace: dict[tuple, Any] | None, +) -> None: + """Finalize active assistant streams during interrupt cleanup.""" + if not assistant_message_by_namespace: + return + + for current_msg in list(assistant_message_by_namespace.values()): + try: + await current_msg.stop_stream() + except Exception: + logger.warning("Failed to stop interrupted assistant stream", exc_info=True) + continue + + if adapter._sync_message_content and current_msg.id: + adapter._sync_message_content(current_msg.id, current_msg._content) + + assistant_message_by_namespace.clear() + + +async def _handle_interrupt_cleanup( + *, + adapter: TextualUIAdapter, + agent: Any, # noqa: ANN401 # Dynamic agent graph type + config: RunnableConfig, + pending_text_by_namespace: dict[tuple, str], + assistant_message_by_namespace: dict[tuple, Any] | None = None, + captured_input_tokens: int, + captured_output_tokens: int, + turn_stats: _session_stats.SessionStats, + start_time: float, + recover_interrupted_turn: bool = True, +) -> None: + """Shared cleanup for CancelledError and KeyboardInterrupt. + + Args: + adapter: UI adapter with display callbacks. + agent: The LangGraph agent. + config: Runnable config with `thread_id`. + pending_text_by_namespace: Accumulated text per namespace. + assistant_message_by_namespace: Active assistant message widgets per namespace. + captured_input_tokens: Input tokens captured before interrupt. + captured_output_tokens: Output tokens captured before interrupt. + turn_stats: Stats for the current turn. + start_time: Monotonic timestamp when the turn began. + recover_interrupted_turn: Whether to append the normal partial assistant + and cancellation messages for an interrupted conversation turn. + + Raises: + ValueError: If proactive remote-run cancellation is attempted without a + `thread_id` in `config` (a contract violation rather than a + transient remote failure). + """ + from langchain_core.messages import HumanMessage + + # Clear active message immediately so it won't block pruning. + # If we don't do this, the store still thinks it's active and protects + # from pruning, which breaks get_messages_to_prune(), potentially + # blocking all future pruning. + if adapter._set_active_message: + adapter._set_active_message(None) + + # Hide spinner (may still show "Offloading" if interrupted mid-offload) + if adapter._set_spinner: + await adapter._set_spinner(None) + + await _stop_assistant_streams(adapter, assistant_message_by_namespace) + + if recover_interrupted_turn: + await adapter._mount_message(AppMessage("Interrupted by user")) + + # Proactively cancel server-side runs before persisting recovery state, so + # the aupdate_state writes below don't 409 against a still-busy thread. This + # is defense-in-depth layered on top of aupdate_state's own 409 -> cancel -> + # retry path (see RemoteAgent.aupdate_state); a failure here is not fatal. + # Absent on local agents, so this is a no-op for them. + cancel_active_runs = getattr(agent, "acancel_active_runs", None) + if cancel_active_runs is not None: + try: + await cancel_active_runs(config) + except ValueError: + # A missing thread_id is a contract violation (a bug), not a + # transient remote failure — surface it rather than downgrading it + # to a warning alongside the swallowed network errors below. + raise + except Exception: + # Remote cancel is best-effort defense-in-depth; transient remote + # failures here are recovered by aupdate_state's 409 retry below. + logger.warning( + "Failed to cancel active remote runs for thread %s", + config.get("configurable", {}).get("thread_id"), + exc_info=True, + ) + + interrupted_msg = ( + _build_interrupted_ai_message( + pending_text_by_namespace, + adapter._current_tool_messages, + ) + if recover_interrupted_turn + else None + ) + + # Close out any tool whose `tool.use` fired but whose `ToolMessage` never + # arrived because the turn was cancelled: emit terminal hooks before the + # widgets are dropped, so a cancel path leaves no unterminated `tool.use` + # (mirroring the HITL-reject branches). The turn does not resume from here, + # so the returned ids need not be tracked for dedup. + # + # Dispatched *before* the `aupdate_state` writes below (not alongside the + # `set_rejected` loop after them): those writes await a possibly-slow remote + # checkpointer, and on an interactive quit the graceful-exit drain in + # `app.py` snapshots the in-flight hook tasks right after cancelling this + # worker. Scheduling the fire-and-forget hooks here — synchronously, as soon + # as cancellation is observed — guarantees they are in that snapshot and get + # drained, rather than being scheduled after a slow write and cancelled at + # loop teardown (a silent audit gap). It reads `tool_msg.args`/`tool_name`, + # both available regardless of the widget's rejected state. + # + # Guarded because this now sits *before* the recovery-state write below: the + # dispatch never raises by construction today (pure payload builders, and + # `dispatch_hook_fire_and_forget` swallows serialization inside its task), but + # this function's whole contract is best-effort-must-not-propagate, so a + # future change here must never skip the `aupdate_state` save or escape the + # cancel handler. + try: + _dispatch_terminal_tool_result_hooks( + adapter._current_tool_messages, "Turn cancelled" + ) + except Exception: + logger.warning("Terminal tool.result dispatch failed on cancel", exc_info=True) + + # Save accumulated state before marking tools as rejected (best-effort). + # State update failures shouldn't prevent cleanup. + from langsmith import tracing_context + + try: + # tracing_context(enabled=False) suppresses only the UpdateState traced + # run that each aupdate_state call would otherwise emit in LangSmith — it + # does not affect any other tracing in the surrounding turn. These writes + # are internal interrupt-recovery mechanics (partial AI message + + # cancellation notice), not user-driven agent activity; surfacing them as + # standalone peer runs alongside real agent turns clutters the trace view. + with tracing_context(enabled=False): + if recover_interrupted_turn: + if interrupted_msg: + await agent.aupdate_state(config, {"messages": [interrupted_msg]}) + + cancellation_msg = HumanMessage( + content=f"{SYSTEM_MESSAGE_PREFIX} Task interrupted by user. " + "Previous operation was cancelled." + ) + cancellation_values: dict[str, Any] = {"messages": [cancellation_msg]} + # Piggy-back the latest token count on this already-required + # write instead of issuing a separate `aupdate_state`. + # `after_model` never ran on the partial turn, so without this + # the count would be stale on resume. + captured_total = captured_input_tokens + captured_output_tokens + if captured_total: + cancellation_values["_context_tokens"] = captured_total + await agent.aupdate_state(config, cancellation_values) + except (httpx.TransportError, httpx.TimeoutException) as e: + logger.warning("Could not save interrupted state (network): %s", e) + except Exception as exc: # interrupt cleanup must not propagate + logger.warning("Failed to save interrupted state", exc_info=True) + # Surface via the chat surface — silent file-only warnings have + # masked real state-write failures (validation, checkpointer + # corruption) in past incidents. The mount is best-effort; the + # adapter may already be tearing down. + with contextlib.suppress(Exception): + await adapter._mount_message( + AppMessage( + f"Could not save interrupted state ({type(exc).__name__}). " + "Subsequent turns may see stale state." + ) + ) + + # Mark tools as rejected AFTER saving state. Terminal hooks for these were + # already dispatched before the state writes above (see the comment there). + # Guard each `set_rejected` — it does DOM work that can raise during + # app-exit teardown — so a failure can't skip the `clear()` below. If it + # did, `_current_tool_messages` would stay populated and the caller's + # `finally` backstop would re-dispatch a duplicate terminal hook for every + # id already closed at the top of this function. + for tool_msg in list(adapter._current_tool_messages.values()): + try: + tool_msg.set_rejected() + adapter._sync_tool_widget(tool_msg) + except Exception: + logger.exception( + "Failed to mark tool row rejected during interrupt cleanup" + ) + adapter._current_tool_messages.clear() + + # Keep the token count marked stale whenever interrupted state was captured, + # including tool-only turns after assistant text was already flushed. + approximate = interrupted_msg is not None + + turn_stats.wall_time_seconds = time.monotonic() - start_time + _report_tokens( + adapter, + captured_input_tokens, + captured_output_tokens, + approximate=approximate, + ) + + +def _report_tokens( + adapter: TextualUIAdapter, + captured_input_tokens: int, + captured_output_tokens: int, + *, + approximate: bool = False, +) -> None: + """Refresh the token-count UI display. + + Persistence into graph state is owned by `ResumeStateMiddleware.after_model` + (normal turns), `_handle_offload` (offload turns), and the interrupt-cleanup + `aupdate_state` write (partial turns) — never this helper. + + Args: + adapter: UI adapter with token callbacks. + captured_input_tokens: Total input tokens captured during the turn. + captured_output_tokens: Total output tokens captured during the turn. + approximate: When `True`, signal to the UI that the count is stale + (e.g. after an interrupted generation) by appending "+". + """ + if captured_input_tokens or captured_output_tokens: + if adapter._on_tokens_update: + adapter._on_tokens_update(captured_input_tokens, approximate=approximate) + elif adapter._on_tokens_show: + adapter._on_tokens_show(approximate=approximate) + + +async def _flush_assistant_text_ns( + adapter: TextualUIAdapter, + text: str, + ns_key: tuple, + assistant_message_by_namespace: dict[tuple, Any], +) -> None: + """Flush accumulated assistant text for a specific namespace. + + Finalizes the streaming by stopping the MarkdownStream. + If no message exists yet, creates one with the full content. + """ + if not text.strip(): + return + + current_msg = assistant_message_by_namespace.get(ns_key) + if current_msg is None: + # No message was created during streaming - create one with full content + msg_id = f"asst-{uuid.uuid4().hex}" + current_msg = AssistantMessage(text, id=msg_id) + await adapter._mount_message(current_msg) + await current_msg.write_initial_content() + assistant_message_by_namespace[ns_key] = current_msg + else: + # Stop the stream to finalize the content + await current_msg.stop_stream() + + # When the AssistantMessage was first mounted and recorded in the + # MessageStore, it had empty content (streaming hadn't started yet). + # Now that streaming is done, the widget holds the full text in + # `_content`, but the store's MessageData still has `content=""`. + # If the message is later pruned and re-hydrated, `to_widget()` would + # recreate it from that stale empty string. This call copies the + # widget's final content back into the store so re-hydration works. + if adapter._sync_message_content and current_msg.id: + adapter._sync_message_content(current_msg.id, current_msg._content) + + # Clear active message since streaming is done + if adapter._set_active_message: + adapter._set_active_message(None) diff --git a/libs/code/deepagents_code/tui/widgets/__init__.py b/libs/code/deepagents_code/tui/widgets/__init__.py new file mode 100644 index 0000000000..051902f0b0 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/__init__.py @@ -0,0 +1,9 @@ +"""Textual widgets for `deepagents-code`. + +Import directly from submodules, e.g.: + + ```python + from deepagents_code.tui.widgets.chat_input import ChatInput + from deepagents_code.tui.widgets.messages import AssistantMessage + ``` +""" diff --git a/libs/code/deepagents_code/tui/widgets/_copy_spans.py b/libs/code/deepagents_code/tui/widgets/_copy_spans.py new file mode 100644 index 0000000000..06ef2a55f0 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/_copy_spans.py @@ -0,0 +1,52 @@ +"""Shared click-to-copy span metadata for Textual widgets. + +Widgets that render `label: value` rows (the debug console snapshot, the welcome +banner) mark individual value spans as copyable by embedding the copy text and a +toast label in the span's style meta. Keeping the meta keys and the +build/extract pair here means the two ends of the protocol cannot drift apart. +""" + +from __future__ import annotations + +from textual.style import Style as TStyle + +COPY_TEXT_META = "copy_text" +"""Meta key marking a span whose text is copied on click.""" + +COPY_LABEL_META = "copy_label" +"""Meta key carrying the field label used in the copy toast.""" + + +def copy_span_style(text: str, label: str) -> TStyle: + """Build the style that marks a span as click-to-copy. + + Args: + text: The text copied to the clipboard when the span is clicked. + label: The field label used to word the success toast. + + Returns: + A style carrying only the copy metadata, so it can be combined with a + visual style (e.g. `TStyle(dim=True) + copy_span_style(...)`). + """ + return TStyle.from_meta({COPY_TEXT_META: text, COPY_LABEL_META: label}) + + +def copy_span_target(style: object) -> tuple[str, str] | None: + """Return the copy text and field label from a span style, if any. + + Args: + style: The Textual event style under the pointer/click. + + Returns: + `(text, label)` when the span carries a copy marker, else `None`. + """ + meta = getattr(style, "meta", None) + if not isinstance(meta, dict): + return None + text = meta.get(COPY_TEXT_META) + if not isinstance(text, str) or not text: + return None + label = meta.get(COPY_LABEL_META) + if not isinstance(label, str) or not label: + return None + return text, label diff --git a/libs/code/deepagents_code/tui/widgets/_inline_prompt.py b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py new file mode 100644 index 0000000000..9e89b79863 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/_inline_prompt.py @@ -0,0 +1,431 @@ +"""Shared primitives for inline prompts.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections import Counter +from typing import TYPE_CHECKING, Any, Generic, TypeVar + +from textual.containers import Horizontal +from textual.content import Content +from textual.message import Message +from textual.widgets import Static + +if TYPE_CHECKING: + from pathlib import Path + + from textual import events + from textual.app import ComposeResult + from textual.widget import Widget + +from deepagents_code import theme +from deepagents_code.config import get_glyphs, is_ascii_mode +from deepagents_code.tui.widgets._paste_textarea import CollapsingPasteTextArea + +logger = logging.getLogger(__name__) + +ResultT = TypeVar("ResultT") + +_UNSET: Any = object() + +MEDIA_UNSUPPORTED_TOAST_PREFIX = "Only text is supported here" +"""Leading clause of the toast shown when media is dropped on an inline prompt. + +Public so tests can assert on the toast without duplicating the whole message, +which names the discarded files (see `_media_unsupported_toast`). +""" + + +def _media_unsupported_toast(paths: list[Path]) -> str: + """Build the toast for a rejected media drop. + + Names each discarded file rather than only the media ones, because the whole + payload is swallowed — a mixed drop otherwise loses its non-media paths with + nothing to explain where they went. Paths are identified by name, falling + back to the full path when two drops share a basename, so the listing always + distinguishes every file it counts. + + Args: + paths: All resolved paths in the rejected payload. Never empty — the + caller only builds a toast once it has found media. + + Returns: + Toast text naming the files that were not inserted. + """ + unique = list(dict.fromkeys(paths)) + name_counts = Counter(path.name for path in unique) + labels = [ + path.name if name_counts[path.name] == 1 else str(path) for path in unique + ] + noun = "file" if len(labels) == 1 else "files" + listing = ", ".join(labels) + return f"{MEDIA_UNSUPPORTED_TOAST_PREFIX}; {noun} not inserted: {listing}." + + +class InlinePromptCompletion(Generic[ResultT]): + """Resolve an inline prompt result at most once. + + `set_future` and `resolve` may be called in either order: a result + recorded before the future is wired is delivered as soon as the future + arrives, so a late `set_future` never strands an awaiter. + """ + + def __init__(self) -> None: + """Initialize an unresolved completion.""" + self._future: asyncio.Future[ResultT] | None = None + self._resolved = False + self._result: ResultT | Any = _UNSET + + @property + def resolved(self) -> bool: + """Whether a terminal result has been recorded.""" + return self._resolved + + def set_future(self, future: asyncio.Future[ResultT]) -> None: + """Set the future to resolve with the terminal result. + + Delivers an already-recorded result immediately, so callers may wire + the future either before or after `resolve`. + + Args: + future: Future owned by the application request path. + """ + self._future = future + if self._resolved and self._result is not _UNSET and not future.done(): + future.set_result(self._result) + + def resolve(self, result: ResultT) -> bool: + """Record the first terminal result and resolve the future if set. + + The result is retained, so a future wired later via `set_future` still + receives it. + + Args: + result: Terminal prompt result. + + Returns: + `True` when this is the first terminal result, otherwise `False`. + """ + if self._resolved: + return False + self._resolved = True + self._result = result + if self._future is not None and not self._future.done(): + self._future.set_result(result) + return True + + +class InlinePromptTextArea(CollapsingPasteTextArea): + """Soft-wrapping text input shared by inline prompts. + + Matches the primary chat input's paste handling: a multi-line paste stays + grouped instead of submitting on the first embedded newline, and a large + paste collapses into a compact `[Pasted text #N]` placeholder that expands + back to the full text via `submitted_value`. + """ + + class Submitted(Message): + """Posted when the user presses Enter to submit text. + + Subclasses should re-declare a nested `Submitted` so Textual derives a + distinct handler name (e.g. `on_goal_review_text_area_submitted`). + Without it, a host mounting more than one inline prompt cannot tell + their submissions apart, and the base handler name goes unhandled. + """ + + def __init__(self, text_area: InlinePromptTextArea, value: str) -> None: + """Initialize a text submission message. + + Args: + text_area: Input that emitted the submission. + value: Complete input text at submission time, with any + collapsed-paste placeholders expanded to their full content. + """ + super().__init__() + self.text_area = text_area + self.value = value + + def __init__(self, **kwargs: Any) -> None: + """Initialize an inline prompt text area.""" + classes = kwargs.pop("classes", None) + prompt_classes = ( + "inline-prompt-input" + if classes is None + else f"inline-prompt-input {classes}".strip() + ) + super().__init__(classes=prompt_classes, **kwargs) + self.show_line_numbers = False + self.soft_wrap = True + + async def _on_key(self, event: events.Key) -> None: + now = time.monotonic() + + # Drive the shared paste-burst state machine so a paste replayed as rapid + # key events (no bracketed paste) stays grouped and can be collapsed. + if await self._absorb_key_into_burst(event, now): + event.prevent_default() + event.stop() + return + + if self._maybe_start_burst(event, now): + event.prevent_default() + event.stop() + return + + if self._track_burst_run(event, now): + event.prevent_default() + event.stop() + return + + if event.key == "backspace" and self._delete_placeholder_token(backwards=True): + event.prevent_default() + event.stop() + return + + # Some terminals (e.g. VSCode built-in) send a literal backslash followed + # by enter for shift+enter; treat that pair as a newline before the enter + # below would otherwise submit. + if self._consume_backslash_enter_newline(event, now): + return + + self._track_backslash_pending(event, now) + + # Modifier+Enter (and Ctrl+J) insert a newline rather than submitting. + if self._consume_modifier_newline(event): + return + + if event.key == "enter": + event.prevent_default() + event.stop() + # Keep a paste's embedded newlines from submitting mid-stream. + if self._consume_enter_as_burst_newline(now): + return + self.post_message(self.Submitted(self, self.submitted_value)) + return + + await super()._on_key(event) + + async def _on_paste(self, event: events.Paste) -> None: + """Reject a dragged media file, else defer to shared paste handling.""" + # Flush first, matching the base handler: a rejection returns early, and + # leaving a pending burst behind would let its timer insert the buffered + # keystrokes after the paste was already refused. + if self._paste_burst_buffer: + await self._flush_paste_burst() + + if await self._reject_dropped_media(event.text): + event.prevent_default() + event.stop() + return + + # Don't call super() here — Textual dispatches a message to *every* + # class in the MRO that defines `_on_paste`, in order, so the base + # handlers already run after this one returns and super() would invoke + # them a second time. The `prevent_default()` above is what stops that + # walk on the rejection path. + + async def _dispatch_burst_payload(self, payload: str) -> None: + """Reject a media file replayed as a key burst, else defer to the base.""" + if await self._reject_dropped_media(payload): + return + await super()._dispatch_burst_payload(payload) + + async def _reject_dropped_media(self, text: str) -> bool: + """Toast and swallow a dropped payload containing an image or video. + + Free-text prompts accept only text, so a dragged media file is rejected + here instead of inserting its path. Detection requires the payload to + resolve to files that exist on disk in the shape a terminal emits for a + drop, so free-form prose is unaffected (see `dropped_payload_paths`). + + The whole payload is swallowed when any path is media, so a mixed drop + does not half-insert; the toast names each discarded file to make that + visible. + + Args: + text: Raw pasted/dropped text payload. + + Returns: + `True` when a media payload was detected and swallowed. + """ + from deepagents_code.input import ( + dropped_payload_paths, + looks_like_dropped_payload, + ) + from deepagents_code.media_utils import is_media_path + + # Screen with the pure string guard before hopping to a thread: the + # thread hop is an `await`, and `_dispatch_burst_payload` runs from the + # burst flush timer's own task rather than the widget message queue, so + # yielding there lets a concurrent keystroke land ahead of the buffered + # payload. Ordinary typing and pasting never pays that cost now. + if not looks_like_dropped_payload(text): + return False + + try: + paths = await asyncio.to_thread(dropped_payload_paths, text) + except Exception: + # The parser guards its own filesystem probes, but + # `_resolve_with_unicode_space_variants` calls `expanduser()` and + # `Path.cwd()` unguarded, so a deleted working directory or an + # unresolvable home still surfaces here. Log at warning (not debug) + # so it survives without DEEPAGENTS_CODE_DEBUG, since falling + # through re-inserts the path this method exists to reject. The + # message never includes the payload, though an OSError traceback + # may name a path. + logger.warning( + "Media-payload detection failed; treating paste as text", + exc_info=True, + ) + return False + if not any(is_media_path(path) for path in paths): + return False + if not self.is_mounted: + # The prompt was resolved while the probe was in flight; `self.app` + # still resolves via the active-app ContextVar, so notifying here + # would toast about a field the user can no longer see. + return True + self.app.notify( + _media_unsupported_toast(paths), + severity="warning", + timeout=5, + markup=False, + ) + return True + + +class InlinePromptOption(Horizontal): + """Render a selectable inline-prompt option with a cursor gutter.""" + + DEFAULT_CSS = """ + InlinePromptOption { + height: auto; + } + + InlinePromptOption > .inline-prompt-option-cursor { + width: 2; + height: 1; + } + + InlinePromptOption > .inline-prompt-option-label { + width: 1fr; + height: auto; + } + + InlinePromptOption.inline-prompt-option-selected > .inline-prompt-option-label { + color: $primary; + } + """ + + def __init__( + self, + text: str, + index: int, + *, + selected: bool = False, + selected_class: str | None = "inline-prompt-option-selected", + **kwargs: Any, + ) -> None: + """Initialize an option. + + Args: + text: Option label. + index: Position in its owning prompt's option list. + selected: Whether to render the option selected initially. + selected_class: CSS class applied while the option is highlighted. + **kwargs: Additional `Horizontal` arguments. + """ + self.option_index = index + self._cursor_visible = selected + self._highlighted = selected + self._text = text + self._selected_class = selected_class + self._cursor_widget: Static | None = None + super().__init__(**kwargs) + self._sync_selected_class() + + def compose(self) -> ComposeResult: + """Compose the cursor gutter and independently wrapping label. + + Yields: + The fixed cursor gutter followed by the wrapping label. + """ + self._cursor_widget = Static( + self._cursor_content(), + classes="inline-prompt-option-cursor", + ) + yield self._cursor_widget + yield Static( + Content.from_markup("$text", text=self._text), + classes="inline-prompt-option-label", + ) + + @property + def selected(self) -> bool: + """Whether the selection cursor is currently shown on this option.""" + return self._cursor_visible + + def select(self) -> None: + """Mark this option as selected.""" + self.set_state(cursor=True, highlighted=True) + + def deselect(self) -> None: + """Mark this option as deselected.""" + self.set_state(cursor=False, highlighted=False) + + def set_state(self, *, cursor: bool, highlighted: bool) -> None: + """Update cursor visibility and visual highlighting independently. + + Args: + cursor: Whether to render the selection cursor. + highlighted: Whether to apply the selected CSS class. + """ + self._cursor_visible = cursor + self._highlighted = highlighted + if self._cursor_widget is not None: + self._cursor_widget.update(self._cursor_content()) + self._sync_selected_class() + + def _cursor_content(self) -> Content: + glyphs = get_glyphs() + marker = glyphs.cursor if self._cursor_visible else self._unselected_marker + return Content(f"{marker} ") + + @property + def _unselected_marker(self) -> str: + """Marker shown in the cursor gutter when this option is not selected.""" + return " " + + def _sync_selected_class(self) -> None: + if self._selected_class is None: + return + self.set_class(self._highlighted, self._selected_class) + + +def newline_hint() -> str: + """Return the newline-shortcut hint fragment (e.g. 'Ctrl+J newline').""" + from deepagents_code.config import newline_shortcut + + return f"{newline_shortcut()} newline" + + +def apply_inline_prompt_border(widget: Widget) -> None: + """Use the ASCII border variant when the active terminal requires it. + + Args: + widget: Mounted prompt shell receiving the border style. + """ + if is_ascii_mode(): + colors = theme.get_theme_colors(widget) + widget.styles.border = ("ascii", colors.success) + + +def stop_inline_prompt_blur(event: events.Blur) -> None: + """Keep blur from being interpreted as prompt dismissal. + + Args: + event: Textual blur event emitted by an inline prompt. + """ + event.stop() diff --git a/libs/code/deepagents_code/tui/widgets/_js_eval_display.py b/libs/code/deepagents_code/tui/widgets/_js_eval_display.py new file mode 100644 index 0000000000..58530f3a60 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/_js_eval_display.py @@ -0,0 +1,139 @@ +"""Helpers for displaying `js_eval` tool output.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class JsEvalStdout: + """Captured stdout printed during a `js_eval` evaluation.""" + + body: str + """Stdout text, verbatim (the wire format does not escape stdout).""" + + +@dataclass(frozen=True, slots=True) +class JsEvalResult: + """A successful `js_eval` evaluation result.""" + + kind: str + """The result `kind` attribute (e.g. `handle`), or `""` for a plain value.""" + + body: str + """Unescaped result text.""" + + +@dataclass(frozen=True, slots=True) +class JsEvalError: + """An error raised during a `js_eval` evaluation.""" + + error_type: str + """The JS error type (e.g. `ReferenceError`), or `""` if the wire format + omitted it.""" + + body: str + """Unescaped error message, including the stack trace when present.""" + + +# Discriminated union of the parsed envelope blocks. Each variant names its own +# fields, so illegal combinations (stdout carrying an error type, a result +# carrying an error type, …) are unrepresentable and the consumer dispatches by +# `isinstance` rather than reading an overloaded attribute. +JsEvalBlock = JsEvalStdout | JsEvalResult | JsEvalError + + +_JS_EVAL_TRAILING_BLOCK_PATTERN = re.compile( + r"<(?Presult|error)(?P[^>]*)>(?P[^<>]*)\Z", + re.DOTALL, +) +r"""Match the trailing ``/`` block, anchored to end of output. + +The wire format emitted by the `js_eval` REPL tool (see langchain_quickjs +`format_outcome`) is `"\n".join(parts)`, where `parts` is an optional +`\n…\n` block followed by exactly one `` +or `` block. + +Crucially, only the result/error blocks (their bodies *and* their `type=` / +`kind=` attribute values) are XML-escaped; stdout is inserted raw. So a +`finditer`-style scan would treat a `fake` *printed* +by user code as real markup. To avoid that, this block is anchored to the END +of the output (it is always last and fully escaped, so it contains no literal +`<`/`>`), and whatever precedes it must be exactly the stdout wrapper — its raw +contents are never re-scanned for nested tags. +""" + +_JS_EVAL_STDOUT_PATTERN = re.compile( + r"\A\n(?P.*)\n\Z", + re.DOTALL, +) +"""Match the full `` wrapper that may precede the trailing block.""" + +_JS_EVAL_TYPE_ATTR_PATTERN = re.compile(r'type="([^"]*)"') +"""Extract the (escaped) `type="…"` attribute value from an `` block.""" + +_JS_EVAL_KIND_ATTR_PATTERN = re.compile(r'kind="([^"]*)"') +"""Extract the (escaped) `kind="…"` attribute value from a `` block.""" + + +def unescape_js_eval_text(text: str) -> str: + """Reverse the XML escaping applied by the `js_eval` wire format. + + The REPL escapes `&`, `<`, and `>` inside result/error blocks; order matters + so `&` is restored last to avoid double-unescaping. + + Args: + text: Escaped block body or attribute value. + + Returns: + The original, unescaped text. + """ + return text.replace("<", "<").replace(">", ">").replace("&", "&") + + +def parse_js_eval_blocks(output: str) -> list[JsEvalBlock] | None: + """Parse `js_eval` output into structured display blocks. + + Parses the wire format structurally rather than scanning for any tag-like + substring: the trailing ``/`` block is anchored to the end of + the output, and any preceding text must match the `` + wrapper exactly. The stdout body is taken verbatim and never re-scanned, so + tag-like text printed by user code is preserved as stdout rather than + mis-parsed into fake result/error sections. + + Args: + output: Raw tool output from the `js_eval` tool. + + Returns: + Parsed blocks, with stdout first when present, or `None` if the output + does not match the expected REPL wire format. + """ + trailing = _JS_EVAL_TRAILING_BLOCK_PATTERN.search(output) + if trailing is None: + return None + + tag = trailing.group("tag") + attrs = trailing.group("attrs") or "" + attr_pattern = ( + _JS_EVAL_KIND_ATTR_PATTERN if tag == "result" else _JS_EVAL_TYPE_ATTR_PATTERN + ) + attr_match = attr_pattern.search(attrs) + attr = unescape_js_eval_text(attr_match.group(1)) if attr_match else "" + body = unescape_js_eval_text(trailing.group("body")) + + prefix = output[: trailing.start()] + blocks: list[JsEvalBlock] = [] + if prefix: + if not prefix.endswith("\n"): + return None + stdout_match = _JS_EVAL_STDOUT_PATTERN.match(prefix[:-1]) + if stdout_match is None: + return None + blocks.append(JsEvalStdout(stdout_match.group("body"))) + + if tag == "result": + blocks.append(JsEvalResult(attr, body)) + else: + blocks.append(JsEvalError(attr, body)) + return blocks diff --git a/libs/code/deepagents_code/tui/widgets/_links.py b/libs/code/deepagents_code/tui/widgets/_links.py new file mode 100644 index 0000000000..1ff071e768 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/_links.py @@ -0,0 +1,261 @@ +"""Shared link-click handling for Textual widgets.""" + +from __future__ import annotations + +import ast +import asyncio +import logging +import webbrowser +from typing import TYPE_CHECKING + +from deepagents_code.unicode_security import check_url_safety, strip_dangerous_unicode + +if TYPE_CHECKING: + from textual.app import App + from textual.events import Click, MouseMove + + +def _event_app(event: object, app: App | None = None) -> App | None: + """Return the app for a click event, including real Textual widgets.""" + if app is not None: + return app + widget = getattr(event, "widget", None) + widget_app = getattr(widget, "app", None) + if widget_app is not None: + return widget_app + event_app = getattr(event, "app", None) + return event_app if event_app is not None else None + + +logger = logging.getLogger(__name__) + + +def _notify( + app: App | None, message: str, *, severity: str, timeout: int | None = None +) -> None: + """Post a best-effort Textual toast, tolerating apps without `notify`. + + Centralizes the guard/`markup=False`/exception-swallowing pattern shared by + every toast in this module so the call sites cannot drift apart. `markup` is + always disabled so URL content can never be interpreted as Textual markup. + + Args: + app: App-like object used to post the toast, or `None`. + message: The toast body. Callers must sanitize any URL with + `strip_dangerous_unicode` before interpolating it here. + severity: Textual notification severity (e.g. `information`, `warning`). + timeout: Optional toast lifetime in seconds; the Textual default is used + when omitted. + """ + notify = getattr(app, "notify", None) + if not callable(notify): + return + kwargs: dict[str, object] = {"severity": severity, "markup": False} + if timeout is not None: + kwargs["timeout"] = timeout + try: + notify(message, **kwargs) + except (AttributeError, TypeError): + logger.debug("Could not send notification", exc_info=True) + + +def _url_open_toasts_enabled() -> bool: + """Return whether successful URL-open clicks should show a toast.""" + from deepagents_code.config_manifest import ( + get_option, + load_config_toml, + resolve_scalar, + ) + + option = get_option("display.show_url_open_toast") + if option is None: + return True + value, _ = resolve_scalar(option, toml_data=load_config_toml()) + return bool(value) + + +def _notify_url_opened(app: App | None, url: str) -> None: + """Show the URL-opened toast when the user has not opted out.""" + if app is None or not _url_open_toasts_enabled(): + return + _notify( + app, + f"Opening URL in default browser: {strip_dangerous_unicode(url)}", + severity="information", + timeout=4, + ) + + +def _link_action_url(click: object) -> str | None: + """Extract a URL from Textual's Markdown `link(...)` click action. + + Args: + click: The `@click` style metadata value to inspect. + + Returns: + The parsed URL when the metadata is a quoted `link(...)` action. + """ + if not isinstance(click, str): + return None + if not click.startswith("link(") or not click.endswith(")"): + return None + try: + url = ast.literal_eval(click[len("link(") : -1].strip()) + except (SyntaxError, ValueError): + return None + return url if isinstance(url, str) and url else None + + +def _style_url(style: object) -> str | None: + """Return a URL from either Rich link style or Textual click metadata. + + Args: + style: The Textual event style to inspect. + + Returns: + The URL embedded in the style, if one is present. + """ + url = getattr(style, "link", None) + if isinstance(url, str) and url: + return url + meta = getattr(style, "meta", None) + if not isinstance(meta, dict): + return None + return _link_action_url(meta.get("@click")) + + +def event_targets_link(event: MouseMove) -> bool: + """Return whether the style under the mouse points to a clickable link. + + Detects both Rich `Style(link=...)` (OSC 8) hyperlinks and the + `@click=link(...)` meta actions that Textual's `Markdown` widget attaches + to rendered links and images. + + Args: + event: The Textual mouse-move event to inspect. + + Returns: + `True` when the hovered character belongs to a link span. + """ + return _style_url(event.style) is not None + + +async def open_checked_url_async( + url: str, *, app: App, notify_on_success: bool = False +) -> bool: + """Open a URL after applying the shared URL safety check. + + Args: + url: The URL to validate and open. + app: App used to post browser-open notifications. + notify_on_success: Whether to post an informational toast when the + browser accepts the URL. + + Returns: + `True` when the URL passed safety checks and the browser accepted it; + `False` when the URL was blocked or the browser could not open it. + """ + safety = check_url_safety(url) + if not safety.safe: + detail = safety.warnings[0] if safety.warnings else "Suspicious URL" + logger.warning("Blocked suspicious URL: %s (%s)", url, detail) + _notify( + app, + f"Blocked suspicious URL: {strip_dangerous_unicode(url)}\n{detail}", + severity="warning", + ) + return False + return await open_url_async(url, app=app, notify_on_success=notify_on_success) + + +async def open_url_async( + url: str, *, app: App, notify_on_success: bool = False +) -> bool: + """Open url in a browser and toast on failure. + + Runs `webbrowser.open` in a thread, catches the platform errors + that can arise when no browser backend is available, and posts a + warning toast containing the URL so the user can copy it manually + instead of the failure vanishing into a background worker log. + + Args: + url: The URL to open. + app: App used to post browser-open notifications. + notify_on_success: Whether to post an informational toast when the + browser accepts the URL. + + Returns: + `True` when the browser accepted the URL; `False` otherwise + (in which case a warning toast has already been posted). + """ + try: + opened = await asyncio.to_thread(webbrowser.open, url) + except (webbrowser.Error, OSError) as exc: + logger.warning("webbrowser.open failed for %s: %s", url, exc, exc_info=True) + opened = False + if not opened: + _notify( + app, + f"Could not open a browser. URL: {strip_dangerous_unicode(url)}", + severity="warning", + timeout=8, + ) + elif notify_on_success: + _notify_url_opened(app, url) + return opened + + +def open_style_link(event: Click, *, app: App | None = None) -> None: + """Open the URL from a Rich link style on click, if present. + + Rich `Style(link=...)` embeds OSC 8 terminal hyperlinks, but Textual's + mouse capture intercepts normal clicks before the terminal can act on them. + By handling the Textual click event directly we open the URL with a single + click, matching the behavior of links in the Markdown widget. + + URLs that fail the safety check (e.g. containing hidden Unicode or + homograph domains) are blocked and not opened; the event bubbles and a + warning is logged and displayed as a Textual notification. + + On success the event is stopped so it does not bubble further and, unless + the user has opted out, a best-effort informational toast reports the URL + that was opened. If the browser cannot be launched -- either + `webbrowser.open` raises or the backend declines and returns a falsy value + -- a warning toast with the URL is shown so it can be copied manually, the + failure is logged, and the event bubbles normally. + + Args: + event: The Textual click event to inspect. + app: App used to post browser-open notifications. + """ + notify_app = _event_app(event, app) + url = _style_url(event.style) + if not url: + return + + safety = check_url_safety(url) + if not safety.safe: + detail = safety.warnings[0] if safety.warnings else "Suspicious URL" + logger.warning("Blocked suspicious URL: %s (%s)", url, detail) + _notify( + notify_app, + f"Blocked suspicious URL: {strip_dangerous_unicode(url)}\n{detail}", + severity="warning", + ) + return + + try: + opened = webbrowser.open(url) + except (webbrowser.Error, OSError) as exc: + logger.warning("webbrowser.open failed for %s: %s", url, exc, exc_info=True) + opened = False + if not opened: + _notify( + notify_app, + f"Could not open a browser. URL: {strip_dangerous_unicode(url)}", + severity="warning", + timeout=8, + ) + return + _notify_url_opened(notify_app, url) + event.stop() diff --git a/libs/code/deepagents_code/tui/widgets/_paste_textarea.py b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py new file mode 100644 index 0000000000..ac6c79a660 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/_paste_textarea.py @@ -0,0 +1,688 @@ +"""Shared paste handling for text-area inputs. + +Terminals deliver a paste in one of two shapes: a single bracketed `Paste` +event, or — when bracketed paste is unavailable — a rapid stream of individual +key events. Both the primary chat input and the inline free-text prompts need +to (a) keep a multi-line paste grouped instead of submitting on the first +embedded newline, and (b) collapse a large paste into a compact +`[Pasted text #N]` placeholder that expands back to the full text on submit. + +`PasteBurstTextArea` owns the burst detection and Enter-suppression state +machine, leaving policy (slash-command context, whether collapsing is enabled, +how a flushed payload is handled) to overridable hooks. +`CollapsingPasteTextArea` layers the large-paste collapse + placeholder storage +on top, keeping the full content off-screen until submission. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, ClassVar + +from textual.binding import Binding +from textual.widgets import TextArea + +from deepagents_code.paste_collapse import ( + PASTE_PLACEHOLDER_PATTERN, + PastedContent, + count_lines, + expand_paste_refs, + format_paste_ref, + should_collapse_paste, +) + +if TYPE_CHECKING: + from textual import events + from textual.timer import Timer + +logger = logging.getLogger(__name__) + +PASTE_BURST_CHAR_GAP_SECONDS = 0.03 +"""Maximum time between chars to treat input as a paste-like burst.""" + +PASTE_BURST_FLUSH_DELAY_SECONDS = 0.08 +"""Idle timeout before flushing buffered burst text.""" + +PASTE_BURST_START_CHARS = {"'", '"'} +"""Characters that can start dropped-path payloads.""" + +PASTE_BURST_MIN_CHARS = 3 +"""Consecutive fast keystrokes before a stream is treated as a paste burst. + +Terminals that lack bracketed paste replay a paste as individual key events. +Counting a short run of rapid chars distinguishes that from human typing, +which has much larger inter-key gaps. +""" + +PASTE_ENTER_SUPPRESS_WINDOW_SECONDS = 0.12 +"""Window after recent burst activity during which `enter` inserts a newline. + +Keeps multi-line pastes grouped as one input even when newlines arrive as +`enter` key events slightly after the surrounding characters (e.g. across +terminal read boundaries), instead of submitting mid-paste. +""" + +_BACKSLASH_ENTER_GAP_SECONDS = 0.15 +"""Maximum gap between a `\\` key and a following `enter` key to treat the +pair as a terminal-emitted shift+enter sequence. + +Some terminals (e.g. VSCode's built-in terminal) send a literal backslash +followed by enter when the user presses shift+enter. The gap is +generous (150 ms) because the terminal emits both characters nearly +simultaneously; a human deliberately typing `\\` then pressing Enter would +have a much larger gap.""" + + +class PasteBurstTextArea(TextArea): + """`TextArea` that detects paste-like keystroke bursts. + + Subclasses drive the state machine from their own `_on_key` by calling the + helper methods here, and override the policy hooks + (`_in_slash_command_context`, `_dispatch_burst_payload`) as needed. The base + inserts a flushed burst verbatim; collapsing into placeholders is layered on + by `CollapsingPasteTextArea`. + """ + + BINDINGS: ClassVar[list[Binding]] = [ + Binding( + "shift+enter,alt+enter,ctrl+enter,ctrl+j", + "insert_newline", + "New Line", + show=False, + priority=True, + ), + Binding( + "ctrl+backspace,alt+backspace", + "delete_word_left", + "Delete left to start of word", + show=False, + ), + ] + """Shared key bindings for every paste-aware text area. + + These are the single source of truth for shortcut keys, inherited by both + the chat input and inline prompts, which no longer define their own (were a + subclass to add its own BINDINGS, Textual would merge them across the MRO + rather than replace these). `_NEWLINE_KEYS` is derived from this list so + `_on_key` stays in sync. + """ + + _NEWLINE_KEYS: ClassVar[frozenset[str]] = frozenset( + key + for b in BINDINGS + if b.action == "insert_newline" + for key in b.key.split(",") + ) + """Flattened set of keys that insert a newline, derived from `BINDINGS`.""" + + _paste_burst_buffer: str + _paste_burst_last_char_time: float | None + _paste_burst_timer: Timer | None + _paste_burst_run: int + _paste_burst_run_text: str + _paste_burst_last_key_time: float | None + _paste_burst_last_suppressed_enter_time: float | None + _paste_burst_window_until: float | None + _backslash_pending_time: float | None + + def __init__(self, **kwargs: Any) -> None: + """Initialize the text area and its paste-burst state.""" + super().__init__(**kwargs) + self._init_paste_burst_state() + + def _init_paste_burst_state(self) -> None: + """Reset all paste-burst tracking fields to their initial values.""" + # Buffer high-frequency key bursts from terminals that emulate paste via + # rapid key events instead of dispatching a paste event. + self._paste_burst_buffer = "" + self._paste_burst_last_char_time = None + self._paste_burst_timer = None + # Counts consecutive rapid keystrokes so a paste-like stream can be + # detected even when it doesn't begin with a quote. + self._paste_burst_run = 0 + self._paste_burst_run_text = "" + self._paste_burst_last_key_time = None + self._paste_burst_last_suppressed_enter_time = None + # Deadline until which `enter` inserts a newline rather than submitting, + # keeping multi-line pastes grouped across read boundaries. + self._paste_burst_window_until = None + # Timestamp of a `\` keypress awaiting a fast `enter` to be treated as a + # terminal-emitted shift+enter. See `_BACKSLASH_ENTER_GAP_SECONDS`. + self._backslash_pending_time = None + + # -- Policy hooks (override in subclasses) -------------------------------- + + def _in_slash_command_context(self) -> bool: # noqa: PLR6301 # overridable hook + """Return whether Enter should keep submit/dispatch semantics. + + Base text areas have no slash-command surface, so the Enter-suppression + window always applies. Override to opt keystrokes out of grouping. + """ + return False + + async def _dispatch_burst_payload(self, payload: str) -> None: + """Handle a flushed burst payload. Base behavior inserts it verbatim.""" + self.insert(payload) + + # -- Burst state machine -------------------------------------------------- + + def _cancel_paste_burst_timer(self) -> None: + """Cancel any scheduled paste-burst flush timer.""" + if self._paste_burst_timer is None: + return + self._paste_burst_timer.stop() + self._paste_burst_timer = None + + def _schedule_paste_burst_flush(self) -> None: + """Schedule idle-time flush for buffered paste-burst text.""" + self._cancel_paste_burst_timer() + self._paste_burst_timer = self.set_timer( + PASTE_BURST_FLUSH_DELAY_SECONDS, self._flush_paste_burst + ) + + def _start_paste_burst(self, char: str, now: float) -> None: + """Start buffering a paste-like keystroke burst.""" + self._paste_burst_buffer = char + self._paste_burst_last_char_time = now + self._paste_burst_window_until = now + PASTE_ENTER_SUPPRESS_WINDOW_SECONDS + self._schedule_paste_burst_flush() + + def _append_paste_burst(self, text: str, now: float) -> None: + """Append text to an active paste-burst buffer.""" + if not self._paste_burst_buffer: + self._start_paste_burst(text, now) + return + self._paste_burst_buffer += text + self._paste_burst_last_char_time = now + self._paste_burst_window_until = now + PASTE_ENTER_SUPPRESS_WINDOW_SECONDS + self._schedule_paste_burst_flush() + + def _note_paste_burst_keystroke(self, char: str, now: float) -> None: + """Track text and timing for consecutive rapid keystrokes.""" + last = self._paste_burst_last_key_time + if last is not None and (now - last) <= PASTE_BURST_CHAR_GAP_SECONDS: + self._paste_burst_run += 1 + self._paste_burst_run_text += char + else: + self._paste_burst_run = 1 + self._paste_burst_run_text = char + self._paste_burst_last_key_time = now + + def _reset_paste_burst_run(self) -> None: + """Clear consecutive-keystroke tracking after non-burst input.""" + self._paste_burst_run = 0 + self._paste_burst_run_text = "" + self._paste_burst_last_key_time = None + self._paste_burst_last_suppressed_enter_time = None + + def _reset_paste_burst_state(self) -> None: + """Reset all paste-burst and backslash tracking to a clean slate. + + Used by text-replacing entry points so a wholesale text swap never + leaves stale burst timing that would misclassify the next keystroke. + """ + self._paste_burst_buffer = "" + self._paste_burst_last_char_time = None + self._paste_burst_window_until = None + self._backslash_pending_time = None + self._reset_paste_burst_run() + self._cancel_paste_burst_timer() + + def _enter_inserts_newline_during_burst(self, now: float) -> bool: + """Return whether `enter` should insert a newline rather than submit. + + True when the preceding keystroke was part of a rapid run or the + previous `enter` was already suppressed, and the suppression window is + still open. The char-gap check keeps a deliberate `enter` pressed after + a burst settles from being swallowed; the window bounds how long a + replayed paste's newlines stay grouped. Returns `False` immediately in + slash-command context (see `_in_slash_command_context`). + """ + if self._in_slash_command_context(): + return False + # Defensive: the shipped `_on_key`s absorb (via `_absorb_key_into_burst`) + # or flush any active buffer before Enter reaches this helper, so this + # branch is unreachable today. It keeps the helper's contract + # self-contained for future callers. + if self._paste_burst_buffer: + return True + until = self._paste_burst_window_until + if until is None or now > until: + return False + last_enter = self._paste_burst_last_suppressed_enter_time + if last_enter is not None: + return True + last_key = self._paste_burst_last_key_time + return last_key is not None and (now - last_key) <= PASTE_BURST_CHAR_GAP_SECONDS + + def _should_start_paste_burst(self, char: str) -> bool: + """Return whether a keypress should start paste-burst buffering. + + Quote-prefixed input at an empty cursor is buffered immediately for + dropped-path parsing. Other printable runs are promoted into the same + buffer once they reach `PASTE_BURST_MIN_CHARS` rapid keystrokes. + """ + if char not in PASTE_BURST_START_CHARS: + return False + if self.text or not self.selection.is_empty: + return False + row, col = self.cursor_location + return row == 0 and col == 0 + + async def _flush_paste_burst(self) -> None: + """Flush buffered burst text through the payload dispatch hook. + + When the buffer is empty this is a no-op, so it is safe to call + defensively before handling a bracketed paste. + """ + payload = self._paste_burst_buffer + self._paste_burst_buffer = "" + self._paste_burst_last_char_time = None + self._cancel_paste_burst_timer() + if not payload: + return + await self._dispatch_burst_payload(payload) + + def _promote_paste_burst_run(self, char: str, now: float) -> bool: + """Move a detected rapid run from the document into the burst buffer. + + The first keys in an unquoted run are inserted normally while the run is + still indistinguishable from typing. Once the threshold is reached, this + removes those keys and buffers the complete run so its eventual flush can + apply dropped-path and paste-collapse policy. + + Args: + char: Current character, which has not yet been inserted. + now: Monotonic timestamp for the current key event. + + Returns: + `True` when the run was promoted and the current key was buffered. + """ + if not char or not self.selection.is_empty: + return False + prefix = self._paste_burst_run_text[: -len(char)] + cursor = self.cursor_location + cursor_offset = self.document.get_index_from_location(cursor) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower + start_offset = cursor_offset - len(prefix) + if start_offset < 0 or self.text[start_offset:cursor_offset] != prefix: + return False + start = self.document.get_location_from_index(start_offset) # ty: ignore[unresolved-attribute] + self.delete(start, cursor) + self._start_paste_burst(self._paste_burst_run_text, now) + return True + + def action_insert_newline(self) -> None: + """Insert a newline at the cursor.""" + self.insert("\n") + + # -- `_on_key` building blocks (shared by concrete text areas) ------------ + + async def _absorb_key_into_burst(self, event: events.Key, now: float) -> bool: + """Absorb a key into an active burst buffer, flushing if it breaks it. + + Returns: + `True` when the key was buffered and the caller should stop handling + it; `False` when there is no active burst (or it was just flushed) + and the caller should continue normal key handling. + """ + if not self._paste_burst_buffer: + return False + if event.key == "enter": + self._append_paste_burst("\n", now) + return True + if event.is_printable and event.character is not None: + last_time = self._paste_burst_last_char_time + if ( + last_time is not None + and (now - last_time) <= PASTE_BURST_CHAR_GAP_SECONDS + ): + self._append_paste_burst(event.character, now) + return True + await self._flush_paste_burst() + return False + + def _maybe_start_burst(self, event: events.Key, now: float) -> bool: + """Start buffering when a keypress looks like the head of a paste. + + Returns: + `True` when a burst was started and the caller should stop handling + the key. + """ + if ( + event.is_printable + and event.character is not None + and self._should_start_paste_burst(event.character) + ): + self._start_paste_burst(event.character, now) + return True + return False + + def _track_burst_run(self, event: events.Key, now: float) -> bool: + """Track a rapid run and promote it into the paste buffer once detected. + + Returns: + `True` when the current key was buffered and should not be handled by + the caller. + """ + if event.is_printable and event.character is not None: + self._paste_burst_last_suppressed_enter_time = None + self._note_paste_burst_keystroke(event.character, now) + if ( + self._paste_burst_run >= PASTE_BURST_MIN_CHARS + and not self._in_slash_command_context() + ): + self._paste_burst_window_until = ( + now + PASTE_ENTER_SUPPRESS_WINDOW_SECONDS + ) + return self._promote_paste_burst_run(event.character, now) + elif event.key != "enter": + self._reset_paste_burst_run() + return False + + def _consume_enter_as_burst_newline(self, now: float) -> bool: + """Insert a newline instead of submitting when inside a paste burst. + + Returns: + `True` when Enter was consumed as a newline (part of a paste); + `False` when Enter should fall through to its submit handling. + """ + if not self._enter_inserts_newline_during_burst(now): + self._paste_burst_last_suppressed_enter_time = None + return False + self._paste_burst_window_until = now + PASTE_ENTER_SUPPRESS_WINDOW_SECONDS + self._paste_burst_last_suppressed_enter_time = now + self.action_insert_newline() + return True + + # -- Newline affordances (shared by concrete text areas) ------------------ + + def _delete_preceding_backslash(self) -> bool: + """Delete the backslash character immediately before the cursor. + + Caller must ensure a backslash is expected at this position. The + method verifies the character before deleting it. + + Returns: + `True` if a backslash was found and deleted, `False` otherwise. + """ + row, col = self.cursor_location + if col > 0: + start = (row, col - 1) + if self.document.get_text_range(start, self.cursor_location) == "\\": + self.delete(start, self.cursor_location) + return True + elif row > 0: + prev_line = self.document.get_line(row - 1) + start = (row - 1, len(prev_line) - 1) + end = (row - 1, len(prev_line)) + if self.document.get_text_range(start, end) == "\\": + self.delete(start, self.cursor_location) + return True + return False + + def _consume_backslash_enter_newline( + self, event: events.Key, now: float, *, enabled: bool = True + ) -> bool: + """Return whether a terminal-emitted backslash+Enter became a newline. + + Args: + event: The key event being handled. + now: Current monotonic timestamp, compared against the pending + backslash time via `_BACKSLASH_ENTER_GAP_SECONDS`. + enabled: When `False`, the fallback is skipped (still clearing any + pending backslash). Callers pass `False` to suppress it while a + competing affordance owns Enter (e.g. an open completion popup). + """ + if ( + event.key == "enter" + and enabled + and self._backslash_pending_time is not None + and (now - self._backslash_pending_time) <= _BACKSLASH_ENTER_GAP_SECONDS + ): + self._backslash_pending_time = None + if self._delete_preceding_backslash(): + event.prevent_default() + event.stop() + self.action_insert_newline() + return True + self._backslash_pending_time = None + return False + + def _track_backslash_pending(self, event: events.Key, now: float) -> None: + """Record a backslash keypress so a fast following Enter becomes a newline.""" + if event.key == "backslash" and event.character == "\\": + self._backslash_pending_time = now + + def _consume_modifier_newline(self, event: events.Key) -> bool: + """Return whether a modifier-Enter (or Ctrl+J) key inserted a newline.""" + if event.key in self._NEWLINE_KEYS: + event.prevent_default() + event.stop() + self.action_insert_newline() + return True + return False + + +class CollapsingPasteTextArea(PasteBurstTextArea): + """Paste-aware text area that collapses large pastes into placeholders. + + The full pasted text is stored off-screen in `_pasted_contents` and a + compact `[Pasted text #N]` placeholder is shown in its place. Read + `submitted_value` to get the text with all placeholders expanded back. + """ + + _pasted_contents: dict[int, PastedContent] + _next_paste_id: int + _collapse_pastes: bool + + def __init__(self, **kwargs: Any) -> None: + """Initialize the text area and its collapsed-paste storage.""" + super().__init__(**kwargs) + self._pasted_contents = {} + self._next_paste_id = 1 + # Resolve the preference once, mirroring how `ChatInput` caches it at + # construction, so paste handling never re-reads config from disk and + # stays consistent with the chat input for the widget's lifetime. + self._collapse_pastes = _collapse_pastes_enabled() + + @property + def submitted_value(self) -> str: + """The current text with collapsed-paste placeholders expanded.""" + return expand_paste_refs(self.text, self._pasted_contents) + + def reset_paste_state(self) -> None: + """Drop burst timing and collapsed-paste storage after a text swap. + + Call after a wholesale programmatic `text` swap (e.g. switching an + inline editor between modes) so a stale flush timer can't fire against + the new text and placeholders from the previous buffer don't linger in + `_pasted_contents`. + """ + self._reset_paste_burst_state() + self._pasted_contents.clear() + self._next_paste_id = 1 + + def _paste_collapse_enabled(self) -> bool: + """Return whether large pastes are collapsed into placeholders. + + Returns the preference resolved once at construction (see `__init__`). + """ + return self._collapse_pastes + + async def _dispatch_burst_payload(self, payload: str) -> None: + """Collapse a large flushed burst, otherwise insert it verbatim.""" + self._insert_paste_payload(payload) + + def _insert_paste_payload(self, payload: str) -> None: + """Collapse `payload` into a placeholder when large, else insert it.""" + if self._paste_collapse_enabled() and should_collapse_paste(payload): + self._collapse_and_insert_paste(payload) + else: + self.insert(payload) + + def _collapse_and_insert_paste(self, text: str) -> None: + """Store full paste content and insert a compact placeholder. + + Pasting content identical to a visible already-collapsed placeholder + expands that placeholder back to full text in place instead of adding a + second placeholder — a repeat paste is treated as a request to see the + content in full. + + Args: + text: The full pasted text to collapse. + """ + visible_ids = { + int(match.group(1)) + for match in PASTE_PLACEHOLDER_PATTERN.finditer(self.text) + } + match_id = next( + ( + pid + for pid, stored in self._pasted_contents.items() + if pid in visible_ids and stored.content == text + ), + None, + ) + if match_id is not None and self._replace_placeholder_with_text(match_id, text): + return + paste_id = self._next_paste_id + self._next_paste_id += 1 + self._pasted_contents[paste_id] = PastedContent(content=text) + self.insert(format_paste_ref(paste_id, count_lines(text))) + + def _replace_placeholder_with_text(self, paste_id: int, content: str) -> bool: + """Replace a `[Pasted text #id]` placeholder with its full text in place. + + Args: + paste_id: The paste id whose placeholder should be expanded. + content: The full text to insert where the placeholder was. + + Returns: + `True` when a matching placeholder was found and replaced. + """ + for match in PASTE_PLACEHOLDER_PATTERN.finditer(self.text): + if int(match.group(1)) != paste_id: + continue + start, end = match.span() + start_location = self.document.get_location_from_index(start) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower + end_location = self.document.get_location_from_index(end) # ty: ignore[unresolved-attribute] + self.delete(start_location, end_location) + self.insert(content, start_location) + return True + return False + + def _delete_placeholder_token(self, *, backwards: bool) -> bool: + """Delete a full collapsed-paste placeholder in one keypress. + + Args: + backwards: Whether the delete is backwards (`backspace`) or + forwards (`delete`). + + Returns: + `True` when a placeholder token was deleted. + """ + if not self.text or not self.selection.is_empty: + return False + cursor_offset = self.document.get_index_from_location(self.cursor_location) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower + span = self._find_placeholder_span(cursor_offset, backwards=backwards) + if span is None: + return False + start, end = span + start_location = self.document.get_location_from_index(start) # ty: ignore[unresolved-attribute] + end_location = self.document.get_location_from_index(end) # ty: ignore[unresolved-attribute] + self.delete(start_location, end_location) + self.move_cursor(start_location) + return True + + def _find_placeholder_span( + self, cursor_offset: int, *, backwards: bool + ) -> tuple[int, int] | None: + """Return the collapsed-paste placeholder span to delete, if any. + + Only placeholders backed by an entry in `_pasted_contents` are treated + as atomic tokens; placeholder-shaped text the user typed by hand edits + character by character. The paste map is left untouched so an undo can + restore the token with its content. + + Args: + cursor_offset: Character offset of the cursor from the text start. + backwards: Whether the delete is backwards (backspace) or forwards. + + Returns: + The `(start, end)` span of the placeholder to delete, or `None`. + """ + text = self.text + pasted_ids = set(self._pasted_contents) + for match in PASTE_PLACEHOLDER_PATTERN.finditer(text): + if int(match.group(1)) not in pasted_ids: + continue + start, end = match.span() + if backwards: + if start < cursor_offset <= end: + return start, end + if cursor_offset > 0: + previous_index = cursor_offset - 1 + # Swallow trailing whitespace with the token, except for a + # newline: backspacing a line break should rejoin the lines + # without deleting the placeholder. + if ( + previous_index < len(text) + and previous_index == end + and text[previous_index].isspace() + and text[previous_index] != "\n" + ): + return start, cursor_offset + elif start <= cursor_offset < end: + return start, end + return None + + def action_delete_right(self) -> None: + """Delete a bound paste placeholder atomically or the next character.""" + if not self._delete_placeholder_token(backwards=False): + super().action_delete_right() + + def action_delete_word_left(self) -> None: + """Delete a bound paste placeholder atomically or the previous word.""" + if not self._delete_placeholder_token(backwards=True): + super().action_delete_word_left() + + async def _on_paste(self, event: events.Paste) -> None: + """Collapse a large bracketed paste, else let the base area insert it.""" + if self._paste_burst_buffer: + await self._flush_paste_burst() + if self._paste_collapse_enabled() and should_collapse_paste(event.text): + # Intercept so Textual's default paste handler doesn't also insert + # the full text; store it and insert a compact placeholder instead. + event.prevent_default() + event.stop() + self._collapse_and_insert_paste(event.text) + # Otherwise fall through: Textual's TextArea._on_paste inserts the text. + + +def _collapse_pastes_enabled() -> bool: + """Resolve whether large pastes should be collapsed into placeholders. + + Reads `DEEPAGENTS_CODE_COLLAPSE_PASTES`, then `[ui].collapse_pastes` in + `~/.deepagents/config.toml`, defaulting to enabled. This is the single + source of truth shared with the chat input (`ChatInput` calls it once at + construction). + + Returns: + The resolved preference (defaults to `True`). + """ + from deepagents_code.config_manifest import ( + get_option, + load_config_toml, + resolve_scalar, + ) + + option = get_option("display.collapse_pastes") + if option is None: + # Unreachable unless the manifest key is renamed without updating this + # literal; log so that mismatch surfaces instead of silently defaulting. + logger.warning( + "Unknown config option %r; defaulting to enabled", "display.collapse_pastes" + ) + return True + value, _ = resolve_scalar(option, toml_data=load_config_toml()) + return bool(value) diff --git a/libs/code/deepagents_code/tui/widgets/agent_selector.py b/libs/code/deepagents_code/tui/widgets/agent_selector.py new file mode 100644 index 0000000000..018ac2dfac --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/agent_selector.py @@ -0,0 +1,420 @@ +"""Interactive agent selector screen for `/agents` command.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from typing import TYPE_CHECKING, ClassVar + +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.content import Content +from textual.screen import ModalScreen +from textual.widgets import OptionList, Static +from textual.widgets.option_list import Option + +if TYPE_CHECKING: + from textual.app import ComposeResult + +from deepagents_code import theme +from deepagents_code.config import Glyphs, get_glyphs, is_ascii_mode +from deepagents_code.model_config import clear_default_agent, save_default_agent + +logger = logging.getLogger(__name__) + + +class AgentSelectorScreen(ModalScreen[str | None]): + """Modal dialog for switching between available agents. + + Displays agent profiles from `~/.deepagents/` (directories that contain an + `AGENTS.md` marker) in an `OptionList`. Returns the selected agent name on + Enter, or `None` on Esc (no change). `Ctrl+S` toggles the highlighted + agent as the persisted default (`[agents].default`), mirroring the model + selector's affordance. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "cancel", "Cancel", show=False), + Binding("tab", "cursor_down", "Next", show=False, priority=True), + Binding("shift+tab", "cursor_up", "Previous", show=False, priority=True), + Binding("ctrl+s", "set_default", "Set default", show=False, priority=True), + ] + """Key bindings for the selector. + + Esc dismisses without switching agents. Arrow keys, Enter, and letter + navigation are handled natively by the embedded `OptionList`; Tab / + Shift+Tab are bound here to advance the cursor for consistency with + other selector screens. Ctrl+S toggles the highlighted agent as the + persisted default. + """ + + CSS = """ + AgentSelectorScreen { + align: center middle; + } + + AgentSelectorScreen > Vertical { + width: 60; + max-width: 90%; + height: auto; + max-height: 80%; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + AgentSelectorScreen .agent-selector-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + AgentSelectorScreen .agent-selector-subtitle { + height: auto; + color: $text-muted; + text-align: center; + margin-bottom: 1; + } + + AgentSelectorScreen OptionList { + height: auto; + max-height: 16; + background: $background; + } + + AgentSelectorScreen .agent-selector-help { + height: auto; + color: $text-muted; + text-style: italic; + margin-top: 1; + text-align: center; + } + """ + """Styling for the centered modal shell, title, option list, and help footer.""" + + def __init__( + self, + current_agent: str | None, + agent_names: list[str], + *, + default_agent: str | None = None, + ) -> None: + """Initialize the `AgentSelectorScreen`. + + Args: + current_agent: The name of the currently active agent (to + highlight). + + May be `None` when no agent is active. + agent_names: Sorted list of available agent names to display. + default_agent: The persisted default agent name from + `[agents].default`, or `None` if no default is set. + """ + super().__init__() + self._current_agent = current_agent + self._agent_names = agent_names + self._default_agent = default_agent + + def compose(self) -> ComposeResult: + """Compose the screen layout. + + Yields: + Widgets for the agent selector UI. + """ + glyphs = get_glyphs() + + with Vertical(): + yield Static("Select Agent", classes="agent-selector-title") + if self._agent_names: + yield Static( + "Switching restarts the agent and starts a new thread.", + classes="agent-selector-subtitle", + ) + option_list = OptionList( + *self._build_options(), + id="agent-options", + ) + option_list.highlighted = self._current_index() + yield option_list + help_text = self._help_text(glyphs) + else: + yield Static( + "No agents found in ~/.deepagents/.\n" + "Run dcode with -a to create one.", + classes="agent-selector-help", + ) + help_text = f"{glyphs.bullet} Esc close" + yield Static(help_text, classes="agent-selector-help", id="agent-help") + + def _build_options(self) -> list[Option]: + """Build option entries with `(current)` / `(default)` suffixes. + + Render labels via `Content.from_markup` so agent directory names + containing Rich markup characters (e.g. `[`) don't break rendering. + + Returns: + One `Option` per agent name in `self._agent_names`. + """ + return [Option(self._format_label(name), id=name) for name in self._agent_names] + + def _format_label(self, name: str) -> Content: + """Render an agent's label with `(current)` / `(default)` markers. + + Args: + name: The agent directory name. + + Returns: + Styled `Content` label. + """ + is_current = name == self._current_agent + is_default = name == self._default_agent + if is_current and is_default: + return Content.from_markup( + "$name [dim](current,[/dim] [bold]default[/bold][dim])[/dim]", + name=name, + ) + if is_current: + return Content.from_markup("$name [dim](current)[/dim]", name=name) + if is_default: + return Content.from_markup( + "$name [dim]([/dim][bold]default[/bold][dim])[/dim]", name=name + ) + return Content.from_markup("$name", name=name) + + def _current_index(self) -> int: + """Return the index of the current agent in the option list, or 0.""" + if self._current_agent is None: + return 0 + try: + return self._agent_names.index(self._current_agent) + except ValueError: + return 0 + + @staticmethod + def _help_text(glyphs: Glyphs) -> str: + r"""Build the help-line text shown beneath the option list. + + Split into two balanced rows joined by `\n` so the wrap is + predictable at the modal's fixed 60-column width — Textual's + default word-wrap might otherwise break mid-phrase (e.g., + between "set" and "default"), which reads as a bug. The Static + host has `height: auto` and `text-align: center`, so each row + centers on its own line. + + Args: + glyphs: Glyph set for the active terminal mode. + + Returns: + Two-line help string describing the available key bindings. + """ + return ( + f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab switch" + f" {glyphs.bullet} Enter select\n" + f"Ctrl+S set default {glyphs.bullet} Esc cancel" + ) + + def on_mount(self) -> None: + """Apply ASCII border if needed.""" + if is_ascii_mode(): + container = self.query_one(Vertical) + colors = theme.get_theme_colors(self) + container.styles.border = ("ascii", colors.success) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + """Dismiss with the selected agent name. + + Args: + event: The option selected event. + """ + name = event.option.id + self.dismiss(name) + + def action_cancel(self) -> None: + """Cancel without switching agents.""" + self.dismiss(None) + + def action_cursor_down(self) -> None: + """Move the option list cursor down (Tab).""" + option_list = self._option_list() + if option_list is not None: + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + """Move the option list cursor up (Shift+Tab).""" + option_list = self._option_list() + if option_list is not None: + option_list.action_cursor_up() + + async def action_set_default(self) -> None: + """Toggle the highlighted agent as the persisted default. + + If the highlighted agent is already the default, clears it. + Otherwise sets it as the new default. Disk I/O is offloaded to a + thread so the modal stays responsive. The help line shows a + transient confirmation/error message; the option list is rebuilt + in place so the `(default)` marker tracks the new state. + + Robustness notes: + * The `to_thread` call is wrapped in `try/except Exception` so + an unexpected error inside the persistence functions + (e.g., `tomli_w.dump` raising a `TypeError`) is treated as + a normal save failure rather than killing the modal. + * Post-await `query_one` is guarded against `NoMatches` so a + user dismissing the modal mid-flight does not surface a + Textual callback error. + * The option-list rebuild is staged in `_refresh_options`, + which builds the new options first and only swaps on + success. A failure mid-rebuild leaves the existing list + intact and shows an error in the help line. + """ + from textual.css.query import NoMatches + + option_list = self._option_list() + if option_list is None or not self._agent_names: + return + + highlighted = option_list.highlighted + if highlighted is None or not (0 <= highlighted < len(self._agent_names)): + return + name = self._agent_names[highlighted] + + if name == self._default_agent: + new_default: str | None = None + success_msg = "Default cleared" + failure_msg = "Failed to clear default" + try: + ok = await asyncio.to_thread(clear_default_agent) + except Exception: + logger.exception("clear_default_agent raised unexpectedly") + ok = False + else: + new_default = name + success_msg = f"Default set to {name}" + failure_msg = "Failed to save default" + try: + ok = await asyncio.to_thread(save_default_agent, name) + except Exception: + logger.exception("save_default_agent raised unexpectedly for %r", name) + ok = False + + # The user may have dismissed the modal while the disk I/O was in + # flight; don't try to mutate widgets on an unmounted screen. + try: + help_widget = self.query_one("#agent-help", Static) + except NoMatches: + return + + if not ok: + help_widget.update( + Content.styled( + failure_msg, + f"bold {theme.get_theme_colors(self).error}", + ) + ) + self.set_timer(3.0, self._restore_help_text) + return + + # Apply the rebuild before mutating `_default_agent` so a failure + # leaves the picker visually consistent with on-disk state. + if not self._refresh_options(option_list, highlighted, new_default): + help_widget.update( + Content.styled( + "Failed to refresh agent list", + f"bold {theme.get_theme_colors(self).error}", + ) + ) + self.set_timer(3.0, self._restore_help_text) + return + + self._default_agent = new_default + help_widget.update(Content.styled(success_msg, "bold")) + self.set_timer(3.0, self._restore_help_text) + + def _refresh_options( + self, + option_list: OptionList, + highlighted: int, + new_default: str | None, + ) -> bool: + """Rebuild option labels in place to track the new `(default)` state. + + Builds the new option list with `new_default` applied first and + only mutates the live `OptionList` once construction succeeds. + Failure mid-build leaves the existing list intact, avoiding the + catastrophic empty-picker state where `clear_options()` had + succeeded but `add_options(...)` raised. + + `OptionList.clear_options()` resets the highlight to `None`, so + restore it explicitly to the previously selected row to avoid + appearing to lose the cursor after a Ctrl+S toggle. + + Args: + option_list: The live `OptionList` to rebuild in place. + highlighted: Index to restore as the highlighted row. + new_default: The agent name about to become the default + (`None` for clear). + + Passed in rather than read from `self._default_agent` + so the caller can decide whether to commit the new + default based on the rebuild's success. + + Returns: + `True` when the rebuild applied cleanly, `False` if option + construction or mounting raised. On `False`, the live + option list has been left untouched. + """ + previous_default = self._default_agent + self._default_agent = new_default + try: + new_options = self._build_options() + except Exception: + logger.exception("Failed to build new agent picker options") + self._default_agent = previous_default + return False + + try: + option_list.clear_options() + option_list.add_options(new_options) + except Exception: + logger.exception("Failed to mount rebuilt agent picker options") + self._default_agent = previous_default + # Best-effort restore of the prior options so the user is + # not left staring at an empty picker. + with contextlib.suppress(Exception): + option_list.clear_options() + option_list.add_options(self._build_options()) + return False + + if 0 <= highlighted < len(self._agent_names): + option_list.highlighted = highlighted + # `_default_agent` is set on entry so `_build_options` reflects + # the new state; revert it here so the caller can commit only + # after the rebuild has fully succeeded. + self._default_agent = previous_default + return True + + def _restore_help_text(self) -> None: + """Restore the default help text after a transient message. + + Guarded against the user having dismissed the modal during the + 3-second timer; without the guard, `query_one` would raise + `NoMatches` and Textual would surface it as a callback error. + """ + from textual.css.query import NoMatches + + try: + help_widget = self.query_one("#agent-help", Static) + except NoMatches: + return + help_widget.update(self._help_text(get_glyphs())) + + def _option_list(self) -> OptionList | None: + """Return the agent `OptionList`, or `None` when the screen is empty.""" + from textual.css.query import NoMatches + + try: + return self.query_one("#agent-options", OptionList) + except NoMatches: + return None diff --git a/libs/code/deepagents_code/tui/widgets/approval.py b/libs/code/deepagents_code/tui/widgets/approval.py new file mode 100644 index 0000000000..8104637faf --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/approval.py @@ -0,0 +1,738 @@ +"""Approval widget for HITL - using standard Textual patterns.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, ClassVar + +from textual.binding import Binding, BindingType +from textual.containers import Container, Vertical, VerticalScroll +from textual.content import Content +from textual.message import Message +from textual.widgets import Input, Static + +if TYPE_CHECKING: + import asyncio + + from textual import events + from textual.app import ComposeResult + +from deepagents_code import theme +from deepagents_code.config import ( + get_glyphs, + is_ascii_mode, +) +from deepagents_code.tui.widgets.tool_renderers import get_renderer +from deepagents_code.unicode_security import ( + check_url_safety, + detect_dangerous_unicode, + format_warning_detail, + iter_string_values, + looks_like_url_key, + render_with_unicode_markers, + strip_dangerous_unicode, + summarize_issues, +) + +logger = logging.getLogger(__name__) + +# Max length for truncated shell command display +_SHELL_COMMAND_TRUNCATE_LENGTH: int = 120 +# Max number of lines for truncated shell command display +_SHELL_COMMAND_TRUNCATE_LINES: int = 5 +_WARNING_PREVIEW_LIMIT: int = 3 +_WARNING_TEXT_TRUNCATE_LENGTH: int = 220 + + +def _is_command_too_long(command: str) -> bool: + """Whether a shell command exceeds the display thresholds (char or line). + + Args: + command: The shell command string to check. + + Returns: + `True` if the command is longer than `_SHELL_COMMAND_TRUNCATE_LENGTH` + characters or has more than `_SHELL_COMMAND_TRUNCATE_LINES` lines. + """ + if len(command) > _SHELL_COMMAND_TRUNCATE_LENGTH: + return True + return command.count("\n") + 1 > _SHELL_COMMAND_TRUNCATE_LINES + + +def _truncate_command(command: str) -> str: + """Truncate a shell command for compact display. + + Applies line truncation first (keeping at most `_SHELL_COMMAND_TRUNCATE_LINES` + lines), then character truncation, so multi-line commands collapse before + long single lines are cut. A single ellipsis is appended at the end. + + Args: + command: The shell command string to truncate. + + Returns: + The truncated command string, with a trailing ellipsis if any truncation + was applied; otherwise the original command unchanged. + """ + ellipsis = get_glyphs().ellipsis + lines = command.split("\n") + truncated = len(lines) > _SHELL_COMMAND_TRUNCATE_LINES + if truncated: + command = "\n".join(lines[:_SHELL_COMMAND_TRUNCATE_LINES]) + if len(command) > _SHELL_COMMAND_TRUNCATE_LENGTH: + command = command[:_SHELL_COMMAND_TRUNCATE_LENGTH] + truncated = True + return command + ellipsis if truncated else command + + +class ApprovalMenu(Container): + """Approval menu using standard Textual patterns. + + Key design decisions (following mistral-vibe reference): + - Container base class with compose() + - BINDINGS for key handling (not on_key) + - can_focus_children = False to prevent focus theft + - Simple Static widgets for options + - Standard message posting + - Tool-specific widgets via renderer pattern + """ + + can_focus = True + can_focus_children = False + + # CSS is in app.tcss - no DEFAULT_CSS needed + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", "Up", show=False), + Binding("k", "move_up", "Up", show=False), + Binding("down", "move_down", "Down", show=False), + Binding("j", "move_down", "Down", show=False), + Binding("enter", "select", "Select", show=False), + Binding("1", "select_position(0)", "Select first", show=False), + Binding("2", "select_position(1)", "Select second", show=False), + Binding("3", "select_position(2)", "Select third", show=False), + Binding("y", "select_approve", "Approve", show=False), + Binding("a", "select_auto", "Auto-approve", show=False), + Binding("n", "select_reject", "Reject", show=False), + Binding("e", "toggle_expand", "Expand command", show=False), + Binding("tab", "reject_with_reason", "Reject with feedback", show=False), + ] + + class Decided(Message): + """Message sent when user makes a decision.""" + + def __init__(self, decision: dict[str, str]) -> None: + """Initialize a Decided message with the user's decision. + + Args: + decision: Dictionary containing the decision type (e.g., 'approve', + 'reject', or 'auto_approve_all'). + """ + super().__init__() + self.decision = decision + + # Tools that don't need detailed info display (already shown in tool call) + _MINIMAL_TOOLS: ClassVar[frozenset[str]] = frozenset({"execute"}) + + def __init__( + self, + action_requests: list[dict[str, Any]] | dict[str, Any], + assistant_id: str | None = None, + id: str | None = None, # noqa: A002 # Textual widget constructor uses `id` parameter + *, + auto_mode_eligible: bool = True, + show_diff_line_numbers: bool = True, + **kwargs: Any, + ) -> None: + """Initialize the ApprovalMenu widget. + + Args: + action_requests: A single action request dictionary or a list of action + request dictionaries requiring approval. Each dictionary should + contain 'name' (tool name) and 'args' (tool arguments). + assistant_id: Optional assistant ID for resolving virtual paths in + file-operation previews. + id: Optional widget ID. Defaults to 'approval-menu'. + auto_mode_eligible: Whether Auto mode can be enabled in this session. + When `False` (e.g. a sandbox is active), the "Enable Auto for this + thread" option is not offered. + show_diff_line_numbers: Whether file-relative line numbers are shown + in diff previews. + **kwargs: Additional keyword arguments passed to the Container base class. + """ + super().__init__(id=id or "approval-menu", classes="approval-menu", **kwargs) + # Support both single request (legacy) and list of requests (batch) + if isinstance(action_requests, dict): + self._action_requests = [action_requests] + else: + self._action_requests = action_requests + + self._assistant_id = assistant_id + self._show_diff_line_numbers = show_diff_line_numbers + # For display purposes, get tool names + self._tool_names = [r.get("name", "unknown") for r in self._action_requests] + self._is_auto_fallback = any( + isinstance(request.get("description"), str) + and request["description"].startswith("Auto human fallback ") + for request in self._action_requests + ) + # Only offer the Auto option when it can actually be enabled. A live + # Auto fallback implies Auto is already active, so its "Switch to + # Manual" affordance is always shown regardless of eligibility. + self._show_auto_option = self._is_auto_fallback or auto_mode_eligible + # Built once: every input to `_build_options` is fixed for the widget's + # lifetime, so caching keeps `_num_options`/`_reject_index` from ever + # disagreeing with the option list they describe. + self._options = self._build_options() + self._num_options = len(self._options) + self._reject_index = self._num_options - 1 + self._selected = 0 + self._future: asyncio.Future[dict[str, str]] | None = None + self._option_widgets: list[Static] = [] + self._tool_info_container: Vertical | None = None + # Minimal display if ALL tools are shell-execution tools + self._is_minimal = all(name in self._MINIMAL_TOOLS for name in self._tool_names) + # For expandable shell commands + self._command_expanded = False + self._command_widget: Static | None = None + self._has_expandable_command = self._check_expandable_command() + self._security_warnings = self._collect_security_warnings() + # Free-text reject mode state (Tab on Reject opens an inline Input). + self._reason_input: Input | None = None + self._reason_input_active = False + self._help_widget: Static | None = None + + def set_future(self, future: asyncio.Future[dict[str, str]]) -> None: + """Set the future to resolve when user decides.""" + self._future = future + + def _check_expandable_command(self) -> bool: + """Check if there's a shell command that can be expanded. + + Returns: + Whether the single action request is an expandable shell command. + """ + if len(self._action_requests) != 1: + return False + req = self._action_requests[0] + if req.get("name", "") != "execute": + return False + command = str(req.get("args", {}).get("command", "")) + return _is_command_too_long(command) + + def _get_command_display(self, *, expanded: bool) -> Content: + """Get the command display content (truncated or full). + + Args: + expanded: Whether to show the full command or truncated version. + + Returns: + Styled Content for the command display. + + Raises: + RuntimeError: If called with empty action_requests. + """ + if not self._action_requests: + msg = "_get_command_display called with empty action_requests" + raise RuntimeError(msg) + req = self._action_requests[0] + command_raw = str(req.get("args", {}).get("command", "")) + command = strip_dangerous_unicode(command_raw) + issues = detect_dangerous_unicode(command_raw) + + too_long = _is_command_too_long(command) + if expanded or not too_long: + command_display = command + else: + command_display = _truncate_command(command) + + if not expanded and too_long: + display = Content.from_markup( + "[bold]$cmd[/bold] [dim](press 'e' to expand)[/dim]", + cmd=command_display, + ) + else: + display = Content.from_markup("[bold]$cmd[/bold]", cmd=command_display) + + if not issues: + return display + + raw_with_markers = render_with_unicode_markers(command_raw) + if not expanded and len(raw_with_markers) > _WARNING_TEXT_TRUNCATE_LENGTH: + raw_with_markers = ( + raw_with_markers[:_WARNING_TEXT_TRUNCATE_LENGTH] + get_glyphs().ellipsis + ) + + return Content.assemble( + display, + Content.from_markup( + "\n[yellow]Warning:[/yellow] hidden chars detected ($summary)\n" + "[dim]raw: $raw[/dim]", + summary=summarize_issues(issues), + raw=raw_with_markers, + ), + ) + + def compose(self) -> ComposeResult: + """Compose the widget with Static children. + + Layout: Tool info first (what's being approved), then options at bottom. + For bash/shell, skip tool info since it's already shown in tool call. + + Yields: + Widgets for title, tool info, options, and help text. + """ + # Title - show count if multiple tools + count = len(self._action_requests) + if count == 1: + title = Content.from_markup( + ">>> $name Requires Approval <<<", name=self._tool_names[0] + ) + else: + title = Content(f">>> {count} Tool Calls Require Approval <<<") + yield Static(title, classes="approval-title") + + if self._security_warnings: + parts: list[Content] = [ + Content.from_markup( + "[yellow]Warning:[/yellow] Potentially deceptive text" + ), + ] + parts.extend( + Content.from_markup("\n[dim]- $w[/dim]", w=warning) + for warning in self._security_warnings[:_WARNING_PREVIEW_LIMIT] + ) + if len(self._security_warnings) > _WARNING_PREVIEW_LIMIT: + remaining = len(self._security_warnings) - _WARNING_PREVIEW_LIMIT + parts.append(Content.styled(f"\n- +{remaining} more warning(s)", "dim")) + yield Static( + Content.assemble(*parts), + classes="approval-security-warning", + ) + + # For shell commands, show the command (expandable if long) + if self._is_minimal and len(self._action_requests) == 1: + self._command_widget = Static( + self._get_command_display(expanded=self._command_expanded), + classes="approval-command", + ) + yield self._command_widget + + # Tool info - only for non-minimal tools (diffs, writes show actual content) + if not self._is_minimal: + with VerticalScroll(classes="tool-info-scroll"): + self._tool_info_container = Vertical(classes="tool-info-container") + yield self._tool_info_container + + # Separator between tool details and options + glyphs = get_glyphs() + yield Static(glyphs.box_horizontal * 40, classes="approval-separator") + + # Options container at bottom + with Container(classes="approval-options-container"): + # Options - one Static widget per visible option + for i in range(self._num_options): # noqa: B007 # Loop variable unused - iterating for count only + widget = Static("", classes="approval-option") + self._option_widgets.append(widget) + yield widget + + # Free-text reject reason input (hidden until activated via Tab) + self._reason_input = Input( + placeholder="Reason (Enter to submit, Esc to cancel)", + classes="approval-reason-input", + id="approval-reason-input", + # Textual selects all on focus by default, which would make the next + # keystroke replace the reason instead of extending it whenever + # `on_focus` hands focus back after it drifted to the menu. + select_on_focus=False, + ) + self._reason_input.display = False + yield self._reason_input + + # Help text at the very bottom + self._help_widget = Static(self._compose_help_text(), classes="approval-help") + yield self._help_widget + + def _compose_help_text(self) -> str: + """Build the help-line content for the current mode. + + Returns: + Help text for either the normal menu or the reject-reason input. + """ + glyphs = get_glyphs() + if self._reason_input_active: + return ( + f"Enter submit {glyphs.bullet} Esc cancel {glyphs.bullet} " + "leave blank to reject without a reason" + ) + quick_keys = "y/a/n" if self._show_auto_option else "y/n" + # The Tab hint shows from every option, not just Reject: the quick keys + # are the fast path, so a hint gated on the Reject row stays invisible to + # the users most likely to want it. `Tab` moves the cursor to Reject + # itself, so the hint is live wherever it is read. + help_parts = [ + ( + f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate " + f"{glyphs.bullet} Enter select {glyphs.bullet} {quick_keys} quick keys" + ), + "Tab reject with feedback", + "Esc reject", + ] + help_text = f" {glyphs.bullet} ".join(help_parts) + if self._has_expandable_command: + help_text += f" {glyphs.bullet} e expand" + return help_text + + async def on_mount(self) -> None: + """Focus self on mount and update tool info.""" + if is_ascii_mode(): + colors = theme.get_theme_colors(self) + self.styles.border = ("ascii", colors.warning) + + if not self._is_minimal: + await self._update_tool_info() + self._update_options() + self.focus() + + async def _update_tool_info(self) -> None: + """Mount the tool-specific approval widgets for all tools.""" + if not self._tool_info_container: + return + + # Clear existing content + await self._tool_info_container.remove_children() + + # Mount info for each tool + for i, action_request in enumerate(self._action_requests): + tool_name = action_request.get("name", "unknown") + tool_args = action_request.get("args", {}) + + # Add tool header if multiple tools + if len(self._action_requests) > 1: + header = Static( + Content.from_markup( + "[bold]$num. $name[/bold]", + num=i + 1, + name=tool_name, + ) + ) + await self._tool_info_container.mount(header) + + # Show description if present + description = action_request.get("description") + if description: + desc_widget = Static( + Content.from_markup("[dim]$desc[/dim]", desc=description), + classes="approval-description", + ) + await self._tool_info_container.mount(desc_widget) + + # Get the appropriate renderer for this tool + renderer = get_renderer(tool_name) + widget_class, data = renderer.get_approval_widget( + tool_args, assistant_id=self._assistant_id + ) + if "show_numbers" in data: + data["show_numbers"] = ( + bool(data["show_numbers"]) and self._show_diff_line_numbers + ) + approval_widget = widget_class(data) + await self._tool_info_container.mount(approval_widget) + + def _build_options(self) -> list[tuple[str, str]]: + """Build the visible options as `(label, decision_type)` pairs. + + The Auto option is omitted unless Auto can actually be enabled + (`_show_auto_option`), so it is never suggested outside the local TUI. + Labels are unnumbered; `_update_options` prefixes the display number. + + Returns: + Ordered `(label, decision_type)` pairs for the visible options. + """ + count = len(self._action_requests) + approve = "Approve (y)" if count == 1 else f"Approve all {count} (y)" + reject = "Reject (n)" if count == 1 else f"Reject all {count} (n)" + options: list[tuple[str, str]] = [(approve, "approve")] + if self._show_auto_option: + if self._is_auto_fallback: + options.append(("Switch to Manual (a)", "switch_manual")) + else: + options.append(("Enable Auto for this thread (a)", "auto_approve_all")) + options.append((reject, "reject")) + return options + + def _update_options(self) -> None: + """Update option widgets based on selection.""" + for i, ((text, _decision), widget) in enumerate( + zip(self._options, self._option_widgets, strict=True) + ): + cursor = f"{get_glyphs().cursor} " if i == self._selected else " " + widget.update(f"{cursor}{i + 1}. {text}") + + # Update classes + widget.remove_class("approval-option-selected") + if i == self._selected: + widget.add_class("approval-option-selected") + if self._help_widget is not None: + self._help_widget.update(self._compose_help_text()) + + def action_move_up(self) -> None: + """Move selection up.""" + if self._reason_input_active: + return + self._selected = (self._selected - 1) % self._num_options + self._update_options() + + def action_move_down(self) -> None: + """Move selection down.""" + if self._reason_input_active: + return + self._selected = (self._selected + 1) % self._num_options + self._update_options() + + def action_select(self) -> None: + """Select the current option, or submit an open reason field. + + While the reason field is open the footer reads `Enter submit`, so an + Enter that reaches the menu instead of the `Input` submits the typed + reason rather than falling through to a reason-less reject that would + discard it. + """ + if self._reason_input_active and self._reason_input is not None: + self._submit_reason(self._reason_input.value) + return + self._handle_selection(self._selected) + + def action_select_position(self, position: int) -> None: + """Submit the option at a display position (0-indexed). + + Backs the numeric quick keys, which map key `1`/`2`/`3` to position + `0`/`1`/`2`. Positions outside the visible options are ignored, so + when the Auto option is hidden (only positions 0-1 exist) the `3` key + (position 2) is a no-op and key `2` (position 1) selects Reject rather + than Auto. + + Args: + position: Zero-based index of the visible option to submit. + """ + if not 0 <= position < self._num_options: + return + self._handle_selection(position) + + def action_select_approve(self) -> None: + """Submit approve option.""" + self._handle_selection(0) + + def action_select_auto(self) -> None: + """Submit the middle option (Auto, or Switch to Manual in a fallback). + + No-op when the option is hidden, since Auto cannot be enabled. When + shown it is always the second option (index 1): "Enable Auto" normally, + or "Switch to Manual" during a live Auto fallback. + """ + if not self._show_auto_option: + return + self._handle_selection(1) + + def action_select_reject(self) -> None: + """Submit reject option. + + When the free-text reject input is open, the first press cancels the + input instead of rejecting, so the user can back out without losing + their unsubmitted reason. + """ + if self._reason_input_active: + self._exit_reason_input_mode() + return + self._handle_selection(self._reject_index) + + def action_toggle_expand(self) -> None: + """Toggle shell command expansion.""" + if not self._has_expandable_command or not self._command_widget: + return + self._command_expanded = not self._command_expanded + self._command_widget.update( + self._get_command_display(expanded=self._command_expanded) + ) + + def _handle_selection( + self, option: int, *, reject_message: str | None = None + ) -> None: + """Handle the selected option. + + Args: + option: Index of the chosen visible option. Maps to a decision type + via the current option layout (which omits Auto when hidden). + reject_message: Optional free-text reason. Only attached when a + non-empty reason is submitted via `on_input_submitted` (the + free-text reject flow opened by `action_reject_with_reason`). + """ + # Every quick key and Enter path resolves the approval through here, and + # the reason-submit callers all clear `_reason_input_active` before + # calling. So reaching this with the flag still set means a key was read + # as a menu command while the reason field was open and holding the + # user's half-typed rejection - letting it through would resolve (and for + # `y`/`a`/`1` *approve*) the very call being rejected. `on_focus` keeps + # the field focused so this should be unreachable; guard anyway, since + # focus is deferred and `Widget.focus()` swallows `NoScreen`. + if self._reason_input_active: + logger.warning( + "option %d reached _handle_selection while the reject reason " + "input was active; ignoring (focus desync)", + option, + ) + return + + decision_type = self._options[option][1] + decision: dict[str, str] = {"type": decision_type} + if decision_type == "reject" and reject_message: + decision["message"] = reject_message + + self.display = False + + # Resolve the future + if self._future and not self._future.done(): + self._future.set_result(decision) + + # Post message + self.post_message(self.Decided(decision)) + + def action_reject_with_reason(self) -> None: + """Enter free-text reject mode from any option. + + Moves the cursor to Reject first, so the highlighted option always + matches the decision the input will submit; it can only ever produce a + reject, never an approval. Reveals the inline `Input` composed (hidden) + by `compose()` and focuses it; its value is emitted verbatim on submit, + leaving any model-facing framing to the caller. + + Note: + `_frame_reject_reason` in `deepagents_code.tui.textual_adapter` + prefixes the raw text before it becomes `RejectDecision.message`. + """ + if self._reason_input_active: + # Tab is advertised unconditionally, so a second press must not wipe + # a reason already being typed - hence returning before the + # `value = ""` reset below rather than re-entering the field. + return + if self._reason_input is None: + # Lifecycle bug: Tab fired before `compose()` populated the Input ref. + # Logging makes the silent no-op debuggable instead of invisible. + # Doubles as the guard for `_update_options`'s `strict=True` zip: + # `compose()` fills `_option_widgets` before assigning + # `_reason_input`, so a non-None ref implies the widget list exists. + # Keep that order if these yields are ever rearranged. + logger.warning( + "action_reject_with_reason: _reason_input is None; menu may not " + "be mounted yet" + ) + return + self._reason_input_active = True + self._selected = self._reject_index + self._reason_input.value = "" + self._reason_input.display = True + self._update_options() + self._reason_input.focus() + + def _submit_reason(self, raw_reason: str) -> None: + """Submit a reject carrying the typed reason. + + Clears `_reason_input_active` before deciding, both so `on_focus` stops + bouncing focus into the field and so `_handle_selection`'s desync guard + recognizes this as the one legitimate caller during reason mode. + + Args: + raw_reason: Unstripped reason field contents. Whitespace-only text + submits a bare reject, matching a blank field. + """ + reason = raw_reason.strip() + self._reason_input_active = False + self._handle_selection(self._reject_index, reject_message=reason or None) + + def _exit_reason_input_mode(self) -> None: + """Close the reason input and return focus to the menu without deciding. + + Backs the Esc/`n` cancel path, so it must leave the user on the menu. + """ + if not self._reason_input_active or self._reason_input is None: + return + # Order matters: clearing the flag before `self.focus()` is what stops + # `on_focus` bouncing focus straight back into the field being closed. + # Reversing these two would trap the user in a cancelled reason field. + self._reason_input_active = False + self._reason_input.display = False + if self._help_widget is not None: + self._help_widget.update(self._compose_help_text()) + self.focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Submit the reject decision with the typed reason (if any).""" + # Stop before the guard so a stray submit (e.g. queued after Esc closed + # the input) cannot bubble to a parent and be re-interpreted, and so a + # foreign Input's submission is never misrouted through this handler. + if event.input is not self._reason_input: + return + event.stop() + if not self._reason_input_active: + logger.debug( + "on_input_submitted fired with inactive reason input; dropping" + ) + return + self._submit_reason(event.value) + + def _collect_security_warnings(self) -> list[str]: + """Collect warning strings for suspicious Unicode and URL values. + + Recursively inspects all nested string values in action arguments. + + Returns: + Warning strings for the current action request batch. + """ + warnings: list[str] = [] + for action_request in self._action_requests: + tool_name = str(action_request.get("name", "unknown")) + args = action_request.get("args", {}) + if not isinstance(args, dict): + continue + for arg_path, text in iter_string_values(args): + issues = detect_dangerous_unicode(text) + if issues: + warnings.append( + f"{tool_name}.{arg_path}: hidden Unicode " + f"({summarize_issues(issues)})" + ) + if looks_like_url_key(arg_path): + result = check_url_safety(text) + if result.safe: + continue + detail = format_warning_detail(result.warnings) + if result.decoded_domain: + detail = f"{detail}; decoded host: {result.decoded_domain}" + warnings.append(f"{tool_name}.{arg_path}: {detail}") + return warnings + + def on_blur(self, event: events.Blur) -> None: # noqa: ARG002 # Textual event handler signature + """Re-focus on blur to keep focus trapped until decision is made. + + Skipped while the free-text reject input is active so the `Input` + widget can keep keyboard focus. + """ + if self._reason_input_active: + return + self.call_after_refresh(self.focus) + + def on_focus(self, event: events.Focus) -> None: # noqa: ARG002 # Textual event handler signature + """Hand focus to the reason input while it is open. + + `on_blur` deliberately stops re-trapping focus during reason mode so the + `Input` can hold it, which leaves the reverse direction unhandled: a + click on the menu body focuses the menu and strands an open reason field, + where quick keys read as menu commands instead of text. Bouncing focus + back keeps "field open" and "field focused" the same state. + + Cannot ping-pong: this focuses the `Input`, whose gain of focus blurs the + menu, and `on_blur` above returns early during reason mode. Nor does it + trap the user - `_exit_reason_input_mode` clears the flag before moving + focus, so a cancelled field is not re-entered. + """ + if self._reason_input_active and self._reason_input is not None: + self.call_after_refresh(self._reason_input.focus) diff --git a/libs/code/deepagents_code/tui/widgets/ask_user.py b/libs/code/deepagents_code/tui/widgets/ask_user.py new file mode 100644 index 0000000000..80f7e20953 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/ask_user.py @@ -0,0 +1,542 @@ +"""Ask user widget for interactive questions during agent execution.""" + +from __future__ import annotations + +import logging +import re +from typing import TYPE_CHECKING, Any, ClassVar, Literal + +from textual.binding import Binding, BindingType +from textual.containers import Container, Vertical +from textual.message import Message +from textual.widgets import Markdown, Static + +if TYPE_CHECKING: + import asyncio + + from textual import events + from textual.app import ComposeResult + + from deepagents_code._ask_user_types import ( + AskUserWidgetResult, + Choice, + Question, + ) + +from deepagents_code.config import get_glyphs +from deepagents_code.editor import editor_display_name +from deepagents_code.tui.widgets._inline_prompt import ( + InlinePromptCompletion, + InlinePromptOption, + InlinePromptTextArea, + apply_inline_prompt_border, + newline_hint, + stop_inline_prompt_blur, +) + +OTHER_CHOICE_LABEL = "Other (type your answer)" +MISSING_ANSWER_TOAST = "Please provide an answer to all questions before continuing." +logger = logging.getLogger(__name__) + +_TRAILING_ANNOTATION_RE = re.compile( + # \u2013 = en-dash, \u2014 = em-dash. + r""" + \s* + (?: + [-\u2013\u2014]\s*(?:optional|required) + | \((?:optional|required)[.!?]?\) + | \[(?:optional|required)[.!?]?\] + ) + [.!?]* + \s*$ + """, + re.IGNORECASE | re.VERBOSE, +) +"""Strip LLM-appended trailing annotations like ' - optional', ' (optional)', +or ' [required]' from question text before rendering. + +Defense-in-depth alongside the instruction in `ASK_USER_TOOL_DESCRIPTION` +(`ask_user.py`). The UI already renders a `*(required)*` marker based on the +`required` field, so any LLM-authored duplicate is redundant noise.""" + + +class AskUserTextArea(InlinePromptTextArea): + """Free-form answer input for ask-user questions. + + Adds one behavior over the shared base: when the cursor is on the first or + last line of a `multiple_choice` question, Up/Down are handed back to the + enclosing choice list instead of moving the text cursor. + """ + + class Submitted(InlinePromptTextArea.Submitted): + """Posted when the user presses Enter to submit an ask-user answer.""" + + async def _on_key(self, event: events.Key) -> None: + if event.key in {"up", "down"}: + cursor_location = self.cursor_location + at_top = self.get_cursor_up_location() == cursor_location + at_bottom = self.get_cursor_down_location() == cursor_location + if (event.key == "up" and at_top) or (event.key == "down" and at_bottom): + question = self._find_question_widget() + if question is not None and question._q_type == "multiple_choice": + event.prevent_default() + event.stop() + if event.key == "up": + question.action_move_up() + else: + question.action_move_down() + return + await super()._on_key(event) + + def _find_question_widget(self) -> _QuestionWidget | None: + """Walk up to find the enclosing `_QuestionWidget`, if any. + + Returns: + The enclosing `_QuestionWidget` ancestor, or `None` if not found. + """ + node: Any = self.parent + while node is not None: + if isinstance(node, _QuestionWidget): + return node + node = node.parent + return None + + +class AskUserMenu(Container): + """Interactive widget for asking the user questions. + + Supports text input and multiple choice questions. Multiple choice + questions always include an "Other" option for free-form input. + """ + + can_focus = True + can_focus_children = True + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "cancel", "Cancel", show=False), + Binding("tab", "next_question", "Next question", show=False, priority=True), + ] + + class Answered(Message): + """Message sent when user submits all answers.""" + + def __init__(self, answers: list[str]) -> None: # noqa: D107 + super().__init__() + self.answers = answers + + class Cancelled(Message): + """Message sent when user cancels the ask_user prompt.""" + + def __init__(self) -> None: # noqa: D107 + super().__init__() + + def __init__( # noqa: D107 + self, + questions: list[Question], + id: str | None = None, # noqa: A002 + **kwargs: Any, + ) -> None: + super().__init__( + id=id or "ask-user-menu", + classes="inline-prompt ask-user-menu", + **kwargs, + ) + self._questions = questions + self._answers: list[str] = [""] * len(questions) + self._current_question = 0 + self._confirmed: list[bool] = [False] * len(questions) + self._completion: InlinePromptCompletion[AskUserWidgetResult] = ( + InlinePromptCompletion() + ) + self._question_widgets: list[_QuestionWidget] = [] + self._help_widget: Static | None = None + + def set_future(self, future: asyncio.Future[AskUserWidgetResult]) -> None: + """Set the future to resolve when user answers.""" + self._completion.set_future(future) + + def compose(self) -> ComposeResult: # noqa: D102 + glyphs = get_glyphs() + count = len(self._questions) + if count == 1: + title = "Agent has a question for you" + else: + title = f"Agent has {count} Questions for you" + yield Static( + f"{glyphs.cursor} {title}", + classes="inline-prompt-title ask-user-title", + ) + yield Static("") + + with Vertical(classes="ask-user-questions"): + for i, q in enumerate(self._questions): + qw = _QuestionWidget(q, index=i, show_number=count > 1) + self._question_widgets.append(qw) + yield qw + + yield Static("") + self._help_widget = Static( + self._render_help(), + classes="inline-prompt-help ask-user-help", + ) + yield self._help_widget + + def _render_help(self) -> str: + """Build the footer hint text for the current menu state. + + The `Ctrl+X` editor hint is included only while one of this menu's text + areas holds focus, matching the routing in `App.action_open_editor`. + + Returns: + The bullet-joined footer hint string. + """ + glyphs = get_glyphs() + parts = [ + f"{glyphs.arrow_up}/{glyphs.arrow_down} Select", + "Enter to continue", + newline_hint(), + ] + if self._show_editor_hint(): + editor = editor_display_name() + parts.append( + f"Ctrl+X edit in {editor}" + if editor is not None + else "Ctrl+X external editor" + ) + if len(self._questions) > 1: + parts.append("Tab/Shift+Tab switch question") + parts.append("Esc to cancel") + return f" {glyphs.bullet} ".join(parts) + + def _show_editor_hint(self) -> bool: + """Whether `ctrl+x` would currently open one of this menu's text areas. + + `App.action_open_editor` routes `ctrl+x` to an ask-user text area only + when one is focused, and otherwise falls through to the chat input. + A visible-but-unfocused field therefore must not advertise the + shortcut: pressing it would open the user's chat draft instead. The + conditions here mirror `App._focused_ask_user_editor`. + + Returns: + `True` if a text area belonging to this menu holds focus. + """ + focused = self.app.focused + return ( + isinstance(focused, AskUserTextArea) + and self in focused.ancestors + and focused.is_attached + and focused.display + and focused.visible + ) + + def _update_help(self) -> None: + """Refresh the footer hint after a focus or field-visibility change.""" + if self._help_widget is not None: + self._help_widget.update(self._render_help()) + + async def on_mount(self) -> None: # noqa: D102 + apply_inline_prompt_border(self) + self._set_active_question(0) + + def focus_active(self) -> None: + """Focus the current active question's input.""" + self._set_active_question(self._current_question) + + def on_ask_user_text_area_submitted(self, event: AskUserTextArea.Submitted) -> None: + """Confirm the question whose text area was submitted.""" + event.stop() + for qw in self._question_widgets: + if (qw._text_input and qw._text_input is event.text_area) or ( + qw._other_input and qw._other_input is event.text_area + ): + answer = qw.get_answer() + if answer.strip() or not qw._required: + self.confirm_and_advance(qw._index) + else: + self.app.notify( + MISSING_ANSWER_TOAST, + severity="warning", + markup=False, + ) + return + + def confirm_and_advance(self, index: int) -> None: + """Confirm the answer at `index` and advance to the next question.""" + self._answers[index] = self._question_widgets[index].get_answer() + self._confirmed[index] = True + + # Find next unconfirmed question. + for i in range(index + 1, len(self._question_widgets)): + if not self._confirmed[i]: + self._set_active_question(i) + return + + # All confirmed — collect final answers and submit. + for i, qw in enumerate(self._question_widgets): + self._answers[i] = qw.get_answer() + if all( + a.strip() or not self._question_widgets[i]._required + for i, a in enumerate(self._answers) + ): + self._submit() + return + + # Edge case: a confirmed required text field was left empty + # (shouldn't happen normally). Re-open it. + for i, a in enumerate(self._answers): + if not a.strip() and self._question_widgets[i]._required: + self._confirmed[i] = False + self._set_active_question(i) + return + + def _set_active_question(self, index: int) -> None: + """Update the visual indicator and focus for the active question.""" + self._highlight_question(index) + self._question_widgets[index].focus_input() + + def _highlight_question(self, index: int) -> None: + """Highlight `index` and dim the rest without changing focus.""" + self._current_question = index + for i, qw in enumerate(self._question_widgets): + if i == index: + qw.add_class("ask-user-question-active") + qw.remove_class("ask-user-question-inactive") + else: + qw.remove_class("ask-user-question-active") + qw.add_class("ask-user-question-inactive") + + def _submit(self) -> None: + result: AskUserWidgetResult = { + "type": "answered", + "answers": self._answers, + } + if self._completion.resolve(result): + self.post_message(self.Answered(self._answers)) + + def action_next_question(self) -> None: + """Navigate to the next question without confirming.""" + if self._current_question < len(self._question_widgets) - 1: + self._set_active_question(self._current_question + 1) + + def action_previous_question(self) -> None: + """Navigate to the previous question without confirming.""" + if self._current_question > 0: + self._set_active_question(self._current_question - 1) + + def action_cancel(self) -> None: # noqa: D102 + if self._completion.resolve({"type": "cancelled"}): + self.post_message(self.Cancelled()) + + def on_descendant_focus(self, event: events.DescendantFocus) -> None: + """Keep the active-question highlight in sync with focus. + + A mouse click moves focus into another question's text input, or onto + the question container itself for multiple-choice (whose choices are + not individually focusable), without going through + `_set_active_question`, which would otherwise leave the highlight on + the previously active question. Sync the highlight to the focused + question so exactly one question is ever active. Focus is not moved + here, so the widget the user clicked keeps focus. + """ + node: Any = event.widget + while node is not None and not isinstance(node, _QuestionWidget): + node = node.parent + if node is not None and node._index != self._current_question: + self._highlight_question(node._index) + # Every focus change inside the menu can flip whether ctrl+x routes + # here, including clicks that land on a question container rather than + # its text area, so refresh regardless of which question is active. + self._update_help() + + def on_descendant_blur(self, event: events.DescendantBlur) -> None: + """Retract the `Ctrl+X` hint when focus leaves a text area.""" + del event # Unused: the hint is recomputed from current focus. + self._update_help() + + def on_blur(self, event: events.Blur) -> None: # noqa: PLR6301 # Textual event handler + """Prevent blur from propagating and dismissing the menu.""" + stop_inline_prompt_blur(event) + + +class _ChoiceOption(InlinePromptOption): + """A single selectable ask-user choice option.""" + + @property + def _unselected_marker(self) -> str: + return get_glyphs().bullet + + def __init__( + self, text: str, index: int, *, selected: bool = False, **kwargs: Any + ) -> None: + """Initialize an ask-user choice option.""" + super().__init__( + text, + index, + selected=selected, + classes="ask-user-choice", + **kwargs, + ) + + +class _QuestionWidget(Vertical): + """Widget for a single question (text or multiple choice).""" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", "Up", show=False), + Binding("k", "move_up", "Up", show=False), + Binding("down", "move_down", "Down", show=False), + Binding("j", "move_down", "Down", show=False), + Binding("enter", "select_or_submit", "Select", show=False), + ] + + can_focus = True + can_focus_children = True + + def __init__( + self, + question: Question, + index: int, + *, + show_number: bool = True, + **kwargs: Any, + ) -> None: + super().__init__(classes="ask-user-question", **kwargs) + question_type = question.get("type", "text") + self._question: Question = question + self._index: int = index + self._show_number = show_number + self._q_type: Literal["text", "multiple_choice"] = ( + "multiple_choice" if question_type == "multiple_choice" else "text" + ) + self._choices: list[Choice] = question.get("choices", []) + self._required: bool = question.get("required", True) + self._choice_widgets: list[_ChoiceOption] = [] + self._selected_choice: int = 0 + self._text_input: AskUserTextArea | None = None + self._other_input: AskUserTextArea | None = None + self._is_other_selected: bool = False + + def compose(self) -> ComposeResult: + q_text = _TRAILING_ANNOTATION_RE.sub("", self._question.get("question", "")) + prefix = f"**{self._index + 1}.** " if self._show_number else "" + suffix = " *(required)*" if self._required else "" + # q_text is agent-authored; rendered as markdown intentionally so + # agents can use inline formatting, links, and code spans in questions. + yield Markdown(f"{prefix}{q_text}{suffix}", classes="ask-user-question-text") + + if self._q_type == "multiple_choice" and self._choices: + for i, choice in enumerate(self._choices): + label = choice.get("value", str(choice)) + cw = _ChoiceOption(label, index=i, selected=(i == 0)) + self._choice_widgets.append(cw) + yield cw + + other_cw = _ChoiceOption(OTHER_CHOICE_LABEL, index=len(self._choices)) + self._choice_widgets.append(other_cw) + yield other_cw + + self._other_input = AskUserTextArea(classes="ask-user-other-input") + self._other_input.display = False + yield self._other_input + else: + self._text_input = AskUserTextArea(classes="ask-user-text-input") + yield self._text_input + + def focus_input(self) -> None: + """Focus the appropriate input for this question.""" + if self._text_input: + self._text_input.focus() + elif self._is_other_selected and self._other_input: + self._other_input.focus() + elif self._choice_widgets: + self.focus() + + def get_answer(self) -> str: + """Return the current answer text for this question. + + Collapsed-paste placeholders are expanded so the agent receives the + full pasted content, not the compact `[Pasted text #N]` token. + """ + if self._q_type == "text" or not self._choices: + return self._text_input.submitted_value if self._text_input else "" + + if self._is_other_selected and self._other_input: + return self._other_input.submitted_value + + if self._choice_widgets and self._selected_choice < len(self._choices): + return self._choices[self._selected_choice].get("value", "") + + return "" + + def action_move_up(self) -> None: + """Move selection up in the choice list.""" + if self._q_type != "multiple_choice" or not self._choice_widgets: + return + if ( + self._is_other_selected + and self._other_input + and self._other_input.has_focus + ): + # Jump directly to the last real choice instead of requiring + # two presses (one to defocus, one to navigate). + self._selected_choice = max(0, len(self._choices) - 1) + self._update_choice_selection() + self.focus() + return + old = self._selected_choice + self._selected_choice = max(0, self._selected_choice - 1) + if old != self._selected_choice: + self._update_choice_selection() + + def action_move_down(self) -> None: + """Move selection down in the choice list.""" + if self._q_type != "multiple_choice" or not self._choice_widgets: + return + max_idx = len(self._choice_widgets) - 1 + old = self._selected_choice + self._selected_choice = min(max_idx, self._selected_choice + 1) + if old != self._selected_choice: + self._update_choice_selection() + + def action_select_or_submit(self) -> None: + """Confirm current choice or open the Other input.""" + if self._q_type == "multiple_choice" and self._choice_widgets: + is_other = self._selected_choice == len(self._choices) + if is_other: + self._is_other_selected = True + if self._other_input: + self._other_input.display = True + self._other_input.focus() + else: + self._is_other_selected = False + if self._other_input: + self._other_input.display = False + menu = self._find_menu() + if menu is not None: + menu.confirm_and_advance(self._index) + + def _find_menu(self) -> AskUserMenu | None: + node: Any = self.parent + while node is not None: + if isinstance(node, AskUserMenu): + return node + node = node.parent + logger.warning( + "Failed to find AskUserMenu ancestor for question index %d", + self._index, + ) + return None + + def _update_choice_selection(self) -> None: + for i, cw in enumerate(self._choice_widgets): + if i == self._selected_choice: + cw.select() + else: + cw.deselect() + + is_other = self._selected_choice == len(self._choices) + self._is_other_selected = is_other + if self._other_input: + self._other_input.display = is_other + if is_other: + self._other_input.focus() diff --git a/libs/code/deepagents_code/tui/widgets/auth.py b/libs/code/deepagents_code/tui/widgets/auth.py new file mode 100644 index 0000000000..1b46e35cf6 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/auth.py @@ -0,0 +1,1997 @@ +"""TUI screens for managing stored model-provider credentials. + +`AuthPromptScreen` accepts an API key for a single provider, persists it via +`auth_store`, and is the sole place that deletes existing credentials (after +a `DeleteCredentialConfirmScreen` confirmation). `AuthManagerScreen` lists +known providers and routes the user into the prompt; it does not delete +directly. Both are reachable via the `/auth` slash command. + +Security notes: + +- Inputs are rendered with `password=True` so the key is never echoed to + the terminal. +- This module never logs the key value, never includes it in `notify()` + payloads, and never round-trips it through Rich markup. Callers that + introduce new logging here must do the same. +""" + +from __future__ import annotations + +import logging +import os +from enum import StrEnum +from functools import partial +from typing import TYPE_CHECKING, ClassVar, NamedTuple +from urllib.parse import urlsplit + +from textual.binding import Binding, BindingType +from textual.color import Color as TColor +from textual.containers import Vertical +from textual.content import Content +from textual.message import Message +from textual.screen import ModalScreen +from textual.style import Style as TStyle +from textual.widgets import Input, OptionList, RadioButton, RadioSet, Static +from textual.widgets.option_list import Option, OptionDoesNotExist + +if TYPE_CHECKING: + from textual.app import ComposeResult + from textual.events import Click, MouseMove + + from deepagents_code.tui.widgets.codex_auth import CodexSignedInAction + +from deepagents_code import auth_store, theme +from deepagents_code.auth_display import format_auth_badge +from deepagents_code.config import ( + LANGSMITH_EU_ENDPOINT, + LANGSMITH_US_ENDPOINT, + apply_stored_langsmith_auth, + get_glyphs, + is_ascii_mode, + is_http_url, + normalize_langsmith_endpoint, +) +from deepagents_code.model_config import ( + CODEX_PROVIDER, + PROVIDER_API_KEY_ENV, + PROVIDERS_DOCS_URL as _PROVIDERS_DOCS_URL, + SERVICE_API_KEY_ENV, + ModelConfig, + ProviderAuthSource, + ProviderAuthState, + ProviderAuthStatus, + clear_caches, + get_available_models, + get_base_url_env_var, + get_base_url_env_vars, + get_credential_env_var, + get_default_base_url_env, + get_provider_auth_status, + get_service_auth_status, + is_langsmith, + is_service, + resolved_env_var_name, +) +from deepagents_code.tui.widgets._links import open_style_link + +logger = logging.getLogger(__name__) + + +CONFIGURATION_DOCS_URL = ( + "https://docs.langchain.com/oss/python/deepagents/code/configuration" +) + + +class Region(StrEnum): + """The closed set of LangSmith region selections in the `/auth` prompt.""" + + US = "us" + EU = "eu" + CUSTOM = "custom" + + +class _RegionSpec(NamedTuple): + """One region row: its enum, radio widget id, SaaS endpoint, and label. + + The single source of truth for the region <-> radio-id <-> endpoint mapping, + so `_REGION_BY_RADIO_ID`, `compose`'s radio buttons, `_region_for_endpoint`, + and `_resolve_langsmith_endpoint` can't drift apart. `endpoint` is the + canonical URL stored for a fixed-SaaS region (`""` for the US default, which + clears the stored endpoint); `Region.CUSTOM` carries `""` here because the + user supplies its endpoint at prompt time, so it is handled explicitly. + """ + + region: Region + radio_id: str + endpoint: str + label: str + + +_REGION_SPECS: tuple[_RegionSpec, ...] = ( + _RegionSpec(Region.US, "auth-region-us", "", "United States (default)"), + _RegionSpec(Region.EU, "auth-region-eu", LANGSMITH_EU_ENDPOINT, "Europe"), + _RegionSpec( + Region.CUSTOM, "auth-region-custom", "", "Custom (self-hosted / proxy)" + ), +) + +_REGION_BY_RADIO_ID: dict[str, Region] = { + spec.radio_id: spec.region for spec in _REGION_SPECS +} +"""Map each region radio's widget id to its region, avoiding string-munging.""" + +_ENDPOINT_BY_REGION: dict[Region, str] = { + spec.region: spec.endpoint for spec in _REGION_SPECS +} +"""Canonical endpoint stored for each fixed-SaaS region (Custom is dynamic).""" + + +class ResolvedEndpoint(NamedTuple): + """Outcome of resolving the region selector to an endpoint to persist. + + `endpoint` is the canonical URL to store (empty for the US SaaS default); + `error` is a user-facing message when a Custom URL is missing or malformed, + in which case `endpoint` is empty and nothing should be saved. + """ + + endpoint: str + error: str | None + + +def _region_for_endpoint(base_url: str) -> Region: + """Map a stored LangSmith endpoint to its `/auth` region selection. + + Args: + base_url: The stored endpoint, or an empty string for the SaaS default. + + Returns: + `Region.US` (blank, or the canonical US SaaS URL that the CLI + `--base-url us` shorthand stores), `Region.EU` (the EU SaaS URL), or + `Region.CUSTOM` (any other endpoint, e.g. self-hosted). + """ + if not base_url or base_url == LANGSMITH_US_ENDPOINT: + return Region.US + for spec in _REGION_SPECS: + if spec.endpoint and base_url == spec.endpoint: + return spec.region + return Region.CUSTOM + + +PROVIDER_DISPLAY_NAMES: dict[str, str] = { + "anthropic": "Anthropic", + "azure_openai": "Azure OpenAI", + "baseten": "Baseten", + "cohere": "Cohere", + "deepseek": "DeepSeek", + "fireworks": "Fireworks", + "google_genai": "Google Gemini", + "google_vertexai": "Google Vertex AI", + "groq": "Groq", + "huggingface": "Hugging Face", + "ibm": "IBM watsonx", + "langsmith": "LangSmith (tracing)", + "litellm": "LiteLLM", + "meta": "Meta", + "mistralai": "Mistral AI", + "nvidia": "NVIDIA", + "openai": "OpenAI", + "openai_codex": "OpenAI Codex (ChatGPT login)", + "openrouter": "OpenRouter", + "perplexity": "Perplexity", + "together": "Together AI", + "xai": "xAI", +} + + +PROVIDER_SHORT_NAMES: dict[str, str] = { + # Only providers whose `PROVIDER_DISPLAY_NAMES` label is too verbose for a + # compact tag need an entry here; everything else falls back to the display + # name, which is already short. + "openai_codex": "OpenAI Codex", +} +"""Compact brand labels for space-constrained UI (e.g. the `/model` Recent tag). + +Sparse companion to `PROVIDER_DISPLAY_NAMES`: an entry exists only when the full +display name carries a parenthetical qualifier that reads badly inside a tag +(e.g. `"OpenAI Codex (ChatGPT login)"`). Resolved via `provider_short_name`. +""" + + +PROVIDER_API_KEY_URLS: dict[str, str] = { + "anthropic": "https://platform.claude.com/login?returnTo=%2Fsettings%2Fkeys", + "baseten": "https://docs.baseten.co/organization/api-keys", + "cohere": "https://dashboard.cohere.com/welcome/login?redirect_uri=%2Fapi-keys", + "deepseek": "https://platform.deepseek.com/api_keys", + "fireworks": "https://app.fireworks.ai/settings/users/api-keys", + "google_genai": "https://aistudio.google.com/api-keys", + "groq": "https://console.groq.com/keys", + "huggingface": "https://huggingface.co/login?next=%2Fsettings%2Ftokens", + "ibm": "https://cloud.ibm.com/iam/apikeys", + "langsmith": "https://smith.langchain.com/settings", + "litellm": "https://docs.litellm.ai/docs/proxy/virtual_keys", + "meta": "https://dev.meta.ai/api-keys/", + "mistralai": "https://console.mistral.ai/api-keys", + "nvidia": "https://build.nvidia.com/settings/api-keys", + "openai": "https://platform.openai.com/api-keys", + "openrouter": "https://openrouter.ai/workspaces/default/keys", + "perplexity": "https://www.perplexity.ai/settings/api", + "tavily": "https://app.tavily.com", + "together": "https://api.together.ai/settings/api-keys", + "xai": "https://console.x.ai/team/default/api-keys", +} + + +def _is_safe_acquisition_url(url: str) -> bool: + """Return whether `url` is safe to render as a clickable link. + + Built-in links are trusted, but `api_key_url` can come from user-owned + config; restricting to `http`/`https` keeps a malformed or + `javascript:`-scheme value from becoming a live hyperlink. + + Args: + url: Candidate link target. + + Returns: + `True` if the URL uses an `http` or `https` scheme. + """ + return urlsplit(url).scheme in {"http", "https"} + + +def provider_display_name(provider: str, config: ModelConfig | None = None) -> str: + """Return a human-readable provider label. + + Shared by the auth UI and the model selector so a provider is labeled + identically in both. (The install prompt reuses the underlying + `PROVIDER_DISPLAY_NAMES` map directly rather than this function, to avoid an + event-loop config read, so a user-configured `display_name` won't surface + there.) + + Resolution order: a configured `display_name`, then the built-in + `PROVIDER_DISPLAY_NAMES` map, then a title-cased form of the provider key. + + Args: + provider: Provider config key. + config: Parsed model config, if already loaded by the caller. + + Returns: + Configured display name, built-in display name, or title-cased provider key. + """ + model_config = config or ModelConfig.load() + return model_config.get_provider_display_name( + provider + ) or PROVIDER_DISPLAY_NAMES.get(provider, provider.replace("_", " ").title()) + + +def provider_short_name(provider: str, config: ModelConfig | None = None) -> str: + """Return a compact brand label for a provider. + + For space-constrained UI (e.g. the `/model` Recent tag). Resolution order: + a configured `short_name`, then the built-in `PROVIDER_SHORT_NAMES` map, + then the full `provider_display_name` (which is already short for providers + without a parenthetical qualifier). + + Args: + provider: Provider config key. + config: Parsed model config, if already loaded by the caller. + + Returns: + Compact brand label, falling back to the display name when none is set. + """ + model_config = config or ModelConfig.load() + return ( + model_config.get_provider_short_name(provider) + or PROVIDER_SHORT_NAMES.get(provider) + or provider_display_name(provider, model_config) + ) + + +def _auth_status_for(provider: str) -> ProviderAuthStatus: + """Resolve the credential readiness of a provider or non-model service. + + Routes services (e.g. Tavily) and model providers to their respective + status helpers. Each call reads the credential file, so callers that need + the status more than once should resolve it here a single time and reuse + the result. + + Args: + provider: Provider or service config key. + + Returns: + The auth status used for both ordering and badge rendering. + """ + if is_service(provider): + return get_service_auth_status(provider) + return get_provider_auth_status(provider) + + +class AuthResult(StrEnum): + """Outcome of an `AuthPromptScreen` interaction. + + The three outcomes need to stay distinguishable because callers in the + recovery path retry the original failing operation only on `SAVED` — + retrying after `DELETED` would loop into the same missing-credentials + error indefinitely. + """ + + SAVED = "saved" + """A key was persisted, or a reload (Ctrl+R) made the provider's credential + resolvable. Either way the caller should retry the original operation.""" + + DELETED = "deleted" + """User cleared the existing stored key. No retry should follow.""" + + CANCELLED = "cancelled" + """User dismissed the prompt without saving.""" + + +class AuthConfirmScreen(ModalScreen[bool]): + """Confirm before launching an authentication flow for a model. + + A provider-agnostic gate shown when a selected model needs credentials + that aren't detected, and starting the auth flow is disruptive enough + that the user should opt in first (e.g. an OAuth flow that launches a + browser and a multi-minute loopback wait). The caller supplies all copy + so the screen carries no provider assumptions; currently only the + `openai_codex` model-switcher path uses it. + + Dismissal values: + + - `True`: proceed to the auth flow. + - `False`: go back without authenticating (also the outcome of Esc). + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "confirm", "Continue", show=False, priority=True), + Binding("escape", "cancel", "Back", show=False, priority=True), + ] + + CSS = """ + AuthConfirmScreen { + align: center middle; + } + + AuthConfirmScreen > Vertical { + width: 64; + max-width: 90%; + height: auto; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + AuthConfirmScreen .auth-confirm-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + AuthConfirmScreen .auth-confirm-body { + height: auto; + color: $text; + margin-bottom: 1; + } + + AuthConfirmScreen .auth-confirm-help { + height: auto; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def __init__( + self, + *, + title: str, + body: str | Content, + help_text: str = "Enter to continue, Esc to go back", + ) -> None: + """Initialize the prompt. + + Args: + title: Heading shown at the top of the dialog. + body: Explanatory copy. Pass a `Content` for inline styling, or a + plain string for unstyled text. + help_text: Key-hint line shown at the bottom. + """ + super().__init__() + self._title = title + self._body = body + self._help_text = help_text + + def compose(self) -> ComposeResult: + """Compose the confirmation dialog. + + Yields: + Title, body, and key-hint widgets parented inside a `Vertical`. + """ + with Vertical(): + yield Static(self._title, classes="auth-confirm-title", markup=False) + yield Static(self._body, classes="auth-confirm-body", markup=False) + yield Static(self._help_text, classes="auth-confirm-help", markup=False) + + def on_mount(self) -> None: + """Apply ASCII border when needed.""" + if is_ascii_mode(): + container = self.query_one(Vertical) + colors = theme.get_theme_colors(self) + container.styles.border = ("ascii", colors.success) + + def action_confirm(self) -> None: + """Dismiss with `True` to proceed to the auth flow.""" + self.dismiss(True) + + def action_cancel(self) -> None: + """Dismiss with `False` to go back without authenticating. + + The method name must stay `cancel`: the app owns a priority `escape` + binding that, for an active `ModalScreen`, dispatches to + `action_cancel` if present and otherwise falls through to + `dismiss(None)`. Renaming this would silently regress Esc to a + `None` dismiss instead of an explicit "go back". + """ + self.dismiss(False) + + +class DeleteCredentialConfirmScreen(ModalScreen[bool]): + """Confirmation overlay shown before clearing a stored credential. + + Patterned on `DeleteThreadConfirmScreen` so the destructive prompt feels + consistent across the app. Always dismisses with `True` on confirm or + `False` on cancel; the caller does the actual delete. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "confirm", "Confirm", show=False, priority=True), + Binding("escape", "cancel", "Cancel", show=False, priority=True), + ] + + CSS = """ + DeleteCredentialConfirmScreen { + align: center middle; + } + + DeleteCredentialConfirmScreen > Vertical { + width: 56; + height: auto; + background: $surface; + border: solid red; + padding: 1 2; + } + + DeleteCredentialConfirmScreen .auth-confirm-text { + text-align: center; + margin-bottom: 1; + } + + DeleteCredentialConfirmScreen .auth-confirm-help { + text-align: center; + color: $text-muted; + text-style: italic; + } + """ + + def __init__(self, provider: str) -> None: + """Initialize the confirmation modal. + + Args: + provider: Provider whose stored credential is about to be cleared. + """ + super().__init__() + self._provider = provider + + def compose(self) -> ComposeResult: + """Compose the confirmation dialog. + + Yields: + Widgets for the delete confirmation prompt. + """ + with Vertical(): + yield Static( + Content.from_markup( + "Delete stored API key for [bold]$provider[/bold]?", + provider=self._provider, + ), + classes="auth-confirm-text", + ) + yield Static( + "Enter to confirm, Esc to cancel", + classes="auth-confirm-help", + ) + + def action_confirm(self) -> None: + """Confirm deletion.""" + self.dismiss(True) + + def action_cancel(self) -> None: + """Cancel deletion.""" + self.dismiss(False) + + +class AuthPromptScreen(ModalScreen[AuthResult]): + """Modal that captures and persists an API key for one provider. + + Dismissal values are members of `AuthResult` so callers in the recovery + path can distinguish "user just saved a key — retry the failed + operation" from "user just cleared their key — don't retry, that would + loop into the same error" from "user cancelled — leave state alone". + """ + + AUTO_FOCUS = "#auth-prompt-input" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "cancel", "Cancel", show=False, priority=True), + Binding("f2", "toggle_advanced", "Advanced", show=False, priority=True), + Binding("ctrl+r", "reload_env", "Reload", show=False, priority=True), + Binding("ctrl+d", "delete_stored", "Delete stored", show=False, priority=True), + ] + + CSS = """ + AuthPromptScreen { + align: center middle; + } + + AuthPromptScreen > Vertical { + width: 72; + max-width: 90%; + height: auto; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + AuthPromptScreen .auth-prompt-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + AuthPromptScreen .auth-prompt-copy { + height: auto; + color: $text; + margin-bottom: 1; + } + + AuthPromptScreen .auth-prompt-status { + height: auto; + color: $text; + background: $background; + padding: 0 1; + margin-bottom: 1; + } + + AuthPromptScreen .auth-prompt-instructions { + height: auto; + color: $text; + margin-bottom: 1; + } + + AuthPromptScreen .auth-prompt-advanced-toggle { + height: auto; + color: $text-muted; + margin-bottom: 1; + } + + AuthPromptScreen #auth-prompt-base-url-label { + text-align: center; + } + + AuthPromptScreen .auth-prompt-meta { + height: auto; + color: $text-muted; + margin-bottom: 1; + } + + AuthPromptScreen #auth-prompt-project-hint { + margin-top: 1; + } + + AuthPromptScreen #auth-prompt-region { + height: auto; + width: 100%; + margin-bottom: 1; + border: solid $primary-lighten-2; + } + + AuthPromptScreen #auth-prompt-region:focus-within { + border: solid $primary; + } + + AuthPromptScreen #auth-prompt-input, + AuthPromptScreen #auth-prompt-base-url { + margin-bottom: 1; + border: solid $primary-lighten-2; + } + + AuthPromptScreen #auth-prompt-input:focus, + AuthPromptScreen #auth-prompt-base-url:focus { + border: solid $primary; + } + + AuthPromptScreen .auth-prompt-error { + height: auto; + color: $error; + margin-bottom: 1; + } + + AuthPromptScreen .auth-prompt-help { + height: auto; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def __init__( + self, + provider: str, + env_var: str | None, + *, + reason: str | None = None, + allow_empty_submit: bool = False, + input_placeholder: str | None = None, + submit_label: str | None = None, + ) -> None: + """Initialize the prompt for `provider`. + + Args: + provider: Provider name (e.g., `"anthropic"`). + env_var: Canonical env var the SDK reads, shown as helper text. + May be `None` for providers that don't use one of the + hardcoded env-var bindings (rare; the prompt still works). + reason: Optional context, e.g., + `"Required to use anthropic:claude-opus-4-8"`. + allow_empty_submit: Whether pressing Enter on an empty key dismisses + with `AuthResult.CANCELLED` instead of showing a validation error. + input_placeholder: Optional placeholder override for the key input. + submit_label: Optional help-label override for the Enter action. + """ + super().__init__() + self._provider = provider + self._env_var = env_var + self._reason = reason + self._allow_empty_submit = allow_empty_submit + self._input_placeholder = input_placeholder + self._submit_label = submit_label + # LangSmith is configured as a tracing service: it carries an optional + # project name and an endpoint chosen from a region selector (US/EU SaaS + # or a custom self-hosted URL), and saving a key turns tracing on. + self._is_langsmith = is_langsmith(provider) + # Probe the store once here so compose-time helpers read the cached + # `self._config` instead of each reloading it. See + # `_probe_credential_state` for the crash-safety rationale that lets + # this run at construction (before the modal mounts) without a guard. + self._probe_credential_state() + self._advanced_visible = bool(self._existing_base_url or self._existing_project) + self._refresh_endpoint_env_notice() + + def _probe_credential_state(self) -> None: + """Resolve the current credential source, store, base URL, and project. + + Never let an unreadable auth.json crash the screen: Textual would + propagate the exception before the modal mounts (at construction) or + while it is live (on Ctrl+R reload). `auth_store` funnels all store + corruption into RuntimeError, so catch that, treat unreadable store + metadata as "no existing key", and surface a one-line warning at + compose time. The resolved auth status is kept: `get_provider_auth_status` + already falls back to environment credentials when the store can't be + read. (A malformed config.toml can't crash us here — `ModelConfig.load()` + returns an empty config instead of raising.) + """ + try: + self._config = ModelConfig.load() + self._auth_status = _auth_status_for(self._provider) + except RuntimeError as exc: + logger.warning( + "Could not resolve credentials for %s: %s", self._provider, exc + ) + self._config = ModelConfig() + self._auth_status = ProviderAuthStatus( + state=ProviderAuthState.MISSING, provider=self._provider + ) + self._set_missing_store_metadata(exc) + return + + try: + self._has_existing = auth_store.get_stored_key(self._provider) is not None + self._existing_base_url = ( + auth_store.get_stored_base_url(self._provider) or "" + ) + self._existing_project = auth_store.get_stored_project(self._provider) or "" + self._region: Region = _region_for_endpoint(self._existing_base_url) + self._store_warning: str | None = None + except RuntimeError as exc: + logger.warning( + "Could not read stored credentials for %s: %s", self._provider, exc + ) + self._set_missing_store_metadata(exc) + + def _set_missing_store_metadata(self, exc: RuntimeError) -> None: + """Clear optional stored metadata after an auth-store read failure.""" + self._has_existing = False + self._existing_base_url = "" + self._existing_project = "" + self._region = Region.US + self._store_warning = ( + f"Credential file is unreadable ({exc}). Saving here will overwrite it." + ) + + def _refresh_endpoint_env_notice(self) -> None: + """Recompute the LangSmith endpoint-precedence notice from the environment. + + Surface when an environment endpoint is set: at startup an existing + `LANGSMITH_ENDPOINT`/`LANGCHAIN_ENDPOINT` takes precedence over the + stored region, so without this note the radio could show one region + while traces route somewhere else. (The note fires on presence, not on + divergence — it may show even when the env value matches the stored + region.) Saving here applies the selection (the save path replaces the + env value), so word the note around that. + + Called at construction and again on Ctrl+R reload, since the reload can + add or remove one of those variables and the recompose must reflect it. + """ + self._endpoint_env_notice: str | None = None + if self._is_langsmith and any( + os.environ.get(var) for var in ("LANGSMITH_ENDPOINT", "LANGCHAIN_ENDPOINT") + ): + self._endpoint_env_notice = ( + "An endpoint is set in your environment " + "(LANGSMITH_ENDPOINT/LANGCHAIN_ENDPOINT) and takes precedence at " + "startup; saving a region here applies your selection instead." + ) + + def compose(self) -> ComposeResult: + """Compose the prompt. + + Yields: + Widgets that make up the auth prompt modal. + """ + glyphs = get_glyphs() + provider_label = provider_display_name(self._provider, self._config) + with Vertical(): + # Tag the title with `(stored)` so the user knows a replacement + # (or the `Ctrl+D delete` affordance shown in the help line) is + # what's about to happen — both are gated on `_has_existing`. + resolved_from_env = self._auth_status.source is ProviderAuthSource.ENV + scoped_env_var = None + if self._auth_status.env_var: + candidate = resolved_env_var_name(self._auth_status.env_var) + if candidate.startswith("DEEPAGENTS_CODE_") and os.environ.get( + candidate + ): + scoped_env_var = candidate + active_env_var = scoped_env_var or ( + resolved_env_var_name(self._auth_status.env_var) + if resolved_from_env and self._auth_status.env_var + else None + ) + if active_env_var and active_env_var.startswith("DEEPAGENTS_CODE_"): + title_prefix = f"{glyphs.warning} " + else: + title_prefix = f"{glyphs.checkmark} " if resolved_from_env else "" + if self._has_existing: + title = Content.assemble( + title_prefix, + Content.from_markup( + "Replace key for [bold]$provider[/bold] [dim](stored)[/dim]", + provider=provider_label, + ), + ) + elif resolved_from_env: + title = Content.assemble( + title_prefix, + Content.from_markup( + "Replace key for [bold]$provider[/bold]", + provider=provider_label, + ), + ) + else: + title = Content.assemble( + title_prefix, + Content.from_markup( + "API key for [bold]$provider[/bold]", + provider=provider_label, + ), + ) + yield Static(title, classes="auth-prompt-title") + if active_env_var: + env_var = active_env_var + is_scoped_env = env_var.startswith("DEEPAGENTS_CODE_") + env_status_style = "$warning" if is_scoped_env else "$success" + env_note = ( + "This scoped env var takes priority. A saved key will be used " + f"only when {env_var} is unset." + if is_scoped_env + else ("Paste a key below to use a different key for dcode.") + ) + yield Static( + Content.assemble( + ( + "Current key is set from environment variable ", + env_status_style, + ), + (env_var, f"bold {env_status_style}"), + (".", env_status_style), + "\n", + (env_note, "italic $text-muted"), + ), + classes="auth-prompt-status", + id="auth-prompt-env-status", + ) + if self._reason: + yield Static( + Content.from_markup("$reason", reason=self._reason), + classes="auth-prompt-copy", + ) + yield Static( + self._build_key_instructions(), + classes="auth-prompt-instructions", + id="auth-prompt-key-instructions", + ) + if self._store_warning: + yield Static( + Content.from_markup("$msg", msg=self._store_warning), + classes="auth-prompt-error", + ) + yield Input( + placeholder=self._input_placeholder + or ( + "Paste a new key to replace the stored one" + if self._has_existing + else "Paste your API key" + ), + password=True, + id="auth-prompt-input", + ) + storage_note: Content | None + if self._is_langsmith: + storage_note = Content.from_markup( + "dcode stores the above key locally and turns on " + "LangSmith tracing. To pause tracing without removing the key, " + "set [bold]DEEPAGENTS_CODE_LANGSMITH_TRACING=false[/bold]." + ) + elif is_service(self._provider): + # Services (e.g. Tavily) skip the storage note: the title and + # reason copy already say what the key is for, so it only adds + # redundant copy here. + storage_note = None + else: + storage_note = Content.from_markup( + "dcode stores the above key locally and uses it " + "when you select [bold]$provider[/bold] models.", + provider=provider_label, + ) + if storage_note is not None: + yield Static( + storage_note, + classes="auth-prompt-meta", + id="auth-prompt-storage-note", + ) + yield Static( + self._build_advanced_toggle_label(), + classes="auth-prompt-advanced-toggle", + id="auth-prompt-advanced-toggle", + ) + if self._env_var: + key_meta = Static( + Content.assemble( + "Alternatively, environment variables can be used in place " + "of the key stored above. Set ", + (f"DEEPAGENTS_CODE_{self._env_var}", TStyle(bold=True)), + " for a dcode-only key; it has the highest priority. Set ", + (self._env_var, TStyle(bold=True)), + " to share a key with other provider SDK tools; it is used " + "only when no scoped or stored key exists. After setting one " + "in a .env file, press ", + ("Ctrl+R", TStyle(bold=True)), + " to reload without restarting. A variable exported in a " + "separate shell after launch is invisible to this process; " + "it needs a full relaunch. ", + ( + "Configuration docs", + self._link_style(CONFIGURATION_DOCS_URL), + ), + ".", + ), + classes="auth-prompt-meta", + id="auth-prompt-key-meta", + ) + key_meta.display = self._advanced_visible + yield key_meta + if self._is_langsmith: + if self._endpoint_env_notice: + yield Static( + Content.from_markup("$msg", msg=self._endpoint_env_notice), + classes="auth-prompt-meta", + id="auth-prompt-endpoint-env-notice", + ) + region_label = Static( + Content.from_markup("[bold]LangSmith region[/bold]"), + classes="auth-prompt-meta", + id="auth-prompt-region-label", + ) + region_label.display = self._advanced_visible + yield region_label + region_set = RadioSet( + *( + RadioButton( + spec.label, + value=self._region == spec.region, + id=spec.radio_id, + ) + for spec in _REGION_SPECS + ), + id="auth-prompt-region", + ) + region_set.display = self._advanced_visible + yield region_set + custom_visible = ( + self._advanced_visible and self._region == Region.CUSTOM + ) + base_url_input = Input( + value=( + self._existing_base_url if self._region == Region.CUSTOM else "" + ), + placeholder="https://my-langsmith.example.com", + id="auth-prompt-base-url", + ) + base_url_input.display = custom_visible + yield base_url_input + base_url_hint_widget = Static( + Content.from_markup( + "Point tracing at a self-hosted or proxied LangSmith. " + "Sets [bold]LANGSMITH_ENDPOINT[/bold]; must be an " + "http(s) URL." + ), + classes="auth-prompt-meta", + id="auth-prompt-base-url-hint", + ) + base_url_hint_widget.display = custom_visible + yield base_url_hint_widget + project_label = Static( + Content.from_markup("[bold]Project name[/bold]"), + classes="auth-prompt-meta", + id="auth-prompt-project-label", + ) + project_label.display = self._advanced_visible + yield project_label + project_input = Input( + value=self._existing_project, + placeholder="LANGSMITH_PROJECT (default: deepagents-code)", + id="auth-prompt-project", + ) + project_input.display = self._advanced_visible + yield project_input + project_hint_widget = Static( + Content.from_markup( + "Route agent traces to this LangSmith project. " + "Leave blank to use the default [bold]deepagents-code[/bold]." + ), + classes="auth-prompt-meta", + id="auth-prompt-project-hint", + ) + project_hint_widget.display = self._advanced_visible + yield project_hint_widget + else: + base_url_label = Static( + Content.from_markup("[bold]Base URL override[/bold]"), + classes="auth-prompt-meta", + id="auth-prompt-base-url-label", + ) + base_url_label.display = self._advanced_visible + yield base_url_label + base_url_input = Input( + value=self._existing_base_url, + placeholder="Base URL", + id="auth-prompt-base-url", + ) + base_url_input.display = self._advanced_visible + yield base_url_input + base_url_hint_widget = Static( + self._build_base_url_hint(), + classes="auth-prompt-meta", + id="auth-prompt-base-url-hint", + ) + base_url_hint_widget.display = self._advanced_visible + yield base_url_hint_widget + yield Static("", classes="auth-prompt-error", id="auth-prompt-error") + save_label = self._submit_label or ( + "Enter replace" if self._has_existing else "Enter save" + ) + help_parts = [ + f"{save_label} {glyphs.bullet} Esc cancel", + "F2 advanced", + "Ctrl+R reload", + ] + if self._has_existing: + help_parts.append("Ctrl+D delete stored") + yield Static( + f" {glyphs.bullet} ".join(help_parts), + classes="auth-prompt-help", + ) + + def _link_style(self, url: str) -> TStyle: + """Return a theme-aware style for inline modal links. + + Args: + url: Link target. + + Returns: + Textual style that opens `url` when clicked. + """ + colors = theme.get_theme_colors(self) + if self.app.theme in {"ansi-dark", "ansi-light"}: + return TStyle(bold=True, underline=True, link=url) + return TStyle(foreground=TColor.parse(colors.primary), underline=True, link=url) + + def _build_key_instructions(self) -> Content: + """Build provider-specific API-key acquisition guidance. + + Returns: + Content shown before the API-key input. May append muted notices: a + provider-specific caveat (e.g. Anthropic subscription plans are + unsupported) and/or a warning that a user-configured `api_key_url` + was rejected for using an unsupported URL scheme. + """ + config = self._config + configured_url = config.get_provider_api_key_url(self._provider) + rejected_url = False + if configured_url and not _is_safe_acquisition_url(configured_url): + logger.warning( + "Ignoring api_key_url for %s: unsupported URL scheme", self._provider + ) + configured_url = None + rejected_url = True + url = configured_url or PROVIDER_API_KEY_URLS.get( + self._provider, _PROVIDERS_DOCS_URL + ) + provider = provider_display_name(self._provider, config) + label = ( + f"{provider} key page" + if configured_url or self._provider in PROVIDER_API_KEY_URLS + else f"{provider} setup docs" + ) + if self._provider == "azure_openai": + instructions = Content.assemble( + "Find your key in your Azure OpenAI resource's " + "Keys and Endpoint page, then paste it below. ", + (label, self._link_style(url)), + ) + elif self._provider == "openai": + instructions = Content.assemble( + f"Sign in to {provider}, create or copy an API key, then " + "paste it below. Minimum permissions needed: " + "under Model capabilities, grant Write access to Responses " + "(/v1/responses). For older models, you may also need " + "Request access to Chat completions (/v1/chat/completions). ", + (label, self._link_style(url)), + ) + elif self._provider == "anthropic": + instructions = Content.assemble( + f"Sign in to {provider}, create or copy an API key, " + "then paste it below. ", + (label, self._link_style(url)), + "\n", + ( + ( + "Subscription plans (Claude Pro/Max, Claude Code) cannot " + "be used for Anthropic calls in dcode. Only a " + "standard API key with pay-as-you-go billing works here." + ), + "italic $text-muted", + ), + ) + else: + instructions = Content.assemble( + f"Sign in to {provider}, create or copy an API key, " + "then paste it below. ", + (label, self._link_style(url)), + ) + if rejected_url: + notice = ( + "Your configured api_key_url was ignored (unsupported URL " + "scheme); showing the default link instead." + ) + instructions = Content.assemble( + instructions, + "\n", + (notice, "italic $text-muted"), + ) + return instructions + + def _build_advanced_toggle_label(self) -> str: + """Build the disclosure-row label for advanced settings. + + Returns: + Toggle label reflecting the current expanded state. + """ + glyphs = get_glyphs() + marker = ( + glyphs.disclosure_expanded + if self._advanced_visible + else glyphs.disclosure_collapsed + ) + return f"{marker} Advanced (F2)" + + def _build_base_url_hint(self) -> Content: + """Build the optional base-URL hint shown inside Advanced. + + Returns: + Content describing blank behavior and env-var precedence. + """ + surviving_base_url_env = get_default_base_url_env(self._provider) + endpoint_envs = get_base_url_env_vars(self._provider) + env_order = ", then ".join( + item for env in endpoint_envs for item in (f"DEEPAGENTS_CODE_{env}", env) + ) + if surviving_base_url_env and env_order: + return Content.from_markup( + "Override the provider endpoint for this stored key. " + "Leave blank to use [bold]$prefixed[/bold].\n" + "Env override order: [bold]$order[/bold].", + prefixed=surviving_base_url_env, + order=env_order, + ) + endpoint_env = get_base_url_env_var(self._provider) + if endpoint_env and env_order: + return Content.from_markup( + "Override the provider endpoint for this stored key. " + "Leave blank to use the provider default.\n" + "Env override order: [bold]$order[/bold].", + order=env_order, + ) + return Content.from_markup( + "Override the provider endpoint for this stored key. " + "Leave blank to use the provider default." + ) + + def action_toggle_advanced(self) -> None: + """Show or hide optional endpoint and env-var details. + + Restores focus to the key input when collapsing so keyboard entry + resumes on the field the user most likely wants. + """ + self._advanced_visible = not self._advanced_visible + for selector in ( + "#auth-prompt-key-meta", + "#auth-prompt-region-label", + "#auth-prompt-region", + "#auth-prompt-base-url-label", + "#auth-prompt-base-url", + "#auth-prompt-base-url-hint", + "#auth-prompt-project-label", + "#auth-prompt-project", + "#auth-prompt-project-hint", + ): + for widget in self.query(selector): + widget.display = self._advanced_visible + # For LangSmith the custom URL field only shows under the Custom region, + # so re-apply its region-gated visibility after the blanket toggle above. + if self._is_langsmith: + self._refresh_langsmith_custom_visibility() + self.query_one("#auth-prompt-advanced-toggle", Static).update( + self._build_advanced_toggle_label() + ) + if not self._advanced_visible: + self.query_one("#auth-prompt-input", Input).focus() + + def _refresh_langsmith_custom_visibility(self) -> None: + """Show the custom URL field only when Advanced is open and region is Custom.""" + custom_visible = self._advanced_visible and self._region == Region.CUSTOM + for selector in ("#auth-prompt-base-url", "#auth-prompt-base-url-hint"): + for widget in self.query(selector): + widget.display = custom_visible + + def on_radio_set_changed(self, event: RadioSet.Changed) -> None: + """Track the chosen LangSmith region and reveal the custom URL field.""" + event.stop() + region = _REGION_BY_RADIO_ID.get(event.pressed.id or "") + if region is None: + # An unknown id (e.g. a renamed radio) must not silently degrade to a + # region — leave the current selection unchanged rather than guess. + return + self._region = region + self._refresh_langsmith_custom_visibility() + if self._region == Region.CUSTOM: + self.query_one("#auth-prompt-base-url", Input).focus() + + def on_click(self, event: Click) -> None: + """Open style-embedded hyperlinks or toggle Advanced.""" + widget = event.widget + if ( + widget is not None + and widget.id == "auth-prompt-advanced-toggle" + and not event.style.link + ): + self.action_toggle_advanced() + event.stop() + return + open_style_link(event) + + def on_mouse_move(self, event: MouseMove) -> None: + """Show a pointer over links and the clickable Advanced row.""" + widget = event.widget + self.styles.pointer = ( + "pointer" + if event.style.link + or (widget is not None and widget.id == "auth-prompt-advanced-toggle") + else "default" + ) + + def on_leave(self) -> None: + """Reset the pointer shape when the mouse leaves the prompt.""" + self.styles.pointer = "default" + + def on_mount(self) -> None: + """Apply ASCII border when needed.""" + if is_ascii_mode(): + container = self.query_one(Vertical) + colors = theme.get_theme_colors(self) + container.styles.border = ("ascii", colors.success) + + def _resolve_langsmith_endpoint(self) -> ResolvedEndpoint: + """Resolve the endpoint to store from the LangSmith region selector. + + Returns: + A `ResolvedEndpoint`: the canonical endpoint to persist (empty for + the US SaaS default), and a non-`None` error when a custom URL is + missing or malformed (so an explicit `Custom` selection never + silently routes the key to the US SaaS default, and the key is + never paired with a non-http(s) endpoint). + """ + if self._region != Region.CUSTOM: + # US and EU resolve to their fixed SaaS endpoints from the table + # (US -> "" clears the stored endpoint back to the SaaS default). + return ResolvedEndpoint(_ENDPOINT_BY_REGION[self._region], None) + raw = self.query_one("#auth-prompt-base-url", Input).value.strip() + if not raw: + # `Custom` is a deliberate non-default choice; a blank field must not + # silently fall back to the US SaaS default (which would reroute the + # key and traces to the very endpoint a self-hosted user is avoiding). + missing_url_error = ( + "Custom endpoint URL is required (or choose United States/Europe)." + ) + return ResolvedEndpoint("", missing_url_error) + endpoint = normalize_langsmith_endpoint(raw) + if not is_http_url(endpoint): + return ResolvedEndpoint("", "Custom endpoint must be an http(s) URL.") + return ResolvedEndpoint(endpoint, None) + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Validate, persist, and dismiss. + + Reads both fields regardless of which one was submitted, so pressing + Enter in either the key or the secondary input (base URL, or the + LangSmith project name) saves the pair. + """ + event.stop() + cleaned = self.query_one("#auth-prompt-input", Input).value.strip() + if self._is_langsmith: + project = self.query_one("#auth-prompt-project", Input).value.strip() + resolved = self._resolve_langsmith_endpoint() + if resolved.error: + self._show_error(resolved.error) + return + base_url = resolved.endpoint + else: + base_url = self.query_one("#auth-prompt-base-url", Input).value.strip() + # Match the CLI `--base-url` guard: never pair the key with a + # non-http(s) endpoint. Validate only a *changed* value, though: the + # field is pre-filled with the stored base URL, so rotating just the + # key must not be blocked by a legacy non-http(s) endpoint (the CLI + # never validated base URLs before this feature). A new or edited + # value must still be http(s). + if ( + base_url + and base_url != self._existing_base_url + and not is_http_url(base_url) + ): + self._show_error("Base URL must be an http(s) URL.") + return + project = "" + if not cleaned: + if self._allow_empty_submit: + # Optional prompts (e.g. the Tavily onboarding step) treat an + # empty submit as an intentional skip. We deliberately reuse + # `CANCELLED` rather than add a `SKIPPED` outcome: every caller + # that allows empty submit wants identical "did not save" + # handling for skip and Escape, so the distinction would be + # dead weight. Revisit if a caller ever needs to tell a + # deliberate decline from an accidental dismissal. + self.dismiss(AuthResult.CANCELLED) + return + self._show_error("API key cannot be empty.") + return + try: + outcome = auth_store.set_stored_key( + self._provider, + cleaned, + base_url=base_url or None, + project=project or None, + ) + except (ValueError, RuntimeError, OSError) as exc: + # `auth_store` exception messages never include the secret value, + # but the path can include user-controlled `DEFAULT_STATE_DIR` + # bytes — render via `Content.from_markup` so a `[` in the path + # can't break Textual's markup pipeline. + logger.warning( + "Failed to persist credential for %s: %s", self._provider, exc + ) + self._show_error("Could not save credential: $exc", exc=str(exc)) + return + for warning in outcome.warnings: + # chmod failures are security regressions the user must see — + # `logger.warning` alone is invisible inside a Textual session. + self.app.notify(warning, severity="warning", markup=False) + if self._is_langsmith: + apply_stored_langsmith_auth(replace_project=True) + clear_caches() + if not outcome.warnings: + # Only claim a clean save when the store locked the file down. When + # chmod warnings fired above, they *are* the outcome message — an + # extra "success" toast on top would compete with (and visually + # bury) the one signal that the key isn't secured. + provider_label = provider_display_name(self._provider, self._config) + # `markup=False`: a configured display name can contain markup + # metacharacters (e.g. `[`) that must not be interpreted here. The + # same guard applies to every interpolated toast below. + self.app.notify( + f"Successfully saved key for {provider_label}.", + severity="information", + markup=False, + ) + self.dismiss(AuthResult.SAVED) + + def action_cancel(self) -> None: + """Dismiss without saving.""" + self.dismiss(AuthResult.CANCELLED) + + async def action_reload_env(self) -> None: + """Re-read .env/environment, re-probe credentials, and continue. + + Runs the same environment/credential reload core as the `/reload` + command (`reload_from_environment` + `clear_caches`), so a credential + env var added to a `.env` file after launch is picked up without a full + restart; it intentionally skips `/reload`'s theme and skill + re-discovery. If the provider was blocked and is now resolvable, dismiss + with SAVED so the caller retries the original operation; otherwise + refresh the modal in place and toast the outcome. + """ + from deepagents_code.config import settings + + try: + settings.reload_from_environment() + clear_caches() + except (OSError, ValueError) as exc: + logger.warning("Failed to reload configuration from auth prompt: %s", exc) + self.app.notify( + "Could not reload configuration. Check your .env file and " + "environment variables for syntax errors, then try again.", + severity="error", + markup=False, + ) + return + + was_blocking = self._auth_status.blocks_start + self._probe_credential_state() + + if was_blocking and not self._auth_status.blocks_start: + self.app.notify( + f"Credentials detected for {self._provider}. Continuing.", + markup=False, + ) + self.dismiss(AuthResult.SAVED) + return + + # The reload may have added/removed a LangSmith endpoint env var; refresh + # the precedence notice so the recompose below doesn't render a stale one. + self._refresh_endpoint_env_notice() + + # Preserve any in-progress input across the recompose so a reload never + # discards values the user already started typing. + before = {inp.id: inp.value for inp in self.query(Input)} + await self.recompose() + for inp in self.query(Input): + if inp.id in before: + inp.value = before[inp.id] + self.query_one("#auth-prompt-input", Input).focus() + + if was_blocking: + self.app.notify( + "No credentials detected. Set a key in a .env file, then press " + "Ctrl+R. A variable exported in a separate shell needs a full " + "relaunch to take effect.", + markup=False, + ) + else: + self.app.notify("Environment reloaded.", markup=False) + + def action_delete_stored(self) -> None: + """Open the delete-confirmation overlay, or quit when nothing is stored. + + Ctrl+D deletes a stored credential, but its `priority` binding also + intercepts the app-level Ctrl+D=quit. When there's no credential to + delete, fall through to quit rather than swallowing the key (mirroring + the thread selector). `app.exit()` is used instead of `dismiss()`, which + would just close the modal silently and re-swallow the key. + """ + if not self._has_existing: + self.app.exit() + return + self.app.push_screen( + DeleteCredentialConfirmScreen(self._provider), + self._on_delete_confirmed, + ) + + def _on_delete_confirmed(self, confirmed: bool | None) -> None: + """Handle the result of the confirmation overlay. + + Args: + confirmed: `True` if the user pressed Enter, `False` on Esc. + """ + if not confirmed: + return + try: + result = auth_store.delete_stored_key(self._provider) + except RuntimeError as exc: + logger.warning( + "Failed to delete credential for %s: %s", self._provider, exc + ) + self._show_error("Could not delete credential: $exc", exc=str(exc)) + return + for warning in result.warnings: + # The rewritten store still holds other providers' secrets, so a + # chmod failure here is the same security regression as on save — + # surface it rather than dropping it on the floor. + self.app.notify(warning, severity="warning", markup=False) + # Toast after `clear_caches` (like the save path) so the confirmation + # reflects fully-settled state rather than firing before the cache is + # invalidated. + clear_caches() + if not result.removed: + # The entry was gone — likely a concurrent delete from another + # app instance. Surface that fact so "delete" UX doesn't lie when + # nothing actually happened on disk. + provider_label = provider_display_name(self._provider, self._config) + self.app.notify( + f"No stored credential for {provider_label} — already removed.", + severity="information", + markup=False, + ) + elif not result.warnings: + # Mirror the save path: a silent successful delete gives no + # confirmation, and the toast is suppressed when warnings fired. + provider_label = provider_display_name(self._provider, self._config) + self.app.notify( + f"Successfully removed key for {provider_label}.", + severity="information", + markup=False, + ) + self.dismiss(AuthResult.DELETED) + + def _show_error(self, template: str, /, **substitutions: str) -> None: + """Render `template` via markup substitution in the inline error slot. + + Args: + template: Markup template (e.g. `"Could not save: $exc"`). + **substitutions: `$name` substitution values; Textual escapes them. + """ + error = self.query_one("#auth-prompt-error", Static) + error.update(Content.from_markup(template, **substitutions)) + + +class AuthManagerScreen(ModalScreen[None]): + """Modal that lists configured providers and lets the user manage keys. + + Reachable via the `/auth` slash command. Always dismisses with `None`; + state changes are persisted by `AuthPromptScreen` and reflected by + re-rendering the option list when this screen is reopened or after a + save/delete completes. + + Well-known providers whose integration package isn't installed yet are + surfaced greyed-out so they stay discoverable. Selecting one routes + through an install confirmation: on confirm the screen records the extra on + `pending_install_extra` and dismisses so the app can install it (mirroring + the model selector's install-on-select flow) and reopen the manager. + """ + + class CredentialSaved(Message): + """Posted when a key prompt successfully persists credentials. + + Carries the `/auth` config key that was saved so the app can react to + credentials that gate spawn-time behavior — e.g. a Tavily key that + enables the `web_search` tool only after the server respawns. + """ + + def __init__(self, provider: str) -> None: + """Store the saved provider/service identifier. + + Args: + provider: The `/auth` config key that was saved (a model + provider name or a service key such as `"tavily"`). + """ + super().__init__() + self.provider = provider + + class CredentialDeleted(Message): + """Posted when a key prompt deletes stored credentials. + + Carries the `/auth` config key that was deleted so the app can clear + any in-memory state derived from the now-removed credential. + """ + + def __init__(self, provider: str) -> None: + """Store the deleted provider/service identifier. + + Args: + provider: The `/auth` config key that was deleted (a model + provider name or a service key such as `"tavily"`). + """ + super().__init__() + self.provider = provider + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "cancel", "Close", show=False, priority=True), + Binding("tab", "cursor_down", "Next", show=False, priority=True), + Binding("shift+tab", "cursor_up", "Previous", show=False, priority=True), + ] + + CSS = """ + AuthManagerScreen { + align: center middle; + } + + AuthManagerScreen > Vertical { + width: 76; + max-width: 90%; + height: 80%; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + AuthManagerScreen .auth-manager-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + AuthManagerScreen .auth-manager-copy { + height: auto; + color: $text-muted; + margin-bottom: 1; + } + + /* `1fr` + `min-height` keeps the option list from pushing the footer + off-screen on short terminals: the list shrinks (and starts scrolling) + before the footer is hidden. */ + AuthManagerScreen OptionList { + height: 1fr; + min-height: 3; + background: $background; + } + + AuthManagerScreen .auth-manager-warning { + height: auto; + color: $warning; + margin-bottom: 1; + } + + AuthManagerScreen .auth-manager-help { + height: auto; + color: $text-muted; + text-style: italic; + text-align: center; + margin-top: 1; + } + """ + + def __init__(self, *, initial_provider: str | None = None) -> None: + """Initialize the manager with an empty install-on-select registry. + + Args: + initial_provider: Provider whose row should start highlighted — + set when reopening after an install-on-select so the cursor + lands on the just-installed provider ready for a key, rather + than resetting to the top of the list. + """ + super().__init__() + # Uninstalled known providers mapped to the extra that installs them, + # populated each time the option list is built. Selecting one routes + # to the install confirmation instead of the key prompt. + self._install_extras: dict[str, str] = {} + # Set when the user confirms installing a provider's extra; the app + # reads these off the screen after dismissal to install then reopen + # the manager with the just-installed provider highlighted. + self.pending_install_extra: str | None = None + self.pending_install_provider: str | None = None + self._initial_provider = initial_provider + + def compose(self) -> ComposeResult: + """Compose the manager. + + Yields: + Widgets for the manager listing. + """ + glyphs = get_glyphs() + options, store_warning = self._build_options_with_warning() + with Vertical(): + yield Static("Manage API keys", classes="auth-manager-title") + yield Static(self._build_description(), classes="auth-manager-copy") + if store_warning: + # Surface auth.json corruption directly — `_build_options` + # falling back silently used to make a corrupt file look + # identical to "no keys stored". + yield Static( + Content.from_markup("$msg", msg=store_warning), + classes="auth-manager-warning", + ) + yield OptionList(*options, id="auth-manager-options") + yield Static( + f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab/Shift+Tab " + f"navigate {glyphs.bullet} Enter add/replace/delete/install " + f"{glyphs.bullet} Esc close", + classes="auth-manager-help", + ) + + def _build_description(self) -> Content: + """Build the description line with an inline docs hyperlink. + + Returns: + Description content. Themes other than the ANSI palette render + the link in the primary color so it reads as clickable; ANSI + users get a bold-only treatment that still reaches the + terminal's link handler via `Style(link=...)`. + """ + colors = theme.get_theme_colors(self) + ansi = self.app.theme in {"ansi-dark", "ansi-light"} + link_style: str | TStyle = ( + TStyle(bold=True, link=_PROVIDERS_DOCS_URL) + if ansi + else TStyle( + foreground=TColor.parse(colors.primary), + link=_PROVIDERS_DOCS_URL, + ) + ) + return Content.assemble( + "Lists installed model providers, services like web search, and any " + "providers you've configured in ~/.deepagents/config.toml. Greyed-out " + "providers aren't installed yet — select one to install it. ", + ("Docs", link_style), + ) + + def on_mount(self) -> None: + """Apply ASCII border and highlight the initial provider when set.""" + if is_ascii_mode(): + container = self.query_one(Vertical) + colors = theme.get_theme_colors(self) + container.styles.border = ("ascii", colors.success) + self._highlight_initial_provider() + + def _highlight_initial_provider(self) -> None: + """Move the cursor to `initial_provider`'s row if it is listed. + + Used when the manager reopens after an install-on-select so the cursor + lands on the just-installed provider (ready for a key) instead of + resetting to the top of the list. + """ + if self._initial_provider is None: + return + option_list = self.query_one("#auth-manager-options", OptionList) + try: + index = option_list.get_option_index(self._initial_provider) + except OptionDoesNotExist: + return + option_list.highlighted = index + option_list.scroll_to_highlight() + + def on_click(self, event: Click) -> None: # noqa: PLR6301 - Textual handler + """Open style-embedded hyperlinks (the title `Docs` link).""" + open_style_link(event) + + def on_mouse_move(self, event: MouseMove) -> None: + """Show a pointer over inline docs links.""" + self.styles.pointer = "pointer" if event.style.link else "default" + + def on_leave(self) -> None: + """Reset the pointer shape when the mouse leaves the manager.""" + self.styles.pointer = "default" + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + """Open the prompt for the selected provider. + + Greyed-out (uninstalled) providers route to an install confirmation + instead of the key prompt, since their package must be installed before + a credential is useful. + """ + provider = event.option.id + if not provider: + return + extra = self._install_extras.get(provider) + if extra is not None: + self._prompt_install_provider(provider, extra) + return + if provider == CODEX_PROVIDER: + # ChatGPT auth uses an OAuth browser flow, not an API key. The + # selector dispatches to a dedicated modal that already knows + # how to surface the authorize URL inline (so headless / SSH + # users can still paste it manually) and run the loopback + # callback wait on a worker. + self._open_codex_screen() + return + if is_service(provider): + # Services (e.g. Tavily web search) use a plain API key, stored the + # same way as a model-provider key. + self.app.push_screen( + AuthPromptScreen(provider, SERVICE_API_KEY_ENV[provider]), + partial(self._on_prompt_closed, provider), + ) + return + env_var = get_credential_env_var(provider) + self.app.push_screen( + AuthPromptScreen(provider, env_var), + partial(self._on_prompt_closed, provider), + ) + + def _prompt_install_provider(self, provider: str, extra: str) -> None: + """Confirm installing an uninstalled provider's extra, then dismiss. + + On confirm, record the extra on `pending_install_extra` and dismiss so + the app can install it (with a server restart) and reopen the manager + with the provider now installed. On cancel, stay on the manager. + + Args: + provider: The uninstalled provider the user selected. + extra: The `deepagents-code` extra that installs `provider`. + """ + from deepagents_code.tui.widgets.install_confirm import ( + InstallProviderConfirmScreen, + ) + + def _on_confirm(proceed: bool | None) -> None: + if proceed: + self.pending_install_extra = extra + self.pending_install_provider = provider + self.dismiss(None) + else: + # Declined or dismissed: clear any pending request so a reused + # screen never carries a stale install request, and stay put. + self.pending_install_extra = None + self.pending_install_provider = None + + self.app.push_screen( + InstallProviderConfirmScreen(provider, extra), + _on_confirm, + ) + + def _open_codex_screen(self) -> None: + """Push the ChatGPT OAuth flow modal and refresh on close. + + When `openai_codex` is already signed in, give the user a chance to + sign out before launching a fresh sign-in flow. Otherwise just run + the sign-in worker. + """ + from deepagents_code.integrations import openai_codex + from deepagents_code.tui.widgets.codex_auth import ( + CodexAuthScreen, + CodexSignedInScreen, + ) + + status = openai_codex.get_status() + if status.logged_in and not status.is_expired: + self.app.push_screen( + CodexSignedInScreen(), + self._on_codex_signed_in_closed, + ) + return + self.app.push_screen(CodexAuthScreen(), self._on_codex_closed) + + def _on_codex_closed(self, _result: bool | None) -> None: + """Refresh the option list once the codex flow dismisses.""" + clear_caches() + self._refresh_options() + + def _on_codex_signed_in_closed(self, action: CodexSignedInAction | None) -> None: + """Handle dismissal of the "already signed in" overlay. + + Args: + action: `SIGN_OUT` to clear the token, `REAUTH` to run the + sign-in flow again, `None` to close cleanly. + """ + from deepagents_code.tui.widgets.codex_auth import CodexSignedInAction + + if action is CodexSignedInAction.SIGN_OUT: + from deepagents_code.integrations import openai_codex + + removed = openai_codex.logout() + if removed: + self.app.notify("Signed out of ChatGPT.", markup=False) + clear_caches() + self._refresh_options() + elif action is CodexSignedInAction.REAUTH: + from deepagents_code.tui.widgets.codex_auth import CodexAuthScreen + + self.app.push_screen(CodexAuthScreen(), self._on_codex_closed) + else: + self._refresh_options() + + def action_cancel(self) -> None: + """Close the manager.""" + self.dismiss(None) + + def action_cursor_down(self) -> None: + """Move the option-list cursor down.""" + self.query_one("#auth-manager-options", OptionList).action_cursor_down() + + def action_cursor_up(self) -> None: + """Move the option-list cursor up.""" + self.query_one("#auth-manager-options", OptionList).action_cursor_up() + + def _on_prompt_closed(self, provider: str, result: AuthResult | None) -> None: + """Refresh the option list once the prompt dismisses. + + Args: + provider: The provider/service whose prompt just closed. + result: Outcome of the prompt interaction. + """ + self._refresh_options() + if result is AuthResult.SAVED: + self.post_message(self.CredentialSaved(provider)) + elif result is AuthResult.DELETED: + self.post_message(self.CredentialDeleted(provider)) + + def _refresh_options(self) -> None: + """Rebuild option labels from current store state.""" + option_list = self.query_one("#auth-manager-options", OptionList) + highlighted = option_list.highlighted + option_list.clear_options() + options, _ = self._build_options_with_warning() + for option in options: + option_list.add_option(option) + if highlighted is not None and option_list.option_count: + option_list.highlighted = min(highlighted, option_list.option_count - 1) + + def _build_options_with_warning(self) -> tuple[list[Option], str | None]: + """Render the option list, returning a corruption warning if any. + + Returns: + `(options, warning_message)`. `warning_message` is `None` when + the credential file is readable; otherwise a one-line hint + telling the user the file is unreadable so a corrupt store + doesn't silently look identical to "no keys stored". + """ + warning: str | None = None + try: + stored = set(auth_store.list_configured_providers()) + except RuntimeError as exc: + logger.warning("Failed to list stored credentials: %s", exc) + stored = set() + warning = ( + f"Credential file is unreadable ({exc}). " + "Saving a key here will overwrite it." + ) + + config = ModelConfig.load() + config_providers = { + name for name, cfg in config.providers.items() if cfg.get("api_key_env") + } + + # Only show well-known providers whose LangChain package is actually + # installed. `get_available_models` returns providers it could + # successfully import profiles for, so it doubles as an install + # gate. Stored and config-defined providers are always shown — even + # if the package was later uninstalled — so a stale credential can + # still be cleaned up and an explicitly-declared provider stays + # visible. + installed = set(get_available_models().keys()) + well_known_installed = set(PROVIDER_API_KEY_ENV) & installed + # `openai_codex` is gated on `langchain-openai` being installed (we + # surface it whenever `openai` was discovered) rather than on + # `PROVIDER_API_KEY_ENV`, since it has no env var of its own. + codex_installed = {CODEX_PROVIDER} if "openai" in installed else set() + + shown = well_known_installed | codex_installed | stored | config_providers + # Surface well-known providers whose package isn't installed yet as + # greyed-out, install-on-select entries so they stay + # discoverable (mirrors the model selector). Disabled providers and + # ones already shown above are skipped. + self._install_extras = self._uninstalled_known_providers(config, shown) + + # Resolve each manageable entry's auth status once and reuse it for + # both ordering and badge rendering. `_auth_status_for` reads the + # credential file, so resolving it separately in the sort key and in + # `_format_label` would read `auth.json` twice per row (and, on a + # corrupt store, log the same warning twice). A single pass halves both. + services = set(SERVICE_API_KEY_ENV) - shown - set(self._install_extras) + status_by_key = {key: _auth_status_for(key) for key in shown | services} + + # Float entries that already have a credential configured to the top so + # the keys a user is actively using are easiest to find; everything else + # keeps alphabetical order (the `key` tiebreaker). Uninstalled + # install-on-select entries are listed afterwards (alphabetically) since + # selecting them installs a package rather than managing a key. + def sort_key(key: str) -> tuple[int, str]: + configured = status_by_key[key].state is ProviderAuthState.CONFIGURED + return (0 if configured else 1, key) + + manageable = sorted(status_by_key, key=sort_key) + extra_providers = sorted(self._install_extras) + options = [ + Option(self._format_label(key, status=status_by_key[key]), id=key) + for key in manageable + ] + options.extend( + Option(self._format_label(provider, installed=False), id=provider) + for provider in extra_providers + ) + return options, warning + + @staticmethod + def _uninstalled_known_providers( + config: ModelConfig, shown: set[str] + ) -> dict[str, str]: + """Map known providers missing their package to the installing extra. + + Args: + config: Loaded model config, used to skip disabled providers. + shown: Providers already listed (installed/stored/config) to skip. + + Returns: + `{provider: extra}` for each well-known, enabled provider whose + integration package is not installed and has a curated extra. + """ + from deepagents_code.config_manifest import ( + is_provider_package_installed, + provider_install_extra, + ) + + uninstalled: dict[str, str] = {} + for provider in PROVIDER_API_KEY_ENV: + if provider in shown or not config.is_provider_enabled(provider): + continue + extra = provider_install_extra(provider) + if extra is None or is_provider_package_installed(provider): + continue + uninstalled[provider] = extra + return uninstalled + + @staticmethod + def _format_label( + provider: str, + *, + installed: bool = True, + status: ProviderAuthStatus | None = None, + ) -> Content: + """Build a `Content` label for `provider` showing its credential source. + + Args: + provider: Provider config key. + installed: Whether the provider's integration package is installed. + Uninstalled providers render dimmed with a `[not installed]` + marker since selecting them prompts an install, not a key. + status: Precomputed auth status to render. Pass this when the + caller already resolved it to avoid a duplicate credential-file + read; resolved on demand when omitted. Ignored for uninstalled + providers, which render no badge. + + Returns: + A composed `Content` with the provider label and a status badge. + """ + name = provider_display_name(provider) + if not installed: + return Content.assemble( + Content.styled(name, "dim"), + " ", + Content.styled("[not installed]", "dim"), + ) + if status is None: + status = _auth_status_for(provider) + badge = format_auth_badge(status) + return Content.assemble( + Content.from_markup("$provider", provider=name), + " ", + badge, + ) diff --git a/libs/code/deepagents_code/tui/widgets/auto_mode_notice.py b/libs/code/deepagents_code/tui/widgets/auto_mode_notice.py new file mode 100644 index 0000000000..2ece75b6cb --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/auto_mode_notice.py @@ -0,0 +1,235 @@ +"""First-enable confirmation modal for Auto mode. + +Shown at most once per install (per notice version) after Auto successfully +becomes active. Enter keeps Auto and records the notice; Esc reverts to Manual +and leaves the notice unsaved so it can appear again next time. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Markdown, Static + +from deepagents_code._markdown import escape_markdown +from deepagents_code.tui.widgets._links import open_checked_url_async + +if TYPE_CHECKING: + from textual.app import ComposeResult + +AUTO_MODE_DOCS_URL = ( + "https://docs.langchain.com/oss/python/deepagents/code/approval-modes" +) +"""Canonical docs page for Manual / Auto / YOLO behavior.""" + +AUTO_MODE_NOTICE_MODEL_ANCHOR = "**classifier model**" +"""Phrase in `AUTO_MODE_NOTICE_BODY` that the model description replaces. + +`build_auto_mode_notice_body` interpolates against this exact substring, so the +constant and the body sentence must be edited together. Rewording the sentence +without updating this value would silently drop the disclosure — hence the +`ValueError` in the builder rather than a quiet no-op `str.replace`. +""" + +AUTO_MODE_NOTICE_BODY = ( + "You switched to **Auto**. The agent can approve **routine gated actions** " + "without asking first — for example ordinary source edits and read-only " + "Git commands.\n\n" + f"Anything uncertain is reviewed against your **literal request** by " + f"{AUTO_MODE_NOTICE_MODEL_ANCHOR}. If review keeps failing, you're asked " + "to approve.\n\n" + "This is **not a sandbox**. The agent still runs on this machine and can " + "change files, run commands, and use tools when Auto allows them.\n\n" + "This notice appears **once** on this machine after you continue.\n\n" + f"[Learn more about approval modes]({AUTO_MODE_DOCS_URL})" +) +"""Default Markdown body shown on first successful Auto enable. + +Auto involves two distinct roles — the model writing your code and the model +reviewing its gated actions — and `--auto-classifier-model` (or +`[models].auto_classifier`) makes them different models. This modal is the only +place that is disclosed, so the review sentence says which model reviews *and* +whether it is the one writing the code; "active model" would read as the latter. +""" + + +def build_auto_mode_notice_body( + model_label: str | None, *, distinct_from_main_model: bool +) -> str: + """Describe the reviewing model in the Auto notice body. + + Args: + model_label: Spec of the model that reviews gated actions, or `None` + when it is not known (no resolved main-model spec yet). + distinct_from_main_model: Whether that model differs from the one writing + code. Drives the wording, because naming a model without saying + which role it plays is what made the old copy misleading. + + Returns: + Markdown notice body containing the safely escaped model label. + + Raises: + ValueError: If `AUTO_MODE_NOTICE_BODY` no longer contains the + interpolation anchor, which would drop the disclosure silently. + """ + if AUTO_MODE_NOTICE_MODEL_ANCHOR not in AUTO_MODE_NOTICE_BODY: + msg = ( + "AUTO_MODE_NOTICE_BODY is missing " + f"{AUTO_MODE_NOTICE_MODEL_ANCHOR!r}; the Auto classifier model " + "would not be disclosed" + ) + raise ValueError(msg) + named = f" ({escape_markdown(model_label)})" if model_label else "" + if distinct_from_main_model: + description = ( + f"a separate {AUTO_MODE_NOTICE_MODEL_ANCHOR}{named} — not the model " + "writing your code" + ) + else: + description = ( + f"the {AUTO_MODE_NOTICE_MODEL_ANCHOR}, which is the same model " + f"writing your code{named}" + ) + return AUTO_MODE_NOTICE_BODY.replace(AUTO_MODE_NOTICE_MODEL_ANCHOR, description, 1) + + +class AutoModeNoticeScreen(ModalScreen[bool]): + """In-TUI first-run notice describing what Auto mode does. + + Dismisses with `True` on Enter (keep Auto) and `False` on Esc (return to + Manual). Programmatic dismiss may yield `None`; callers treat that like + cancel so Auto is never left active without an explicit continue. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "confirm", "Keep Auto", show=False, priority=True), + Binding("escape", "cancel", "Manual", show=False, priority=True), + ] + + CSS = """ + AutoModeNoticeScreen { + align: center middle; + } + + AutoModeNoticeScreen > Vertical { + width: 72; + max-width: 90%; + height: auto; + background: $surface; + border: solid $warning; + padding: 1 2; + } + + AutoModeNoticeScreen .auto-mode-notice-title { + text-style: bold; + color: $warning; + text-align: center; + margin-bottom: 1; + } + + AutoModeNoticeScreen .auto-mode-notice-body { + height: auto; + color: $text; + margin-bottom: 1; + padding: 0; + } + + AutoModeNoticeScreen .auto-mode-notice-body > * { + margin: 0 0 1 0; + } + + AutoModeNoticeScreen .auto-mode-notice-body > *:last-child { + margin-bottom: 0; + } + + AutoModeNoticeScreen .auto-mode-notice-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + margin-top: 1; + } + """ + + # The screen must be the focus target for its own priority Enter/Esc + # bindings to fire (see `on_mount`); without this the keys reach no handler. + can_focus = True + + def __init__( + self, + body: str | None = None, + *, + model_label: str | None = None, + distinct_from_main_model: bool | None = None, + ) -> None: + """Initialize the notice. + + Args: + body: Optional Markdown body under the title. Defaults to + `AUTO_MODE_NOTICE_BODY`. Links open in a browser. + model_label: Spec of the model that reviews gated actions, added to + the default body. Ignored when `body` is supplied. + distinct_from_main_model: Whether the reviewing model differs from + the model writing code. Required alongside `model_label`; + omitting both leaves the body's generic anchor in place. + """ + super().__init__() + if body is not None: + self._body = body + elif distinct_from_main_model is not None: + self._body = build_auto_mode_notice_body( + model_label, distinct_from_main_model=distinct_from_main_model + ) + else: + self._body = AUTO_MODE_NOTICE_BODY + + def on_mount(self) -> None: + """Take focus so priority bindings receive Enter/Esc.""" + self.focus() + + def compose(self) -> ComposeResult: + """Compose the Auto first-enable notice. + + Yields: + Title, body, and help-row widgets parented inside a `Vertical`. + """ + with Vertical(): + yield Static( + "Auto mode", + classes="auto-mode-notice-title", + markup=False, + ) + # open_links=False so we own the click path (toast feedback + shared + # URL safety). Assistant message widgets use the same pattern. + yield Markdown( + self._body, + classes="auto-mode-notice-body", + open_links=False, + ) + yield Static( + "Enter to keep Auto · Esc for Manual", + classes="auto-mode-notice-help", + markup=False, + ) + + async def on_markdown_link_clicked(self, event: Markdown.LinkClicked) -> None: + """Open docs (or any body link) with the shared URL helper.""" + event.stop() + await open_checked_url_async(event.href, app=self.app, notify_on_success=True) + + def action_confirm(self) -> None: + """Keep Auto and mark the notice dismissed without re-showing.""" + self.dismiss(True) + + def action_cancel(self) -> None: + """Return to Manual without persisting the notice. + + The method name must stay `cancel`: the app owns a priority `escape` + binding that, for an active `ModalScreen`, dispatches to + `action_cancel` if present and otherwise falls through to + `dismiss(None)`. Renaming this would silently regress Esc handling. + """ + self.dismiss(False) diff --git a/libs/code/deepagents_code/tui/widgets/autocomplete.py b/libs/code/deepagents_code/tui/widgets/autocomplete.py new file mode 100644 index 0000000000..7442e4be75 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/autocomplete.py @@ -0,0 +1,900 @@ +"""Autocomplete system for @ mentions and / commands. + +This is a custom implementation that handles trigger-based completion +for slash commands (/) and file mentions (@). +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import shutil + +# S404: subprocess is required for git ls-files to get project file list +import subprocess # noqa: S404 +from difflib import SequenceMatcher +from enum import StrEnum +from pathlib import Path +from typing import TYPE_CHECKING, Protocol + +from deepagents_code.project_utils import find_project_root +from deepagents_code.unicode_security import sanitize_control_chars + +logger = logging.getLogger(__name__) + + +def _get_git_executable() -> str | None: + """Get full path to git executable using shutil.which(). + + Returns: + Full path to git executable, or None if not found. + """ + return shutil.which("git") + + +if TYPE_CHECKING: + from textual import events + + from deepagents_code.command_registry import CommandEntry + + +class CompletionResult(StrEnum): + """Result of handling a key event in the completion system.""" + + IGNORED = "ignored" # Key not handled, let default behavior proceed + HANDLED = "handled" # Key handled, prevent default + SUBMIT = "submit" # Key triggers submission (e.g., Enter on slash command) + + +class CompletionView(Protocol): + """Protocol for views that can display completion suggestions.""" + + def render_completion_suggestions( + self, suggestions: list[tuple[str, str]], selected_index: int + ) -> None: + """Render the completion suggestions popup. + + Args: + suggestions: List of (label, description) tuples + selected_index: Index of currently selected item + """ + ... + + def clear_completion_suggestions(self) -> None: + """Hide/clear the completion suggestions popup.""" + ... + + def replace_completion_range(self, start: int, end: int, replacement: str) -> None: + """Replace text in the input from start to end with replacement. + + Args: + start: Start index in the input text + end: End index in the input text + replacement: Text to insert + """ + ... + + +class CompletionController(Protocol): + """Protocol for completion controllers.""" + + def can_handle(self, text: str, cursor_index: int) -> bool: + """Check if this controller can handle the current input state.""" + ... + + def on_text_changed(self, text: str, cursor_index: int) -> None: + """Called when input text changes.""" + ... + + def on_key( + self, event: events.Key, text: str, cursor_index: int + ) -> CompletionResult: + """Handle a key event. Returns how the event was handled.""" + ... + + def reset(self) -> None: + """Reset/clear the completion state.""" + ... + + +# ============================================================================ +# Slash Command Completion +# ============================================================================ + + +MAX_SUGGESTIONS = 10 +"""UI cap so the completion popup doesn't get unwieldy.""" + +_MIN_SLASH_FUZZY_SCORE = 25 +"""Minimum score for slash-command fuzzy matches.""" + +_MIN_DESC_SEARCH_LEN = 2 +"""Minimum query length to search command descriptions (avoids single-char noise).""" + + +class SlashCommandController: + """Controller for / slash command completion.""" + + def __init__( + self, + commands: list[CommandEntry], + view: CompletionView, + ) -> None: + """Initialize the slash command controller. + + Args: + commands: List of `CommandEntry` instances. + view: View to render suggestions to. + """ + self._commands = commands + self._view = view + self._suggestions: list[tuple[str, str]] = [] + # Machine names aligned by index with `_suggestions`. The popup shows + # each suggestion's label, but completion inserts the machine name so a + # plugin skill shown as `/skill:review` still inserts its full + # `/skill:my-plugin:review`. + self._suggestion_names: list[str] = [] + self._selected_index = 0 + + def update_commands(self, commands: list[CommandEntry]) -> None: + """Replace the commands list and reset suggestions. + + Used to merge dynamically discovered skill commands with + the static command registry at runtime. + + Args: + commands: New list of `CommandEntry` instances. + """ + self._commands = commands + self.reset() + + @staticmethod + def can_handle(text: str, cursor_index: int) -> bool: # noqa: ARG004 # Required by AutocompleteProvider interface + """Handle input that starts with /. + + Returns: + True if text starts with slash, indicating a command. + """ + return text.startswith("/") + + def reset(self) -> None: + """Clear suggestions.""" + if self._suggestions: + self._suggestions.clear() + self._suggestion_names.clear() + self._selected_index = 0 + self._view.clear_completion_suggestions() + + def name_prefix_matches(self, text: str, cursor_index: int) -> list[CommandEntry]: + """Return commands whose names start with the current slash query.""" + if cursor_index < 0 or cursor_index > len(text): + return [] + if not self.can_handle(text, cursor_index): + return [] + + search = text[1:cursor_index].lower() + if not search or " " in search: + return [] + + return [ + entry + for entry in self._commands + if entry.name.lstrip("/").lower().startswith(search) + ] + + @staticmethod + def _score_command(search: str, cmd: str, desc: str, keywords: str = "") -> float: + """Score a command against a search string. Higher = better match. + + Args: + search: Lowercase search string (without leading `/`). + cmd: Command name (e.g. `'/help'`). + desc: Command description text. + keywords: Space-separated hidden keywords for matching. + + Returns: + Score value where higher indicates better match quality. + """ + if not search: + return 0.0 + name = cmd.lstrip("/").lower() + lower_desc = desc.lower() + # Prefix match on command name — highest priority + if name.startswith(search): + return 200.0 + # Substring match on command name + if search in name: + return 150.0 + # Hidden keyword match — treated like a word-boundary description match + if keywords and len(search) >= _MIN_DESC_SEARCH_LEN: + for kw in keywords.lower().split(): + if kw.startswith(search) or search in kw: + return 120.0 + # Substring match on description (require ≥2 chars to avoid single-letter noise) + if len(search) >= _MIN_DESC_SEARCH_LEN and search in lower_desc: + idx = lower_desc.find(search) + # Word-boundary bonus: match at start of description or after a space + if idx == 0 or lower_desc[idx - 1] == " ": + return 110.0 + return 90.0 + # Fuzzy match via SequenceMatcher on name + desc + name_ratio = SequenceMatcher(None, search, name).ratio() + desc_ratio = SequenceMatcher(None, search, lower_desc).ratio() + best = max(name_ratio * 60, desc_ratio * 30) + return best if best >= _MIN_SLASH_FUZZY_SCORE else 0.0 + + def on_text_changed(self, text: str, cursor_index: int) -> None: + """Update suggestions when text changes.""" + if cursor_index < 0 or cursor_index > len(text): + self.reset() + return + + if not self.can_handle(text, cursor_index): + self.reset() + return + + # Get the search string (text after /) + search = text[1:cursor_index].lower() + + # Space means the user finished picking a command — dismiss popup + if " " in search: + self.reset() + return + + if not search: + # No search text — show all commands. Display the label, but keep + # the machine name aligned for insertion. + selected = list(self._commands)[:MAX_SUGGESTIONS] + else: + # Score and filter commands using fuzzy matching. Matching runs on + # the machine name so the full namespaced name is always reachable. + scored = [ + (score, entry) + for entry in self._commands + if ( + score := self._score_command( + search, entry.name, entry.description, entry.hidden_keywords + ) + ) + > 0 + ] + scored.sort(key=lambda x: -x[0]) + selected = [entry for _, entry in scored[:MAX_SUGGESTIONS]] + + if selected: + self._suggestions = [ + (entry.label(), entry.description) for entry in selected + ] + self._suggestion_names = [entry.name for entry in selected] + self._selected_index = 0 + self._view.render_completion_suggestions( + self._suggestions, self._selected_index + ) + else: + self.reset() + + def on_key( + self, event: events.Key, _text: str, cursor_index: int + ) -> CompletionResult: + """Handle key events for navigation and selection. + + Returns: + CompletionResult indicating how the key was handled. + """ + if not self._suggestions: + return CompletionResult.IGNORED + + match event.key: + case "tab" | "space": + if self._apply_selected_completion(cursor_index): + return CompletionResult.HANDLED + return CompletionResult.IGNORED + case "enter": + if self._apply_selected_completion(cursor_index): + return CompletionResult.SUBMIT + return CompletionResult.HANDLED + case "down": + self._move_selection(1) + return CompletionResult.HANDLED + case "up": + self._move_selection(-1) + return CompletionResult.HANDLED + case "escape": + self.reset() + return CompletionResult.HANDLED + case _: + return CompletionResult.IGNORED + + def _move_selection(self, delta: int) -> None: + """Move selection up or down.""" + if not self._suggestions: + return + count = len(self._suggestions) + self._selected_index = (self._selected_index + delta) % count + self._view.render_completion_suggestions( + self._suggestions, self._selected_index + ) + + def _apply_selected_completion(self, cursor_index: int) -> bool: + """Apply the currently selected completion. + + Returns: + True if completion was applied, False if no suggestions. + """ + if not self._suggestions: + return False + + # Insert the machine name (aligned by index), not the displayed label. + command = self._suggestion_names[self._selected_index] + # Replace from start to cursor with the command + self._view.replace_completion_range(0, cursor_index, command) + self.reset() + return True + + def apply_name_prefix_completion( + self, match: CommandEntry, cursor_index: int + ) -> None: + """Apply a command-name prefix match. + + Args: + match: Command entry to apply. + cursor_index: Cursor index in completion-space coordinates. + """ + self._view.replace_completion_range(0, cursor_index, match.name) + self.reset() + + +# ============================================================================ +# Fuzzy File Completion (scoped to current working directory) +# ============================================================================ + +# Constants for fuzzy file completion +_MAX_FALLBACK_FILES = 1000 +"""Hard cap on files returned by the non-git glob fallback.""" + +_MIN_FUZZY_SCORE = 15 +"""Minimum score to include in file-completion results.""" + +_MIN_FUZZY_RATIO = 0.4 +"""SequenceMatcher threshold for filename-only fuzzy matches.""" + +_NOT_A_REPO_MARKER = "not a git repository" +"""Marker in `git ls-files` stderr for a non-repository directory. + +Running outside a work tree exits 128 and prints a "fatal: not a git +repository" message. That case intentionally falls back to a glob walk, so it +is left unlogged to avoid noise. +""" + +_GIT_STDERR_LOG_LIMIT = 500 +"""Max characters of git stderr to include in a diagnostic log line.""" + + +def _run_git_ls_files( + git_path: str, root: Path, extra_args: list[str] +) -> tuple[bool, list[str]]: + """Run `git ls-files` with the given arguments and return file paths. + + Args: + git_path: Full path to the git executable. + root: Directory to run the command in. + extra_args: Flags appended after `ls-files`, e.g. + `["--others", "--exclude-standard"]`. + + Returns: + Tuple of success status and relative file paths. Success is `False` + when git could not be run or exited non-zero, signalling the caller + to fall back to a glob walk. + """ + try: + # S603: git_path validated via shutil.which(); ls-files args are + # caller-supplied literals. + result = subprocess.run( # noqa: S603 + [git_path, "ls-files", *extra_args], + cwd=root, + capture_output=True, + text=True, + timeout=5, + check=False, + # Git localizes stderr; use C so the non-repo marker stays stable. + env={**os.environ, "LC_ALL": "C"}, + ) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + logger.debug("git ls-files %s failed to run", extra_args, exc_info=True) + return False, [] + if result.returncode != 0: + stderr = sanitize_control_chars(result.stderr, max_length=_GIT_STDERR_LOG_LIMIT) + # Running outside a work tree exits 128 with a "not a git repository" + # fatal message. That is the expected trigger for the glob fallback, so + # keep it quiet. Everything else is a genuine failure worth logging with + # enough context (root/cwd, args, exit code, stripped stderr) to debug. + if _NOT_A_REPO_MARKER not in stderr.lower(): + logger.debug( + "git ls-files failed: root=%s args=%s exit=%d stderr=%s", + root, + extra_args, + result.returncode, + stderr, + ) + return False, [] + return True, [f for f in result.stdout.strip().split("\n") if f] + + +def _get_project_files(root: Path) -> list[str]: + """Get project files using git ls-files or fallback to glob. + + Includes both tracked files and untracked files that are not ignored + (via `--others --exclude-standard`), so freshly created files surface + in `@` completion without needing to be committed first. + + Returns: + List of relative file paths from project root. + """ + git_path = _get_git_executable() + if git_path: + tracked_ok, tracked = _run_git_ls_files(git_path, root, []) + if tracked_ok: + # The untracked scan is optional; if it fails or times out, keep the + # already-successful tracked list rather than dropping to the glob + # fallback (which only walks a few levels deep). + _, untracked = _run_git_ls_files( + git_path, root, ["--others", "--exclude-standard"] + ) + seen: set[str] = set() + files: list[str] = [] + for f in (*tracked, *untracked): + if f not in seen: + seen.add(f) + files.append(f) + return files + + # Fallback: simple glob (limited depth to avoid slowness) + files = [] + try: + for pattern in ["*", "*/*", "*/*/*", "*/*/*/*"]: + for p in root.glob(pattern): + if p.is_file() and not any(part.startswith(".") for part in p.parts): + files.append(p.relative_to(root).as_posix()) + if len(files) >= _MAX_FALLBACK_FILES: + break + if len(files) >= _MAX_FALLBACK_FILES: + break + except OSError: + logger.debug("glob fallback failed for %s", root, exc_info=True) + return files + + +def _fuzzy_score(query: str, candidate: str) -> float: + """Score a candidate against query. Higher = better match. + + Returns: + Score value where higher indicates better match quality. + """ + query_lower = query.lower() + # Normalize path separators for cross-platform support + candidate_normalized = candidate.replace("\\", "/") + candidate_lower = candidate_normalized.lower() + + # Extract filename for matching (prioritize filename over full path) + filename = candidate_normalized.rsplit("/", 1)[-1].lower() + filename_start = candidate_lower.rfind("/") + 1 + + # Check filename first (higher priority) + if query_lower in filename: + idx = filename.find(query_lower) + # Bonus for being at start of filename + if idx == 0: + return 150 + (1 / len(candidate)) + # Bonus for word boundary in filename + if idx > 0 and filename[idx - 1] in "_-.": + return 120 + (1 / len(candidate)) + return 100 + (1 / len(candidate)) + + # Check full path + if query_lower in candidate_lower: + idx = candidate_lower.find(query_lower) + # At start of filename + if idx == filename_start: + return 80 + (1 / len(candidate)) + # At word boundary in path + if idx == 0 or candidate[idx - 1] in "/_-.": + return 60 + (1 / len(candidate)) + return 40 + (1 / len(candidate)) + + # Fuzzy match on filename only (more relevant) + filename_ratio = SequenceMatcher(None, query_lower, filename).ratio() + if filename_ratio > _MIN_FUZZY_RATIO: + return filename_ratio * 30 + + # Fallback: fuzzy on full path + ratio = SequenceMatcher(None, query_lower, candidate_lower).ratio() + return ratio * 15 + + +def _is_dotpath(path: str) -> bool: + """Check if path contains dotfiles/dotdirs (e.g., .github/...). + + Returns: + True if path contains hidden directories or files. + """ + return any(part.startswith(".") for part in path.split("/")) + + +def _path_depth(path: str) -> int: + """Get depth of path (number of / separators). + + Returns: + Number of path separators in the path. + """ + return path.count("/") + + +def _fuzzy_search( + query: str, + candidates: list[str], + limit: int = 10, + *, + include_dotfiles: bool = False, +) -> list[str]: + """Return top matches sorted by score. + + Args: + query: Search query + candidates: List of file paths to search + limit: Max results to return + include_dotfiles: Whether to include dotfiles (default False) + + Returns: + List of matching file paths sorted by relevance score. + """ + # Filter dotfiles unless explicitly searching for them + filtered = ( + candidates + if include_dotfiles + else [c for c in candidates if not _is_dotpath(c)] + ) + + if not query: + # Empty query: show root-level files first, sorted by depth then name + sorted_files = sorted(filtered, key=lambda p: (_path_depth(p), p.lower())) + return sorted_files[:limit] + + scored = [ + (score, c) + for c in filtered + if (score := _fuzzy_score(query, c)) >= _MIN_FUZZY_SCORE + ] + scored.sort(key=lambda x: -x[0]) + return [c for _, c in scored[:limit]] + + +def _scope_files_to_cwd(files: list[str], project_root: Path, cwd: Path) -> list[str]: + """Scope a project-root-relative file list to paths under `cwd`. + + Args: + files: File paths relative to `project_root` (as produced by + `_get_project_files`). + project_root: Directory the `files` paths are relative to. + cwd: Directory to scope suggestions to. + + Returns: + Paths rewritten relative to `cwd`, filtered to that subtree (possibly + empty), when `cwd` is nested under `project_root`. The input list + unchanged when `cwd` equals `project_root`. An empty list when `cwd` is + not under `project_root`: the paths are project-root-relative and would + resolve to the wrong base from `cwd`, so fail closed rather than offer + misleading suggestions. + """ + if cwd == project_root: + return files + try: + relative_cwd = cwd.relative_to(project_root).as_posix() + except ValueError: + return [] + prefix = f"{relative_cwd}/" + return [path[len(prefix) :] for path in files if path.startswith(prefix)] + + +class FuzzyFileController: + """Controller for @ file completion with fuzzy matching from current cwd.""" + + def __init__( + self, + view: CompletionView, + cwd: Path | None = None, + ) -> None: + """Initialize the fuzzy file controller. + + Args: + view: View to render suggestions to + cwd: Current working directory for file completion scope + """ + self._view = view + self._cwd = (cwd or Path.cwd()).resolve() + self._project_root = find_project_root(self._cwd) or self._cwd + self._suggestions: list[tuple[str, str]] = [] + self._selected_index = 0 + self._file_cache: list[str] | None = None + # When True, `_project_root` is a provisional value (set synchronously by + # `set_cwd`) and the real project root is resolved off the event loop in + # `warm_cache`. See `set_cwd` for why discovery is deferred. + self._project_root_pending = False + self._cache_generation = 0 + + def _get_files(self) -> list[str]: + """Get cached file list or refresh. + + Returns: + List of project file paths. + """ + if self._file_cache is None: + files = _get_project_files(self._project_root) + self._file_cache = _scope_files_to_cwd(files, self._project_root, self._cwd) + return self._file_cache + + def refresh_cache(self) -> None: + """Force refresh of file cache.""" + self._cache_generation += 1 + self._file_cache = None + + def set_cwd(self, cwd: Path) -> None: + """Switch completion roots to a new cwd. + + Roots completion at `cwd` immediately and invalidates the file cache. + Project-root discovery (`find_project_root`) walks the filesystem, so it + is deferred to `warm_cache` (which runs in a worker thread) rather than + run here on the event loop. Until then `cwd` is used as a provisional + root, which is a safe narrower scope. + """ + self._cache_generation += 1 + self._cwd = cwd.resolve() + self._project_root = self._cwd + self._project_root_pending = True + self._file_cache = None + self.reset() + + async def warm_cache(self, *, force: bool = False) -> None: + """Pre-populate the file cache off the event loop. + + Also resolves a project root deferred by `set_cwd`, so the blocking + filesystem walk runs in a worker thread instead of on the event loop. + + Warmers are scheduled non-exclusively, so quick cwd/cache invalidations + can run concurrently. The generation is snapshotted once before the first + await and re-checked after each await, so a warmer whose generation has + been superseded drops its results instead of overwriting controller state + belonging to a newer generation. (Snapshotting again before the second + await would defeat the guard: it would match the post-supersession + generation and let a stale-root file walk win.) + + Args: + force: Re-walk and swap in a fresh file list even when the cache is + already populated. Used by the periodic background refresh so + files created or deleted mid-session surface in `@` completion. + The existing cache stays visible until the new walk completes. + """ + cwd = self._cwd + generation = self._cache_generation + if self._project_root_pending: + root = await asyncio.to_thread(find_project_root, cwd) + if generation != self._cache_generation: + # A newer cwd/cache invalidation superseded this warmer. + return + resolved = root or cwd + if resolved != self._project_root: + # The real root differs from the provisional `cwd`; drop any + # cache built against the narrower scope. + self._file_cache = None + self._project_root = resolved + self._project_root_pending = False + if not force and self._file_cache is not None: + return + project_root = self._project_root + # Best-effort: on failure the existing cache (if any) stays in place. A + # cold cache (`_file_cache is None`) is later filled synchronously by + # `_get_files()`; a force refresh that fails simply leaves the prior + # list visible. Log at debug so a recurring background refresh failure + # (the 30s timer) is diagnosable rather than silently stale. + try: + files = await asyncio.to_thread(_get_project_files, project_root) + if generation == self._cache_generation: + self._file_cache = _scope_files_to_cwd(files, project_root, cwd) + except Exception: # best-effort refresh; prior cache is the fallback + logger.debug("File-cache warm failed for %s", project_root, exc_info=True) + + @staticmethod + def can_handle(text: str, cursor_index: int) -> bool: + """Handle input that contains @ not followed by space. + + Returns: + True if cursor is after @ and within a file mention context. + """ + if cursor_index <= 0 or cursor_index > len(text): + return False + + before_cursor = text[:cursor_index] + if "@" not in before_cursor: + return False + + at_index = before_cursor.rfind("@") + if cursor_index <= at_index: + return False + + # Fragment from @ to cursor must not contain spaces + fragment = before_cursor[at_index:cursor_index] + return bool(fragment) and " " not in fragment + + def reset(self) -> None: + """Clear suggestions.""" + if self._suggestions: + self._suggestions.clear() + self._selected_index = 0 + self._view.clear_completion_suggestions() + + def on_text_changed(self, text: str, cursor_index: int) -> None: + """Update suggestions when text changes.""" + if not self.can_handle(text, cursor_index): + self.reset() + return + + before_cursor = text[:cursor_index] + at_index = before_cursor.rfind("@") + search = before_cursor[at_index + 1 :] + + suggestions = self._get_fuzzy_suggestions(search) + + if suggestions: + self._suggestions = suggestions + self._selected_index = 0 + self._view.render_completion_suggestions( + self._suggestions, self._selected_index + ) + else: + self.reset() + + def _get_fuzzy_suggestions(self, search: str) -> list[tuple[str, str]]: + """Get fuzzy file suggestions. + + Returns: + List of (label, type_hint) tuples for matching files. + """ + files = self._get_files() + # Include dotfiles only if query starts with "." + include_dots = search.startswith(".") + matches = _fuzzy_search( + search, files, limit=MAX_SUGGESTIONS, include_dotfiles=include_dots + ) + + suggestions: list[tuple[str, str]] = [] + for path in matches: + # Get file extension for type hint + ext = Path(path).suffix.lower() + type_hint = ext[1:] if ext else "file" + suggestions.append((f"@{path}", type_hint)) + + return suggestions + + def on_key( + self, event: events.Key, text: str, cursor_index: int + ) -> CompletionResult: + """Handle key events for navigation and selection. + + Returns: + CompletionResult indicating how the key was handled. + """ + if not self._suggestions: + return CompletionResult.IGNORED + + match event.key: + case "tab" | "enter": + if self._apply_selected_completion(text, cursor_index): + return CompletionResult.HANDLED + return CompletionResult.IGNORED + case "down": + self._move_selection(1) + return CompletionResult.HANDLED + case "up": + self._move_selection(-1) + return CompletionResult.HANDLED + case "escape": + self.reset() + return CompletionResult.HANDLED + case _: + return CompletionResult.IGNORED + + def _move_selection(self, delta: int) -> None: + """Move selection up or down.""" + if not self._suggestions: + return + count = len(self._suggestions) + self._selected_index = (self._selected_index + delta) % count + self._view.render_completion_suggestions( + self._suggestions, self._selected_index + ) + + def _apply_selected_completion(self, text: str, cursor_index: int) -> bool: + """Apply the currently selected completion. + + Returns: + True if completion was applied, False if no suggestions or invalid state. + """ + if not self._suggestions: + return False + + label, _ = self._suggestions[self._selected_index] + before_cursor = text[:cursor_index] + at_index = before_cursor.rfind("@") + + if at_index < 0: + return False + + # Replace from @ to cursor with the completion + self._view.replace_completion_range(at_index, cursor_index, label) + self.reset() + return True + + +# Keep old name as alias for backwards compatibility +PathCompletionController = FuzzyFileController + + +# ============================================================================ +# Multi-Completion Manager +# ============================================================================ + + +class MultiCompletionManager: + """Manages multiple completion controllers, delegating to the active one.""" + + def __init__(self, controllers: list[CompletionController]) -> None: + """Initialize with a list of controllers. + + Args: + controllers: List of completion controllers (checked in order) + """ + self._controllers = controllers + self._active: CompletionController | None = None + + def on_text_changed(self, text: str, cursor_index: int) -> None: + """Handle text change, activating the appropriate controller.""" + # Find the first controller that can handle this input + candidate = None + for controller in self._controllers: + if controller.can_handle(text, cursor_index): + candidate = controller + break + + # No controller can handle - reset if we had one active + if candidate is None: + if self._active is not None: + self._active.reset() + self._active = None + return + + # Switch to new controller if different + if candidate is not self._active: + if self._active is not None: + self._active.reset() + self._active = candidate + + # Let the active controller process the change + candidate.on_text_changed(text, cursor_index) + + def on_key( + self, event: events.Key, text: str, cursor_index: int + ) -> CompletionResult: + """Handle key event, delegating to active controller. + + Returns: + CompletionResult from active controller, or IGNORED if none active. + """ + if self._active is None: + return CompletionResult.IGNORED + return self._active.on_key(event, text, cursor_index) + + def reset(self) -> None: + """Reset all controllers.""" + if self._active is not None: + self._active.reset() + self._active = None diff --git a/libs/code/deepagents_code/tui/widgets/chat_input.py b/libs/code/deepagents_code/tui/widgets/chat_input.py new file mode 100644 index 0000000000..14ae6da54f --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/chat_input.py @@ -0,0 +1,2931 @@ +"""Chat input widget for deepagents-code with autocomplete and history support.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, assert_never + +from rich.cells import cell_len +from rich.segment import Segment +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.content import Content +from textual.css.query import NoMatches +from textual.geometry import Offset, Size +from textual.message import Message +from textual.reactive import reactive +from textual.strip import Strip +from textual.widgets import Static, TextArea + +from deepagents_code import theme +from deepagents_code.command_registry import CommandEntry, get_slash_commands +from deepagents_code.config import ( + MODE_DISPLAY_GLYPHS, + MODE_PREFIXES, + detect_mode_prefix, + is_ascii_mode, +) +from deepagents_code.input import IMAGE_PLACEHOLDER_PATTERN, VIDEO_PLACEHOLDER_PATTERN +from deepagents_code.paste_collapse import ( + PASTE_PLACEHOLDER_PATTERN, + PastedContent, + count_lines, + expand_paste_refs, + format_paste_ref, + should_collapse_paste, +) +from deepagents_code.tui.widgets._paste_textarea import ( + PasteBurstTextArea, + _collapse_pastes_enabled, +) +from deepagents_code.tui.widgets.autocomplete import ( + CompletionResult, + FuzzyFileController, + MultiCompletionManager, + SlashCommandController, +) +from deepagents_code.tui.widgets.history import HistoryManager + +logger = logging.getLogger(__name__) + + +def _default_history_path() -> Path: + """Return the default history file path. + + Extracted as a function so tests can monkeypatch it to a temp path, + preventing test runs from polluting `~/.deepagents/.state/history.jsonl`. + """ + from deepagents_code.model_config import DEFAULT_STATE_DIR + + return DEFAULT_STATE_DIR / "history.jsonl" + + +_LOCK_KEYS = frozenset({"caps_lock", "num_lock", "scroll_lock"}) +"""Lock keys that must never insert text. + +Under the kitty keyboard protocol with associated-text reporting (VS Code's +xterm.js and others), pressing a lock key arrives as a `Key` event whose +`character` is the text that *would* have been produced by the next key — +e.g. pressing Caps Lock reports `key='caps_lock'`, `character='A'`. Textual's +parser does not strip this, so `TextArea` inserts a stray letter. We drop +these events entirely. Terminals encode lock keys in several shapes (iTerm2 +notably differs from kitty/Ghostty); `_textual_patches.py` is the canonical +reference and neutralizes every shape at the parser. See the kitty keyboard +protocol spec (functional key definitions) for background. +""" + + +_FILE_CACHE_WORKER_GROUP = "file-cache" +"""Textual worker group for all `@` file-completion cache warmers.""" + +_REFOCUS_CLICK_SUPPRESS_WINDOW_SECONDS = 0.3 +"""Window after a terminal focus regain during which a click only refocuses. + +When the terminal window is unfocused and the user clicks back in, we rely on +the OS delivering a `FocusIn` (Textual `AppFocus`) before the mouse report — +the same FocusIn support `on_app_focus` documents. Terminals without it never +arm suppression, so clicks just behave normally (the cursor moves). A +mouse-down landing within this window after the focus regain is treated as +focus-only so the cursor stays put instead of jumping to the click location. + +The window trades off two failure modes: too small and a genuine refocus click +leaks through and moves the cursor (the bug this guards against); too large and +an intentional click made shortly after refocusing is wrongly suppressed. 0.3s +comfortably covers the FocusIn-to-mouse-report latency while staying below a +deliberate click-pause-click interaction. +""" + +_FILE_CACHE_REFRESH_INTERVAL_SECONDS = 30.0 +"""How often to refresh the `@` file-completion cache in the background. + +The cache is pre-warmed on mount and re-warmed on cwd switches, but files +created or deleted mid-session would otherwise stay stale until the next switch. +A periodic refresh keeps `@` suggestions current; the walk runs off the event +loop and swaps in atomically, so it never blocks typing.""" + +if TYPE_CHECKING: + from textual import events + from textual.app import ComposeResult + from textual.events import Click + + from deepagents_code.config_manifest import CursorStyle + from deepagents_code.input import MediaTracker, ParsedPastedPathPayload + + +def _should_collapse_chat_paste(text: str) -> bool: + """Return whether pasted chat text should be collapsed.""" + return detect_mode_prefix(text) is None and should_collapse_paste(text) + + +_PASTE_COLLAPSED_TOAST = "Large paste collapsed. Paste again to expand." +"""Toast shown when a paste collapses into a `[Pasted text #N]` placeholder. + +Emitted only for a new collapse, not when a repeat paste expands an existing +placeholder back to full text. +""" + + +class CompletionOption(Static): + """A clickable completion option in the autocomplete popup.""" + + DEFAULT_CSS = """ + CompletionOption { + height: 1; + padding: 0 1; + } + + CompletionOption:hover { + background: $surface-lighten-1; + } + + CompletionOption.completion-option-selected { + background: $primary; + color: $background; + text-style: bold; + } + + CompletionOption.completion-option-selected:hover { + background: $primary-lighten-1; + } + """ + + class Clicked(Message): + """Message sent when a completion option is clicked.""" + + def __init__(self, index: int) -> None: + """Initialize with the clicked option index.""" + super().__init__() + self.index = index + + def __init__( + self, + label: str, + description: str, + index: int, + is_selected: bool = False, + **kwargs: Any, + ) -> None: + """Initialize the completion option. + + Args: + label: The main label text (e.g., command name or file path) + description: Secondary description text + index: Index of this option in the suggestions list + is_selected: Whether this option is currently selected + **kwargs: Additional arguments for parent + """ + super().__init__(**kwargs) + self._label = label + self._description = description + self._index = index + self._is_selected = is_selected + + def on_mount(self) -> None: + """Set up the option display on mount.""" + self._update_display() + + def _update_display(self) -> None: + """Update the display text and styling.""" + display_label = self._label.removeprefix("/") + if self._description: + content = Content.from_markup( + "[bold]$label[/bold] [dim]$desc[/dim]", + label=display_label, + desc=self._description, + ) + else: + content = Content.from_markup("[bold]$label[/bold]", label=display_label) + + self.update(content) + + if self._is_selected: + self.add_class("completion-option-selected") + else: + self.remove_class("completion-option-selected") + + def set_selected(self, *, selected: bool) -> None: + """Update the selected state of this option.""" + if self._is_selected != selected: + self._is_selected = selected + self._update_display() + + def set_content( + self, label: str, description: str, index: int, *, is_selected: bool + ) -> None: + """Replace label, description, index, and selection in-place.""" + self._label = label + self._description = description + self._index = index + self._is_selected = is_selected + self._update_display() + + def on_click(self, event: Click) -> None: + """Handle click on this option.""" + event.stop() + self.post_message(self.Clicked(self._index)) + + +InputAction = Literal["clear", "copy"] +"""Closed set of actions an `InputActionButton` can dispatch.""" + + +class InputActionButton(Static): + """Small clickable button shown at the right edge of the chat input row. + + Provides discoverable mouse alternatives to keyboard shortcuts for + clearing (`[ X ]`) and copying (`[ COPY ]`) the current draft. + """ + + DEFAULT_CSS = """ + InputActionButton { + height: 1; + margin: 0 0 0 1; + text-style: bold; + } + + InputActionButton.input-action-clear { + width: 5; + color: $error; + } + + InputActionButton.input-action-copy { + width: 8; + color: $primary; + } + + InputActionButton.input-action-clear:hover { + background: $error; + color: auto; + } + + InputActionButton.input-action-copy:hover { + background: $primary; + color: auto; + } + """ + + class Clicked(Message): + """Message sent when an input action button is clicked.""" + + def __init__(self, action: InputAction) -> None: + """Initialize with the action identifier (`clear` or `copy`).""" + super().__init__() + self.action = action + + @property + def allow_select(self) -> bool: + """Disable terminal text selection for the action label.""" + return False + + def __init__(self, label: str, action: InputAction, **kwargs: Any) -> None: + """Initialize the button with a label and an action identifier.""" + super().__init__(label, markup=False, **kwargs) + self._action = action + + def on_click(self, event: Click) -> None: + """Relay the click as a typed `Clicked` message.""" + event.stop() + self.post_message(self.Clicked(self._action)) + + +class CompletionPopup(VerticalScroll): + """Popup widget that displays completion suggestions as clickable options.""" + + DEFAULT_CSS = """ + CompletionPopup { + display: none; + height: auto; + max-height: 12; + } + """ + + class OptionClicked(Message): + """Message sent when a completion option is clicked.""" + + def __init__(self, index: int) -> None: + """Initialize with the clicked option index.""" + super().__init__() + self.index = index + + def __init__(self, **kwargs: Any) -> None: + """Initialize the completion popup.""" + super().__init__(**kwargs) + self.can_focus = False + self._options: list[CompletionOption] = [] + self._selected_index = 0 + self._pending_suggestions: list[tuple[str, str]] = [] + self._pending_selected: int = 0 + self._rebuild_generation: int = 0 + + def update_suggestions( + self, suggestions: list[tuple[str, str]], selected_index: int + ) -> None: + """Update the popup with new suggestions.""" + if not suggestions: + self.hide() + return + + self._selected_index = selected_index + self._pending_suggestions = suggestions + self._pending_selected = selected_index + # Increment generation so stale callbacks from prior calls are skipped. + self._rebuild_generation += 1 + gen = self._rebuild_generation + # show() is still deferred to _rebuild_options to avoid stale content, + # but the rebuild runs before the next paint so prompt and popup changes + # appear in the same frame. + self.call_next(lambda: self._rebuild_options(gen)) + + async def _rebuild_options(self, generation: int) -> None: + """Rebuild option widgets from pending suggestions. + + Reuses existing DOM nodes where possible to avoid flicker from + a full teardown/mount cycle while the popup is visible. + + Args: + generation: Caller's generation counter; skipped if superseded. + """ + if generation != self._rebuild_generation: + return + + suggestions = self._pending_suggestions + selected_index = self._pending_selected + + if not suggestions: + self.hide() + return + + existing = len(self._options) + needed = len(suggestions) + + # Update existing widgets in-place + for i in range(min(existing, needed)): + label, desc = suggestions[i] + self._options[i].set_content( + label, desc, i, is_selected=(i == selected_index) + ) + + # DOM mutations: trim extras / mount new widgets + try: + if existing > needed: + for option in self._options[needed:]: + await option.remove() + del self._options[needed:] + + if needed > existing: + new_widgets: list[CompletionOption] = [] + for idx in range(existing, needed): + label, desc = suggestions[idx] + option = CompletionOption( + label=label, + description=desc, + index=idx, + is_selected=(idx == selected_index), + ) + new_widgets.append(option) + self._options.extend(new_widgets) + await self.mount(*new_widgets) + except Exception: + logger.exception("Failed to rebuild completion popup; hiding to recover") + self._options = [] + with contextlib.suppress(Exception): + await self.remove_children() + self.hide() + return + + # The DOM mutations above can await, during which a hide() (or a newer + # rebuild) bumps the generation to cancel this one. The top-of-function + # guard ran before that await, so re-check here: without it a stale + # rebuild would re-show a popup that was dismissed mid-flight (e.g. when + # a completion is applied and the popup hidden in the same key press). + if generation != self._rebuild_generation: + return + + self.show() + + if 0 <= selected_index < len(self._options): + self._options[selected_index].scroll_visible() + + def update_selection(self, selected_index: int) -> None: + """Update which option is selected without rebuilding the list.""" + # Keep pending state in sync so an in-flight _rebuild_options uses + # the latest selection. + self._pending_selected = selected_index + + if self._selected_index == selected_index: + return + + # Deselect previous + if 0 <= self._selected_index < len(self._options): + self._options[self._selected_index].set_selected(selected=False) + + # Select new + self._selected_index = selected_index + if 0 <= selected_index < len(self._options): + self._options[selected_index].set_selected(selected=True) + self._options[selected_index].scroll_visible() + + def on_completion_option_clicked(self, event: CompletionOption.Clicked) -> None: + """Handle click on a completion option.""" + event.stop() + self.post_message(self.OptionClicked(event.index)) + + def hide(self) -> None: + """Hide the popup.""" + self._pending_suggestions = [] + self._rebuild_generation += 1 # Cancel any in-flight rebuild + self.styles.display = "none" # ty: ignore[invalid-assignment] # Textual accepts string display values at runtime + + def show(self) -> None: + """Show the popup.""" + self.styles.display = "block" + + +class ChatTextArea(PasteBurstTextArea): + """TextArea subclass with custom key handling for chat input. + + Modifier-Enter / Ctrl+J newline bindings and the VSCode backslash+enter + fallback are inherited from `PasteBurstTextArea`. + """ + + _skip_history_change_events: int + """Counter incremented before a history-driven text replacement so the + resulting `TextArea.Changed` event (which fires on the next message-loop + iteration) can be suppressed. `ChatInput.on_text_area_changed` decrements + the counter. + """ + + class Submitted(Message): + """Message sent when text is submitted.""" + + def __init__(self, value: str) -> None: + """Initialize with submitted value.""" + self.value = value + super().__init__() + + class HistoryPrevious(Message): + """Request previous history entry.""" + + def __init__(self, current_text: str) -> None: + """Initialize with current text for saving.""" + self.current_text = current_text + super().__init__() + + class HistoryNext(Message): + """Request next history entry.""" + + class PastedPaths(Message): + """Message sent when paste payload resolves to file paths.""" + + def __init__(self, raw_text: str, paths: list[Path]) -> None: + """Initialize with raw pasted text and parsed file paths.""" + self.raw_text = raw_text + self.paths = paths + super().__init__() + + class PastedText(Message): + """Message sent when a paste is large enough to be collapsed. + + The full text is carried in the message so `ChatInput` can store it + and insert a compact placeholder into the text area instead. + """ + + def __init__(self, text: str) -> None: + """Initialize with the full pasted text. + + Args: + text: The complete pasted text content. + """ + self.text = text + super().__init__() + + class Typing(Message): + """Posted when the user presses a printable key or backspace. + + Relayed by `ChatInput` as `ChatInput.Typing` for the app to track + typing activity. + """ + + argument_hint: reactive[str] = reactive("") + """Inline slash-command argument hint rendered at the end of the line.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize the chat text area.""" + # Remove placeholder if passed, TextArea doesn't support it the same way + kwargs.pop("placeholder", None) + super().__init__(**kwargs) + self._chat_input_owner: ChatInput | None = None + self._skip_history_change_events = 0 + self._completion_active = False + # Paste-burst and backslash-pending state is initialized by + # PasteBurstTextArea.__init__. + # Tracks terminal focus so a click that re-focuses the window only + # restores focus instead of also moving the cursor. See + # `_REFOCUS_CLICK_SUPPRESS_WINDOW_SECONDS`. + self._app_blurred = False + self._refocus_time: float | None = None + + def render_line(self, y: int) -> Strip: + """Render a single line, appending any argument hint at line end. + + The built-in `TextArea.suggestion` renders at the cursor position, + but slash-command argument hints should stay attached to the end of the + command text regardless of cursor movement. + + Args: + y: Y Coordinate of line relative to the widget region. + + Returns: + A rendered line. + """ + strip = super().render_line(y) + if not self._should_render_argument_hint(): + return strip + + line_info = self._get_visual_line_info(y) + if line_info is None: + return strip + + line_index, section_offset = line_info + if not self._is_argument_hint_section(line_index, section_offset): + return strip + + content_cells = self._get_section_cell_length(line_index, section_offset) + if content_cells >= strip.cell_length: + return strip + + prefix = strip.crop(0, content_cells) + suffix = strip.crop(content_cells, strip.cell_length) + suffix_width = suffix.cell_length + cursor_on_hint = self._cursor_at_argument_hint_anchor(line_index) + if cursor_on_hint and suffix_width > 0: + suffix = suffix.crop(1, suffix.cell_length) + + hint_strip = self._build_argument_hint_strip(cursor_on_hint=cursor_on_hint) + tail = Strip.join([hint_strip, suffix]).crop(0, suffix_width) + return Strip.join([prefix, tail]) + + def _should_render_argument_hint(self) -> bool: + """Return whether the inline argument hint should be rendered.""" + return bool( + self.argument_hint and (self.has_focus or not self.hide_suggestion_on_blur) + ) + + def _get_visual_line_info(self, y: int) -> tuple[int, int] | None: + """Map a widget-relative y coordinate to wrapped line metadata. + + Returns: + Tuple of `(line_index, section_offset)` for the wrapped line at `y`, + otherwise `None` when `y` is outside the wrapped document. + """ + _scroll_x, scroll_y = self.scroll_offset + absolute_y = scroll_y + y + # Private Textual API (verified against textual 3.x); revisit on + # major Textual upgrades. + try: + offset_map = self.wrapped_document._offset_to_line_info + except AttributeError: + logger.warning( + "WrappedDocument._offset_to_line_info not found; " + "argument hint rendering disabled (Textual API change?)" + ) + return None + if absolute_y < 0 or absolute_y >= len(offset_map): + return None + entry = offset_map[absolute_y] + expected_length = 2 # (line_index, section_offset) + if not isinstance(entry, tuple) or len(entry) != expected_length: + logger.warning("Unexpected offset_map entry: %r", entry) + return None + return entry + + def _is_argument_hint_section(self, line_index: int, section_offset: int) -> bool: + """Return whether a wrapped section owns the end-of-line hint.""" + if line_index != self.document.line_count - 1: + return False + return section_offset == len(self.wrapped_document.get_offsets(line_index)) + + def _get_section_cell_length(self, line_index: int, section_offset: int) -> int: + """Return the rendered cell width of a wrapped text section.""" + wrapped_sections = self.wrapped_document.get_sections(line_index) + if section_offset < 0 or section_offset >= len(wrapped_sections): + return 0 + section_text = wrapped_sections[section_offset].expandtabs(self.indent_width) + return cell_len(section_text) + + def _cursor_at_argument_hint_anchor(self, line_index: int) -> bool: + """Return whether the cursor currently sits on the hint anchor.""" + if not self._draw_cursor or not self.show_cursor or not self.has_focus: + return False + cursor_row, cursor_column = self.selection.end + if cursor_row != line_index: + return False + return cursor_column == len(self.document.get_line(line_index)) + + def _build_argument_hint_strip(self, *, cursor_on_hint: bool) -> Strip: + """Build a strip for the current argument hint text. + + Returns: + A `Strip` containing the current argument hint, with cursor styling + applied to the first hint character when the cursor sits on the + hint anchor. + """ + hint = self.argument_hint + hint_style = self.get_component_rich_style("text-area--suggestion") + if not cursor_on_hint or not hint: + return Strip([Segment(hint, hint_style)], cell_length=cell_len(hint)) + + ta_theme = self._theme + cursor_style = ta_theme.cursor_style if ta_theme else None + first_style = hint_style if cursor_style is None else hint_style + cursor_style + segments = [Segment(hint[0], first_style)] + if len(hint) > 1: + segments.append(Segment(hint[1:], hint_style)) + return Strip(segments, cell_length=cell_len(hint)) + + def scroll_cursor_visible( + self, center: bool = False, animate: bool = False + ) -> Offset: + """Scroll to make the cursor visible, guarding against cursor/document desync. + + Textual's `WrappedDocument.location_to_offset` has an off-by-one in its + line-index clamp (`len(...)` instead of `len(...) - 1`). When a reactive + watcher (e.g. `_watch_show_vertical_scrollbar`) fires between a document + replacement and cursor update, the stale cursor location triggers a + `ValueError`. Guard here since `scroll_cursor_visible` is the sole + caller of `_recompute_cursor_offset`. + + Args: + center: Whether the cursor should be scrolled to the center. + animate: Whether to animate while scrolling. + + Returns: + The scroll offset applied, or `Offset(0, 0)` on desync. + """ + try: + return super().scroll_cursor_visible(center=center, animate=animate) + except ( + ValueError + ): # WrappedDocument.get_offsets off-by-one clamp in location_to_offset + logger.warning( + "Cursor/document desync in scroll_cursor_visible " + "(cursor=%s, doc_lines=%d); skipping scroll", + self.cursor_location, + self.document.line_count, + ) + return Offset(0, 0) + + def set_app_focus(self, *, has_focus: bool) -> None: + """Set whether the app should show the cursor as active. + + Args: + has_focus: Whether the app input should be focused. + """ + self._backslash_pending_time = None + if has_focus and not self.has_focus: + self.call_after_refresh(self.focus) + + def _notify_app_blur(self) -> None: + """Record that the terminal window lost OS focus.""" + self._app_blurred = True + + def _notify_app_focus(self) -> None: + """Record that the terminal window regained OS focus via a focus event. + + Stamps the regain time so the click that re-focused the window (which + arrives just after the focus event) can be treated as focus-only. + """ + if self._app_blurred: + self._refocus_time = time.monotonic() + self._app_blurred = False + + def _consume_refocus_click(self) -> bool: + """Return whether the current mouse-down only re-focuses the window. + + `_refocus_time` is only cleared here, so a focus regain that is never + followed by a text-area click leaves the stamp set. The gap check + bounds that staleness: an old stamp exceeds the window and returns + `False` (clearing it), so a much later click is never suppressed. + """ + refocus_time = self._refocus_time + if refocus_time is None: + return False + self._refocus_time = None + gap = time.monotonic() - refocus_time + return gap <= _REFOCUS_CLICK_SUPPRESS_WINDOW_SECONDS + + async def _on_mouse_down(self, event: events.MouseDown) -> None: + """Position the cursor on click, except when the click re-focuses the app. + + A mouse-down landing within a short window after a terminal focus + regain only restores focus and leaves the cursor where it was. + + Deliberately shadows Textual's private `TextArea._on_mouse_down` to gate + cursor positioning; verified against Textual 8.2.7. If the base handler + changes, re-verify that early-returning before `super()` still leaves no + selection/capture state set. + """ + if self._consume_refocus_click(): + event.stop() + event.prevent_default() + return + await super()._on_mouse_down(event) + + def set_completion_active(self, *, active: bool) -> None: + """Set whether completion suggestions are visible.""" + self._completion_active = active + + def action_insert_newline(self) -> None: + """Insert a newline character.""" + self.insert("\n") + # TextArea's built-in cursor-visible scroll runs before the widget + # reflows for the new row, so it sees stale dimensions and is a no-op + # when the cursor would land below `max-height`. Re-issue after + # refresh so it stays in view. + self.call_after_refresh(self.scroll_cursor_visible) + + def _refresh_scrollbars(self) -> None: + """Refresh scrollbars without flashing a transient vertical bar. + + `TextArea` grows its `virtual_size` height the moment a row is inserted, + a frame before this `height: auto` widget's container reflows to match. + The base `_refresh_scrollbars` decides vertical visibility by comparing + `virtual_size.height` against the stale `self._container_size.height`, + so for that one frame the freshly inserted row looks like overflow and + the scrollbar flashes on, then off once the container catches up. + + The widget only ever truly overflows once its content exceeds the height + it settles at — its resolved `max-height` (the layout chain above it is + all `height: auto`, so it always grows to `min(content, max-height)`). + Feed the base method that settled height instead of the mid-reflow one, + so the bar appears only on genuine overflow and never flashes. All other + base behavior (horizontal bar, anti-oscillation, scroll updates) is left + untouched. + + Deliberately overrides Textual's private `_refresh_scrollbars` and + swaps the private `_container_size`; verified against Textual 8.2.7. + Re-verify on major Textual upgrades. + """ + bound = self._settled_content_height() + if bound is None: + super()._refresh_scrollbars() + return + + original = self._container_size + # Never report a viewport smaller than the settled height; `max(...)` + # also guards the unlikely case where the real container is already + # larger than the bound, so we only ever raise the comparison height. + corrected_height = max(original.height, min(self.virtual_size.height, bound)) + if corrected_height == original.height: + super()._refresh_scrollbars() + return + + self._container_size = Size(original.width, corrected_height) + try: + super()._refresh_scrollbars() + finally: + self._container_size = original + + def _settled_content_height(self) -> int | None: + """Return the content-row height this widget settles at, if knowable. + + Returns `None` (so the caller defers to the base behavior) unless the + vertical overflow is `auto` and `max-height` resolves to a fixed cell + count, the only case where the flash-suppression bound is well-defined. + """ + styles = self.styles + if styles.overflow_y != "auto" or not styles.has_rule("max_height"): + return None + max_height = styles.max_height + cells = max_height.cells if max_height is not None else None + if cells is None: + return None + # box-sizing is border-box by default, so subtract border/padding to get + # the content-row count the base method compares `virtual_size` against. + return max(1, cells - self.gutter.height) + + def _cursor_at_visual_top(self) -> bool: + """Return whether the cursor cannot move up further.""" + try: + return self.get_cursor_up_location() == self.cursor_location + except ValueError: + # `WrappedDocument.location_to_offset` can raise during a brief + # text/cursor desync window (see `scroll_cursor_visible` guard). + # Treat as "not at top" so TextArea moves the cursor instead of + # firing history navigation on a transient state. + return False + + def _cursor_at_visual_bottom(self) -> bool: + """Return whether the cursor cannot move down further.""" + try: + return self.get_cursor_down_location() == self.cursor_location + except ValueError: + return False + + def action_cursor_up(self, select: bool = False) -> None: + """Move cursor up, or navigate to the previous history entry at top. + + When `select` is true or a selection is active, falls through to + TextArea's default so shift+up extends selection rather than + triggering navigation. History fires only when moving up cannot + advance the cursor — handled via the wrapped-document navigator so + soft-wrap is respected. + """ + if not select and self.selection.is_empty and self._cursor_at_visual_top(): + self.post_message(self.HistoryPrevious(self.text)) + return + super().action_cursor_up(select) + + def action_cursor_down(self, select: bool = False) -> None: + """Move cursor down, or navigate to the next history entry at bottom. + + Mirrors `action_cursor_up`: defers to TextArea on selection or when + the cursor still has somewhere to move; otherwise fires history. + """ + if not select and self.selection.is_empty and self._cursor_at_visual_bottom(): + self.post_message(self.HistoryNext()) + return + super().action_cursor_down(select) + + def _in_slash_command_context(self) -> bool: + """Return whether the current input is composing a slash command.""" + owner = self._chat_input_owner + if owner is not None and owner.mode == "command": + return True + return self.text.startswith("/") + + def _paste_collapse_enabled(self) -> bool: + """Return whether large pastes should be collapsed into placeholders. + + Reads the owning `ChatInput`'s resolved preference, defaulting to + enabled when the owner is not yet attached. + """ + owner = self._chat_input_owner + return owner is None or owner._collapse_pastes + + async def _dispatch_burst_payload(self, payload: str) -> None: + """Route a flushed burst through dropped-path and large-paste checks. + + When parsing fails, the buffered text is inserted unchanged so regular + typing behavior is preserved. + """ + from deepagents_code.input import parse_pasted_path_payload + + try: + parsed = await asyncio.to_thread(parse_pasted_path_payload, payload) + except Exception: + # The parser guards its own filesystem probes, but + # `_resolve_with_unicode_space_variants` calls `expanduser()` and + # `Path.cwd()` unguarded, so a deleted working directory or an + # unresolvable home still surfaces here. Leave a breadcrumb (the + # message never carries the paste content) instead of swallowing it, + # then fall through to normal text handling. Logged at warning (not + # debug) so it actually surfaces in production. + logger.warning( + "Path-payload parsing failed; treating burst as text", + exc_info=True, + ) + parsed = None + if parsed is not None: + self.post_message(self.PastedPaths(payload, parsed.paths)) + return + + if self._paste_collapse_enabled() and _should_collapse_chat_paste(payload): + self.post_message(self.PastedText(payload)) + return + + self.insert(payload) + + async def _on_key(self, event: events.Key) -> None: + """Handle key events.""" + # Lock keys (Caps Lock, Num Lock, Scroll Lock) must never type. The + # kitty parser patch in `_textual_patches.py` already neutralizes these + # at the source; this is defense-in-depth in case a lock key still + # arrives with associated text (e.g. if that patch failed to install or + # a future terminal bypasses it). Note this only shields the chat input + # — if the parser patch silently no-ops, other widgets stay broken. The + # key may carry modifier prefixes (e.g. 'ctrl+caps_lock'), so match on + # the final '+'-delimited token. + if event.key.rsplit("+", 1)[-1] in _LOCK_KEYS: + event.prevent_default() + event.stop() + return + + # VS Code 1.110 incorrectly sends space as a CSI u escape code + # (`\x1b[32u`) instead of a plain ` ` character. Textual parses + # this as Key(key='space', character=None, is_printable=False), so + # the TextArea never inserts the space. Per the kitty keyboard + # protocol spec, keys that generate text (like space) should NOT + # use CSI u encoding — VS Code is the outlier here. + # + # This workaround should be safe to keep indefinitely: once VS Code or + # Textual fixes the issue upstream, `character` will be `' '` and + # this branch simply won't match. + # + # Upstream: https://github.com/Textualize/textual/issues/6408 + if event.key == "space" and event.character is None: + event.prevent_default() + event.stop() + self.insert(" ") + self.post_message(self.Typing()) + return + + now = time.monotonic() + + # Signal typing activity for printable keys and backspace so the app + # can defer approval widgets while the user is actively editing. + if event.is_printable or event.key == "backspace": + self.post_message(self.Typing()) + + if await self._absorb_key_into_burst(event, now): + event.prevent_default() + event.stop() + return + + if self._maybe_start_burst(event, now): + event.prevent_default() + event.stop() + return + + # Promote rapid keystroke runs into the paste buffer so terminals without + # bracketed paste still get newline grouping and large-paste collapsing. + if self._track_burst_run(event, now): + event.prevent_default() + event.stop() + return + + # A mode trigger (`!`, `!!`, `/`) typed at the very start of an + # unselected input switches modes. Handle it before TextArea inserts the + # character so the trigger never flashes on screen for a frame before + # the change handler would strip it. + if ( + event.is_printable + and event.character is not None + and self.cursor_location == (0, 0) + and self.selection.is_empty + and self._chat_input_owner is not None + and self._chat_input_owner.handle_mode_prefix_keystroke(event.character) + ): + event.prevent_default() + event.stop() + return + + # Some terminals (e.g. VSCode built-in) send a literal backslash + # followed by enter for shift+enter. When enter arrives shortly + # after a backslash, delete the backslash and insert a newline. The + # fallback is inactive while completion is active. + if self._consume_backslash_enter_newline( + event, now, enabled=not self._completion_active + ): + return + + self._track_backslash_pending(event, now) + + # Modifier+Enter inserts newline — keys derived from BINDINGS + if self._consume_modifier_newline(event): + return + + if event.key == "backspace" and self._delete_placeholder_token(backwards=True): + event.prevent_default() + event.stop() + return + + # If completion is active, let parent handle navigation keys. + # Space is included so that slash-command completion can accept the + # selected suggestion via the same code path as Tab (avoiding a + # frame-lag between the popup hiding and the argument hint appearing). + # When the active controller ignores the space (e.g. file completion), + # ChatInput.on_key inserts it manually. + if self._completion_active and event.key in { + "up", + "down", + "tab", + "enter", + "space", + }: + # Prevent TextArea's default behavior (e.g., Enter inserting newline) + # but let event bubble to ChatInput for completion handling + event.prevent_default() + return + + # Plain Enter submits, unless a recent keystroke burst suggests this + # newline is part of a paste replayed as key events; then insert a + # newline and keep the window alive so the rest of the paste stays + # grouped instead of submitting mid-stream. + if event.key == "enter": + event.prevent_default() + event.stop() + if self._consume_enter_as_burst_newline(now): + return + if ( + self._chat_input_owner is not None + and self._chat_input_owner._handle_stale_slash_enter() + ): + return + value = self.text.strip() + if value: + self.post_message(self.Submitted(value)) + return + + await super()._on_key(event) + + def action_delete_right(self) -> None: + """Delete a bound placeholder atomically or the next character.""" + if not self._delete_placeholder_token(backwards=False): + super().action_delete_right() + + def action_delete_word_left(self) -> None: + """Delete a bound placeholder atomically or the previous word.""" + if not self._delete_placeholder_token(backwards=True): + super().action_delete_word_left() + + def _delete_placeholder_token(self, *, backwards: bool) -> bool: + """Delete a full placeholder token (image, video, or paste) in one keypress. + + Args: + backwards: Whether the delete action is backwards (`backspace`) or + forwards (`delete`). + + Returns: + `True` when a placeholder token was deleted. + """ + if not self.text or not self.selection.is_empty: + return False + + cursor_offset = self.document.get_index_from_location(self.cursor_location) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower + span = self._find_placeholder_span(cursor_offset, backwards=backwards) + if span is None: + return False + + start, end = span + start_location = self.document.get_location_from_index(start) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower + end_location = self.document.get_location_from_index(end) # ty: ignore[unresolved-attribute] + self.delete(start_location, end_location) + self.move_cursor(start_location) + return True + + def _bound_media_placeholders(self) -> set[str]: + """Return placeholder tokens bound to currently tracked media. + + Returns: + The set of `[image N]`/`[video N]` tokens for media the tracker is + actually holding. Empty when there is no owner/tracker. + """ + owner = self._chat_input_owner + tracker = owner._image_tracker if owner is not None else None + if tracker is None: + return set() + placeholders = {img.placeholder for img in tracker.images} + placeholders.update(video.placeholder for video in tracker.videos) + return placeholders + + def _bound_paste_ids(self) -> set[int]: + """Return paste ids that have backing content in the owner. + + Returns: + The set of paste ids present in `ChatInput._pasted_contents`. Empty + when there is no owner. + """ + owner = self._chat_input_owner + if owner is None: + return set() + return set(owner._pasted_contents) + + def _find_placeholder_span( + self, cursor_offset: int, *, backwards: bool + ) -> tuple[int, int] | None: + """Return placeholder span to delete for current cursor and key direction. + + Covers image, video, and collapsed-paste placeholders so each deletes as + a single atomic token. Paste placeholders carry backing content in + `ChatInput._pasted_contents`; that map is intentionally left untouched + here so an undo can restore the token with its content (it is cleared + only at submit). + + Only tokens bound to real attachments are treated as atomic: image/video + placeholders must correspond to a tracked media item and paste + placeholders to an entry in `ChatInput._pasted_contents`. Placeholder- + shaped text the user typed by hand (e.g. literally typing ``[image 2]``) + is left as ordinary text and edits character by character. + + Args: + cursor_offset: Character offset of the cursor from the start of text. + backwards: Whether the delete action is backwards (backspace) or + forwards (delete). + + Returns: + The `(start, end)` character span of the placeholder to delete, or + `None` when the cursor is not adjacent to a bound placeholder + token. + """ + text = self.text + media_placeholders = self._bound_media_placeholders() + pasted_ids = self._bound_paste_ids() + for pattern in ( + IMAGE_PLACEHOLDER_PATTERN, + VIDEO_PLACEHOLDER_PATTERN, + PASTE_PLACEHOLDER_PATTERN, + ): + for match in pattern.finditer(text): + if pattern is PASTE_PLACEHOLDER_PATTERN: + if int(match.group(1)) not in pasted_ids: + continue + elif match.group(0) not in media_placeholders: + continue + start, end = match.span() + if backwards: + # Cursor is inside token or right after a trailing space inserted + # with the token. + if start < cursor_offset <= end: + return start, end + if cursor_offset > 0: + previous_index = cursor_offset - 1 + # Swallow trailing whitespace with the token, except for + # a newline: backspacing a line break should rejoin the + # lines without deleting the placeholder. + if ( + previous_index < len(text) + and previous_index == end + and text[previous_index].isspace() + and text[previous_index] != "\n" + ): + return start, cursor_offset + elif start <= cursor_offset < end: + return start, end + return None + + def replace_placeholder_with_text(self, paste_id: int, content: str) -> bool: + """Replace a `[Pasted text #id]` placeholder with full text in place. + + Used when the same content is pasted again: the compact placeholder is + expanded back to the original text where it sits, preserving surrounding + input. + + Args: + paste_id: The paste id whose placeholder should be expanded. + content: The full text to insert where the placeholder was. + + Returns: + `True` when a matching placeholder was found and replaced. + """ + for match in PASTE_PLACEHOLDER_PATTERN.finditer(self.text): + if int(match.group(1)) != paste_id: + continue + start, end = match.span() + start_location = self.document.get_location_from_index(start) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower + end_location = self.document.get_location_from_index(end) # ty: ignore[unresolved-attribute] + self.delete(start_location, end_location) + self.insert(content, start_location) + return True + return False + + async def _on_paste(self, event: events.Paste) -> None: + """Handle paste events, detecting file paths and large pastes.""" + self._backslash_pending_time = None + if self._paste_burst_buffer: + await self._flush_paste_burst() + + from deepagents_code.input import parse_pasted_path_payload + + try: + parsed = await asyncio.to_thread(parse_pasted_path_payload, event.text) + except Exception: + # See _flush_paste_burst: swallowing here would silently break the + # drag-drop-file path, so log a breadcrumb and fall through to text. + logger.debug( + "Path-payload parsing failed; treating paste as text", + exc_info=True, + ) + parsed = None + if parsed is not None: + event.prevent_default() + event.stop() + self.post_message(self.PastedPaths(event.text, parsed.paths)) + return + + if self._paste_collapse_enabled() and _should_collapse_chat_paste(event.text): + # Intercept the paste so Textual's default _on_paste doesn't insert + # the full text. ChatInput stores the content and inserts a compact + # placeholder instead. + event.prevent_default() + event.stop() + self.post_message(self.PastedText(event.text)) + return + + # Don't call super() here — Textual's MRO dispatch already calls + # TextArea._on_paste after this handler returns. Calling super() + # would insert the text a second time, duplicating the paste. + + def set_text_from_history(self, text: str, *, cursor_at_end: bool = True) -> None: + """Set text from history navigation. + + Args: + text: The history entry text to load. + cursor_at_end: Place the cursor at the end of the loaded text + (use for down-navigation, so the next down press continues + forward through history). When `False`, place at the start + so the next up press continues backward. Defaults to `True` + to preserve historical cursor-at-end behavior for callers + that don't specify a direction. + """ + self._reset_paste_burst_state() + self._skip_history_change_events += 1 + self.text = text + # The suppressed Changed event (see above) is what would normally toggle + # the clear/copy buttons, so sync them now to hide/show in the same frame + # the text swaps — otherwise an emptied draft keeps the buttons for a frame. + self._sync_owner_action_buttons(text) + if cursor_at_end: + self.move_cursor_to_end() + else: + self.move_cursor((0, 0)) + + def move_cursor_to_end(self) -> None: + """Move the cursor to the end of the current text.""" + lines = self.text.split("\n") + last_row = len(lines) - 1 + self.move_cursor((last_row, len(lines[last_row]))) + + def clear_text(self) -> None: + """Clear the text area.""" + # Increment (not reset) so any pending Changed event from a prior + # set_text_from_history is still suppressed, plus one for the + # self.text = "" assignment below. + self._skip_history_change_events += 1 + self._reset_paste_burst_state() + self.text = "" + # Hide the clear/copy buttons in the same frame the draft empties; the + # suppressed Changed event would otherwise leave them for an extra frame. + self._sync_owner_action_buttons("") + self.move_cursor((0, 0)) + + def _sync_owner_action_buttons(self, text: str) -> None: + """Match the owner's clear/copy buttons to programmatically set text. + + History/clear text swaps suppress the `Changed` event that normally + drives button visibility, so the owner is updated directly to keep the + buttons in lockstep with the draft (matching the `Changed`-path gate). + """ + owner = self._chat_input_owner + if owner is not None: + owner._set_action_buttons_visible(visible=bool(text.strip())) + + def discard_text(self) -> bool: + """Clear the draft via an undoable edit (restorable with ctrl+z). + + Unlike `clear_text`, the deletion is recorded in the undo history and + the resulting `Changed` event is allowed to propagate, so completion + and argument-hint state stay in sync. + + Returns: + `True` when there was text to clear. + """ + if not self.text: + return False + self._reset_paste_burst_state() + self.clear() + return True + + +class _CompletionViewAdapter: + """Translate completion-space replacements to text-area coordinates.""" + + def __init__(self, chat_input: ChatInput) -> None: + """Initialize adapter with its owning `ChatInput`.""" + self._chat_input = chat_input + + def render_completion_suggestions( + self, suggestions: list[tuple[str, str]], selected_index: int + ) -> None: + """Delegate suggestion rendering to `ChatInput`.""" + self._chat_input.render_completion_suggestions(suggestions, selected_index) + + def clear_completion_suggestions(self) -> None: + """Delegate completion clearing to `ChatInput`.""" + self._chat_input.clear_completion_suggestions() + + def replace_completion_range(self, start: int, end: int, replacement: str) -> None: + """Map completion indices to text-area indices before replacing text.""" + # The completion controller returns the full command name (e.g. + # "/remember") in completion space, but the TextArea only contains + # text after the virtual mode prefix (e.g. "/" in command mode). + # Strip the prefix to avoid double-insertion. + prefix = MODE_PREFIXES.get(self._chat_input.mode, "") + if prefix and replacement.startswith(prefix): + replacement = replacement[len(prefix) :] + self._chat_input.replace_completion_range( + self._chat_input._completion_index_to_text_index(start), + self._chat_input._completion_index_to_text_index(end), + replacement, + ) + + +class ChatInput(Vertical): + """Chat input widget with prompt, multi-line text, autocomplete, and history. + + Features: + - Multi-line input with TextArea + - Enter to submit, modifier key for newlines (see `config.newline_shortcut`) + - Up/Down arrows for command history at input boundaries (start/end of text) + - Autocomplete for @ (files) and / (commands) + """ + + DEFAULT_CSS = """ + ChatInput { + height: auto; + layers: base actions; + } + + ChatInput #input-box { + height: auto; + min-height: 3; + max-height: 25; + padding: 0; + background: $background; + border: solid $primary; + } + + ChatInput.mode-shell #input-box { + border: solid $mode-bash; + } + + ChatInput.mode-command #input-box { + border: solid $mode-command; + } + + ChatInput.mode-shell-incognito #input-box { + border: solid $mode-incognito; + border-title-color: $mode-incognito; + border-title-style: bold; + } + + /* Action buttons float on their own z-layer over the top border line, so + they cost no content row and never overlap the draft text. The row docks + to the right edge and sizes to its buttons (`width: auto`), overlaying + only the right portion of the border line and leaving the rest clear. */ + ChatInput #input-actions { + layer: actions; + dock: right; + width: auto; + height: 1; + margin-right: 1; + display: none; + } + + ChatInput .input-row { + height: auto; + width: 100%; + } + + ChatInput .input-prompt { + width: 3; + height: 1; + padding: 0 1; + color: $primary; + text-style: bold; + } + + ChatInput.mode-shell .input-prompt { + color: $mode-bash; + } + + ChatInput.mode-command .input-prompt { + color: $mode-command; + } + + ChatInput.mode-shell-incognito .input-prompt { + color: $mode-incognito; + } + + ChatInput ChatTextArea { + width: 1fr; + height: auto; + min-height: 1; + max-height: 8; + border: none; + background: transparent; + padding: 0; + } + + ChatInput ChatTextArea.cursor-underline .text-area--cursor { + background: transparent; + color: $text; + text-style: underline; + } + + ChatInput ChatTextArea:focus { + border: none; + } + """ + """Border and prompt glyph change color per mode for immediate visual feedback.""" + + class Submitted(Message): + """Message sent when input is submitted.""" + + def __init__(self, value: str, mode: str = "normal") -> None: + """Initialize with value and mode.""" + super().__init__() + self.value = value + self.mode = mode + + class ModeChanged(Message): + """Message sent when input mode changes.""" + + def __init__(self, mode: str) -> None: + """Initialize with new mode.""" + super().__init__() + self.mode = mode + + class Typing(Message): + """Posted when the user presses a printable key or backspace in the input. + + The app uses this to delay approval widgets while the user is actively + typing, preventing accidental key presses (e.g. `y`, `n`) from + triggering approval decisions. + """ + + mode: reactive[str] = reactive("normal") + + def __init__( + self, + cwd: str | Path | None = None, + history_file: Path | None = None, + image_tracker: MediaTracker | None = None, + **kwargs: Any, + ) -> None: + """Initialize the chat input widget. + + Args: + cwd: Current working directory for file completion + history_file: Override path for persisted input history. + Resolved by `_default_history_path()` when `None`. + image_tracker: Optional tracker for attached images + **kwargs: Additional arguments for parent + """ + super().__init__(**kwargs) + self._cwd = Path(cwd) if cwd else Path.cwd() + self._image_tracker = image_tracker + self._input_box: Vertical | None = None + self._action_buttons: Horizontal | None = None + self._text_area: ChatTextArea | None = None + self._popup: CompletionPopup | None = None + self._completion_manager: MultiCompletionManager | None = None + self._completion_view: _CompletionViewAdapter | None = None + self._slash_controller: SlashCommandController | None = None + + # Collapsed paste storage: paste_id → full content. When a large paste + # arrives, the full text is stored here and a compact + # `[Pasted text #N +M lines]` placeholder is inserted into the text + # area instead. At submission the placeholder is expanded back. + self._pasted_contents: dict[int, PastedContent] = {} + self._next_paste_id = 1 + + # Whether large pastes are collapsed into `[Pasted text #N +M lines]` + # placeholders. + # Gated by `display.collapse_pastes` (env / `[ui].collapse_pastes`); + # when disabled, pasted text is inserted verbatim. + self._collapse_pastes = _collapse_pastes_enabled() + + # Guard flag: set True before programmatically stripping the mode + # prefix character so the resulting text-change event does not + # re-evaluate mode. + self._stripping_prefix = False + + # When the user submits, we clear the text area which fires a + # text-change event. Without this guard the tracker would see the + # now-empty text, assume all media were deleted, and discard them + # before the app has a chance to send them. Each submit bumps the + # counter by one; the next text-change event decrements it and + # skips the sync. + self._skip_media_sync_events = 0 + + # Number of virtual prefix characters currently injected for + # completion controller calls (0 for normal, 1 for shell/command). + self._completion_prefix_len = 0 + + # Guard flag: set while replacing a dropped path payload with an + # inline image placeholder so the resulting change event doesn't + # immediately recurse into the same replacement path. + self._applying_inline_path_replacement = False + + # Text area content from the previous Changed event. Used to skip + # blocking filesystem path-detection on single-keystroke edits while + # still detecting replacement edits that insert a full path payload. + self._prev_text = "" + + # Track current suggestions for click handling + self._current_suggestions: list[tuple[str, str]] = [] + self._current_selected_index = 0 + + # Command name (without /) → argument hint for inline ghost text + self._argument_hints: dict[str, str] = {} + # Runtime hints that depend on session state, kept separate so rebuilding + # slash commands after skill discovery cannot replace them. + self._argument_hint_overrides: dict[str, str] = {} + + # Set up history manager + if history_file is None: + history_file = _default_history_path() + self._history = HistoryManager(history_file) + + def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual widget method convention + """Compose the chat input layout. + + Yields: + Widgets for the input row and completion popup. + """ + # The bordered box owns the prompt, text area, and completion popup so + # the action buttons (a sibling) can float on its top border line; a + # widget can only render on its sibling's border, not its parent's. + with Vertical(id="input-box"): + with Horizontal(classes="input-row"): + yield Static(">", classes="input-prompt", id="prompt") + yield ChatTextArea(id="chat-input") + yield CompletionPopup(id="completion-popup") + + # Action buttons float on their own z-layer over the top border line so + # they cost no content row and never overlap the draft text. + with Horizontal(id="input-actions"): + yield InputActionButton( + "[ X ]", + "clear", + id="clear-button", + classes="input-action input-action-clear", + ) + yield InputActionButton( + "[ COPY ]", + "copy", + id="copy-button", + classes="input-action input-action-copy", + ) + + def on_mount(self) -> None: + """Initialize components after mount.""" + self._input_box = self.query_one("#input-box", Vertical) + self._action_buttons = self.query_one("#input-actions", Horizontal) + if is_ascii_mode(): + colors = theme.get_theme_colors(self) + self._input_box.styles.border = ("ascii", colors.primary) + + self._text_area = self.query_one("#chat-input", ChatTextArea) + self._popup = self.query_one("#completion-popup", CompletionPopup) + self._text_area._chat_input_owner = self + + # Both controllers implement the CompletionController protocol but have + # different concrete types; the list-item warning is a false positive. + self._completion_view = _CompletionViewAdapter(self) + self._file_controller = FuzzyFileController( + self._completion_view, cwd=self._cwd + ) + self._slash_controller = SlashCommandController( + get_slash_commands(), self._completion_view + ) + self._completion_manager = MultiCompletionManager( + [ + self._slash_controller, + self._file_controller, + ] # ty: ignore[invalid-argument-type] # Controller types are compatible at runtime + ) + + self._rebuild_argument_hints(get_slash_commands()) + + self._warm_file_cache() + self.set_interval( + _FILE_CACHE_REFRESH_INTERVAL_SECONDS, + self._refresh_file_cache, + ) + self._text_area.focus() + + def _warm_file_cache(self, *, force: bool = False, exclusive: bool = False) -> None: + """Schedule an `@` file-completion cache warmer. + + No-ops before `on_mount` wires up the file controller (the periodic + refresh interval can fire during teardown or a partial mount). + + Args: + force: Re-walk even when the cache is already populated. The prior + cache stays visible until the new walk completes. + exclusive: Cancel any other in-flight warmer in the shared worker + group before starting, so a slow walk is superseded by the next + tick rather than stacking overlapping walks. Used by the + periodic refresh; the on-mount/cwd-switch warmers run + non-exclusively so a quick invalidation can warm concurrently. + """ + file_controller = getattr(self, "_file_controller", None) + if file_controller is None: + return + self.run_worker( + file_controller.warm_cache(force=force), + exclusive=exclusive, + group=_FILE_CACHE_WORKER_GROUP, + exit_on_error=False, + ) + + def _refresh_file_cache(self) -> None: + """Re-warm the `@` file-completion cache off the event loop.""" + self._warm_file_cache(force=True, exclusive=True) + + def set_cwd(self, cwd: str | Path) -> None: + """Update file completion to use a new cwd. + + Re-roots the file controller and schedules a background cache warm so + the project-root walk runs off the event loop. + """ + self._cwd = Path(cwd) + file_controller = getattr(self, "_file_controller", None) + if file_controller is not None: + file_controller.set_cwd(self._cwd) + self._warm_file_cache() + + def update_slash_commands(self, commands: list[CommandEntry]) -> None: + """Update the slash command controller's command list. + + Called by the app after discovering skills to merge static + commands with dynamic `/skill:` entries. + + Args: + commands: Full list of `CommandEntry` instances. + """ + if self._slash_controller: + self._slash_controller.update_commands(commands) + self._rebuild_argument_hints(commands) + else: + logger.warning( + "Cannot update slash commands: controller not initialized " + "(widget not yet mounted)" + ) + + def set_argument_hint_override(self, command: str, hint: str | None) -> None: + """Set, suppress, or restore a runtime slash-command argument hint. + + Args: + command: Slash command name, with or without the leading `/`. + hint: Replacement hint, an empty string to suppress the registered + hint, or `None` to restore it. + """ + name = command.removeprefix("/") + if hint is None: + self._argument_hint_overrides.pop(name, None) + else: + self._argument_hint_overrides[name] = hint + self._update_argument_hint() + + def _rebuild_argument_hints(self, commands: list[CommandEntry]) -> None: + """Rebuild the command-name -> argument-hint lookup. + + Args: + commands: Current list of `CommandEntry` instances. + """ + self._argument_hints = { + entry.name.removeprefix("/"): entry.argument_hint + for entry in commands + if entry.argument_hint + } + + def _update_argument_hint(self) -> None: + """Show or clear inline ghost text for slash-command argument hints. + + Sets `ChatTextArea.argument_hint` when the input is a known slash + command followed by a trailing space with no args typed yet. Both + spacebar and Tab completion produce this state (Tab goes through + `replace_completion_range` which appends a trailing space). + """ + if not self._text_area: + return + + if self.mode == "command": + text = self._text_area.text + if text.endswith(" ") and text.count(" ") == 1: + command = text[:-1] + hint = self._argument_hint_overrides.get(command) + if hint is None: + hint = self._argument_hints.get(command, "") + if hint: + self._text_area.argument_hint = hint + return + + self._text_area.argument_hint = "" + + def _set_action_buttons_visible(self, *, visible: bool) -> None: + """Show or hide the clear/copy action buttons on the input border. + + Only writes `display` when it actually changes. Mutating it on every + keystroke would trigger a layout reflow each time, which perturbs the + completion popup's deferred (`call_after_refresh`) show/hide ordering. + """ + if self._action_buttons is not None and self._action_buttons.display != visible: + self._action_buttons.display = visible + + def on_text_area_changed(self, event: TextArea.Changed) -> None: + """Detect input mode and update completions.""" + text = event.text_area.text + # Reveal the clear/copy buttons only when there is a meaningful draft to + # act on, so an empty input keeps a clean, uncluttered border. + # Whitespace-only input (e.g. stray spaces or newlines) has nothing + # worth clearing or copying, so it stays hidden too. Done before the + # early returns below so recalled-history text shows them as well. + # NOTE: this `strip()` gate is deliberately stricter than the keyboard + # paths (esc+esc clear, Ctrl+C copy), which act on the raw value so a + # whitespace-only draft is still clearable/copyable without the buttons. + self._set_action_buttons_visible(visible=bool(text.strip())) + # Drag-drop / bracketed paste arrive as one Changed event with a + # multi-character inserted span. Normal typing arrives one character at + # a time. Checking the changed span (rather than net length delta) + # preserves replacement edits where selected text is replaced by a path + # of similar length. + should_check_path_payload = self._should_check_path_payload(text) + previous_text = self._prev_text + self._sync_media_tracker_to_text( + text, previous_text=previous_text, cursor_offset=self._get_cursor_offset() + ) + self._prev_text = text + + # History handlers explicitly decide mode and stripped display text. + # Skip mode detection here so recalled entries don't inherit stale mode. + if self._text_area and self._text_area._skip_history_change_events > 0: + self._text_area._skip_history_change_events -= 1 + if self._completion_manager: + self._completion_manager.reset() + self.scroll_visible() + return + if self._text_area and self._text_area._skip_history_change_events < 0: + logger.warning( + "_skip_history_change_events is negative (%d); resetting to 0", + self._text_area._skip_history_change_events, + ) + self._text_area._skip_history_change_events = 0 + + if self._applying_inline_path_replacement: + self._applying_inline_path_replacement = False + elif should_check_path_payload and self._apply_inline_dropped_path_replacement( + text + ): + return + + # Checked after the guards above so we skip the (potentially slow) + # filesystem lookup when the text change came from history navigation + # or prefix stripping, which never need path detection. + is_path_payload = should_check_path_payload and self._is_dropped_path_payload( + text + ) + + # Guard: skip mode re-detection after we programmatically stripped + # a prefix character. + if self._stripping_prefix: + self._stripping_prefix = False + elif detected_prefix := detect_mode_prefix(text): + prefix, raw_detected = detected_prefix + detected, strip_length = self._resolve_prefix_mode(prefix, raw_detected) + if prefix == "/" and is_path_payload: + # Absolute dropped paths stay normal input, not slash-command mode. + if self.mode != "normal": + self.mode = "normal" + else: + # Detected a mode-trigger prefix (e.g. "!" or "/"). + # Strip it unconditionally -- even when already in the correct + # mode -- because completion controllers may write replacement + # text that re-includes the trigger character. The + # _stripping_prefix guard prevents the resulting change event + # from looping back here. + if self.mode != detected: + self.mode = detected + if strip_length: + self._strip_mode_prefix(strip_length) + # Fall through to update completion suggestions in the same + # refresh cycle as the mode/glyph change rather than waiting + # for the next text-change event caused by the prefix strip. + # Note: the strip's text-change event will also call + # on_text_changed (idempotently) since _stripping_prefix only + # skips mode detection, not the completion block below. + # Set inline argument hint before the completion manager runs so + # the suggestion is ready in the same render pass that hides the popup. + self._update_argument_hint() + + # Update completion suggestions using completion-space text/cursor. + if self._completion_manager and self._text_area: + if is_path_payload: + self._completion_manager.reset() + else: + vtext, vcursor = self._completion_text_and_cursor() + self._completion_manager.on_text_changed(vtext, vcursor) + + # Scroll input into view when content changes (handles text wrap) + self.scroll_visible() + + def _should_check_path_payload(self, text: str) -> bool: + """Return whether a text change may contain a pasted path payload.""" + old = self._prev_text + if text == old: + return False + + prefix_len = 0 + max_prefix_len = min(len(old), len(text)) + while prefix_len < max_prefix_len and old[prefix_len] == text[prefix_len]: + prefix_len += 1 + + old_suffix = len(old) + text_suffix = len(text) + while ( + old_suffix > prefix_len + and text_suffix > prefix_len + and old[old_suffix - 1] == text[text_suffix - 1] + ): + old_suffix -= 1 + text_suffix -= 1 + + inserted_len = text_suffix - prefix_len + return inserted_len > 1 + + @staticmethod + def _parse_dropped_path_payload( + text: str, *, allow_leading_path: bool = False + ) -> ParsedPastedPathPayload | None: + """Parse dropped-path payload text through a single parser entrypoint. + + Returns: + Parsed payload details, otherwise `None`. + """ + from deepagents_code.input import parse_pasted_path_payload + + return parse_pasted_path_payload(text, allow_leading_path=allow_leading_path) + + def _parse_dropped_path_payload_with_command_recovery( + self, text: str, *, allow_leading_path: bool = False + ) -> tuple[str, ParsedPastedPathPayload | None]: + """Parse payload and recover stripped leading slash in command mode. + + Args: + text: Input text to parse. + allow_leading_path: Whether to parse leading path + suffix payloads. + + Returns: + Tuple of `(candidate_text, parsed_payload)`. + """ + candidate = text + parsed = self._parse_dropped_path_payload( + text, allow_leading_path=allow_leading_path + ) + if parsed is not None: + return candidate, parsed + + if self.mode != "command": + return candidate, None + + prefixed = f"/{text.lstrip('/')}" + parsed = self._parse_dropped_path_payload( + prefixed, allow_leading_path=allow_leading_path + ) + if parsed is None: + return candidate, None + + logger.debug( + "Recovering stripped absolute path; resetting mode from " + "'command' to 'normal'" + ) + self.mode = "normal" + return prefixed, parsed + + def _extract_leading_dropped_path_with_command_recovery( + self, text: str + ) -> tuple[str, tuple[Path, int] | None]: + """Extract a leading dropped-path token with command-mode recovery. + + Args: + text: Input text to parse. + + Returns: + Tuple of `(candidate_text, leading_match)`, where `leading_match` is + `(path, token_end)` when extraction succeeds, otherwise `None`. + """ + from deepagents_code.input import extract_leading_pasted_file_path + + leading_match = extract_leading_pasted_file_path(text) + candidate = text + if leading_match is not None: + return candidate, leading_match + + if self.mode != "command": + return candidate, None + + prefixed = f"/{text.lstrip('/')}" + leading_match = extract_leading_pasted_file_path(prefixed) + if leading_match is None: + return candidate, None + + logger.debug( + "Recovering stripped absolute leading path; resetting mode " + "from 'command' to 'normal'" + ) + self.mode = "normal" + return prefixed, leading_match + + @staticmethod + def _is_existing_path_payload(text: str) -> bool: + """Return whether text is a dropped-path payload for existing files.""" + if len(text) < 2: # noqa: PLR2004 # Need at least '/' + one char + return False + from deepagents_code.input import parse_pasted_path_payload + + return parse_pasted_path_payload(text, allow_leading_path=True) is not None + + def _is_dropped_path_payload(self, text: str) -> bool: + """Return whether current text looks like a dropped file-path payload.""" + if not text: + return False + if self._is_existing_path_payload(text): + return True + if self.mode == "command": + candidate = f"/{text.lstrip('/')}" + return self._is_existing_path_payload(candidate) + return False + + def _resolve_prefix_mode(self, prefix: str, detected: str) -> tuple[str, int]: + """Resolve target mode and strip length for a detected mode prefix. + + Applies the `!`/`!!` state machine relative to the current mode. + + Returns: + Tuple of `(target_mode, strip_length)`. + """ + strip_length = len(prefix) + if self.mode == "shell" and detected == "shell": + # First `!` was stripped on entry to shell mode, so this `!` is the + # second bang of `!!`. Promote to incognito and consume it. + detected = "shell_incognito" + elif self.mode == "shell_incognito" and detected == "shell": + # Already in incognito; an extra `!` is part of the command body. + # Skip the strip-and-demote path that would drop back to shell mode. + detected = "shell_incognito" + strip_length = 0 + return detected, strip_length + + def handle_mode_prefix_keystroke(self, char: str) -> bool: + """Switch input mode for a mode trigger typed at the start of the input. + + Handles the switch before `TextArea` inserts the character so the + trigger (`!`, `!!`, `/`) never flashes on screen for a frame before the + change handler would strip it. + + Returns: + True if the keystroke was consumed as a mode selector without + inserting the character, otherwise False. + """ + detected_prefix = detect_mode_prefix(char) + if detected_prefix is None: + return False + prefix, raw_detected = detected_prefix + detected, strip_length = self._resolve_prefix_mode(prefix, raw_detected) + if not strip_length: + # An extra `!` inside an incognito command body is literal text. + return False + if self.mode != detected: + self.mode = detected + # No text changed, so run the same hint/completion refresh that + # on_text_area_changed performs after stripping a typed prefix. + self._update_argument_hint() + if self._completion_manager and self._text_area: + vtext, vcursor = self._completion_text_and_cursor() + self._completion_manager.on_text_changed(vtext, vcursor) + self.scroll_visible() + return True + + def _strip_mode_prefix(self, length: int = 1) -> None: + """Remove the mode trigger from the text area. + + Sets the `_stripping_prefix` guard so the resulting text-change event is + not misinterpreted as new input. + + Args: + length: Number of leading characters to strip (matches the trigger + length detected by `detect_mode_prefix`). + """ + if not self._text_area: + return + if self._stripping_prefix: + logger.warning( + "Previous _stripping_prefix guard was never cleared; " + "resetting. This may indicate a missed text-change event." + ) + text = self._text_area.text + if not text: + return + row, col = self._text_area.cursor_location + self._stripping_prefix = True + self._text_area.text = text[length:] + if row == 0 and col > 0: + col = max(0, col - length) + self._text_area.move_cursor((row, col)) + + def _completion_text_and_cursor(self) -> tuple[str, int]: + """Return controller-facing text/cursor in completion space. + + Also updates `_completion_prefix_len` so that subsequent calls to + `_completion_index_to_text_index` use the matching offset. + """ + if not self._text_area: + self._completion_prefix_len = 0 + return "", 0 + + text = self._text_area.text + cursor = self._get_cursor_offset() + prefix = MODE_PREFIXES.get(self.mode, "") + self._completion_prefix_len = len(prefix) + + if prefix: + return prefix + text, cursor + len(prefix) + return text, cursor + + def _completion_index_to_text_index(self, index: int) -> int: + """Translate completion-space index into text-area index. + + Args: + index: Cursor/index position in completion space. + + Returns: + Clamped index in text-area space. + """ + if not self._text_area: + return 0 + + if 0 <= index <= self._completion_prefix_len: + return 0 + + mapped = index - self._completion_prefix_len + text_len = len(self._text_area.text) + if mapped < 0 or mapped > text_len: + logger.warning( + "Completion index %d mapped to %d, outside [0, %d]; " + "clamping (prefix_len=%d, mode=%s)", + index, + mapped, + text_len, + self._completion_prefix_len, + self.mode, + ) + return max(0, min(mapped, text_len)) + + def _handle_stale_slash_enter(self) -> bool: + """Refresh stale slash completions during an Enter-key race. + + Returns: + `True` when Enter was handled by applying a single visible + suggestion or by showing multiple visible suggestions. + """ + if self.mode != "command" or self._text_area is None: + return False + + slash_controller = self._slash_controller + if slash_controller is None: + return False + + if self._text_area._completion_active: + return False + + text, cursor = self._completion_text_and_cursor() + if not text.startswith("/"): + return False + + matches = slash_controller.name_prefix_matches(text, cursor) + if not matches: + return False + + completion_manager = self._completion_manager + if completion_manager is None: + logger.warning( + "Slash controller is initialized without completion manager; " + "stale slash Enter cannot refresh completions." + ) + return False + + completion_manager.on_text_changed(text, cursor) + if len(matches) == 1: + slash_controller.apply_name_prefix_completion(matches[0], cursor) + self._submit_value(self._text_area.text.strip()) + return True + return True + + def _submit_value(self, value: str) -> None: + """Prepend mode prefix, save to history, post message, and reset input. + + This is the single path for all submission flows so the prefix-prepend + + history + post + clear + mode-reset logic stays in one place. + + Args: + value: The stripped text to submit (without mode prefix). + """ + if not value: + return + + if self._completion_manager: + self._completion_manager.reset() + + # Expand collapsed paste placeholders back to their full content so the + # agent receives the original text, not the compact reference. + value = expand_paste_refs(value, self._pasted_contents) + value = self._replace_submitted_paths_with_images(value) + + mode = self.mode + if mode == "normal": + detected = detect_mode_prefix(value) + if detected is not None: + _, mode = detected + + # Prepend mode prefix so the app layer receives the original trigger + # form (e.g. "!ls", "/help"). The value may already contain the prefix + # when a completion controller wrote it back into the text area before + # the strip handler ran. + prefix = MODE_PREFIXES.get(mode, "") + if prefix and not value.startswith(prefix): + value = prefix + value + + # Placeholder spans were captured against the raw draft; the transforms + # above (whitespace strip, paste expansion, path substitution, prefix) + # shifted offsets. Re-map spans onto the final submitted text so the + # adapter strips the correct display token from the model-facing message + # instead of a same-looking literal the user typed. + if self._text_area is not None and self._image_tracker is not None: + self._image_tracker.remap_spans_to_text( + value, previous_text=self._text_area.text + ) + + self._history.add(value) + self.post_message(self.Submitted(value, mode)) + + if self._text_area: + # Preserve submission-time attachments until adapter consumes them. + self._skip_media_sync_events += 1 + self._text_area.clear_text() + # Clear only after submit. Ordinary edits are undoable, so removing + # backing content earlier can strand a restored placeholder. The input + # and its paste map are emptied together here, so IDs can safely restart + # at 1 for the next message. + self._pasted_contents.clear() + self._next_paste_id = 1 + self.mode = "normal" + + def _sync_media_tracker_to_text( + self, + text: str, + *, + previous_text: str | None = None, + cursor_offset: int | None = None, + ) -> None: + """Keep tracked media aligned with placeholder tokens in input text. + + Args: + text: Current text in the input area. + previous_text: Previous text in the input area. + cursor_offset: Current cursor offset in the input area. + """ + if not self._image_tracker: + return + if self._skip_media_sync_events: + if self._skip_media_sync_events < 0: + logger.warning( + "_skip_media_sync_events is negative (%d); resetting to 0", + self._skip_media_sync_events, + ) + self._skip_media_sync_events = 0 + else: + self._skip_media_sync_events -= 1 + return + self._image_tracker.sync_to_text( + text, previous_text=previous_text, cursor_offset=cursor_offset + ) + + def on_chat_text_area_typing( + self, + event: ChatTextArea.Typing, # noqa: ARG002 # Textual event handler signature + ) -> None: + """Relay typing activity to the app as `ChatInput.Typing`.""" + self.post_message(self.Typing()) + + def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None: + """Handle text submission. + + Always posts the Submitted event - the app layer decides whether to + process immediately or queue based on agent status. + """ + self._submit_value(event.value) + + def on_chat_text_area_history_previous( + self, event: ChatTextArea.HistoryPrevious + ) -> None: + """Handle history previous request.""" + entry = self._history.get_previous(event.current_text, query=event.current_text) + if entry is not None and self._text_area: + mode, display_text = self._history_entry_mode_and_text(entry) + self.mode = mode + # Cursor at top so pressing up again continues backward through + # history without the user having to navigate to the first row. + self._text_area.set_text_from_history(display_text, cursor_at_end=False) + else: + # No matching older entry — surface the boundary so the user + # doesn't think their keypress was lost. + self.app.bell() + + def on_chat_text_area_history_next( + self, + event: ChatTextArea.HistoryNext, # noqa: ARG002 # Textual event handler signature + ) -> None: + """Handle history next request.""" + entry = self._history.get_next() + if entry is not None and self._text_area: + mode, display_text = self._history_entry_mode_and_text(entry) + self.mode = mode + # Cursor at end so pressing down again continues forward through + # history. + self._text_area.set_text_from_history(display_text, cursor_at_end=True) + else: + self.app.bell() + + def on_chat_text_area_pasted_paths(self, event: ChatTextArea.PastedPaths) -> None: + """Handle paste payloads that resolve to dropped file paths.""" + if not self._text_area: + return + + self._insert_pasted_paths(event.raw_text, event.paths) + + def on_chat_text_area_pasted_text(self, event: ChatTextArea.PastedText) -> None: + """Handle large pastes by collapsing into a compact placeholder. + + Stores the full text in `_pasted_contents` and inserts a + `[Pasted text #N +M lines]` placeholder into the text area instead + of the raw content, keeping the input box compact. + + Args: + event: The `PastedText` message carrying the full pasted text. + """ + if not self._text_area: + return + self._collapse_and_insert_paste(event.text) + + def handle_external_paste(self, pasted: str) -> bool: + """Handle paste text from app-level routing when input is not focused. + + When the text area is mounted, the paste is always consumed: file paths + are attached as images, large text is collapsed into a placeholder, + and remaining plain text is inserted directly. + + Args: + pasted: Raw pasted text payload. + + Returns: + `True` when the text area is mounted and the paste was inserted, + `False` if the widget is not yet composed. + """ + if not self._text_area: + return False + + parsed = self._parse_dropped_path_payload(pasted) + if parsed is not None: + self._insert_pasted_paths(pasted, parsed.paths) + elif self._collapse_pastes and _should_collapse_chat_paste(pasted): + self._collapse_and_insert_paste(pasted) + else: + self._text_area.insert(pasted) + + self._text_area.focus() + return True + + def _collapse_and_insert_paste(self, text: str) -> None: + """Store full paste content and insert a compact placeholder. + + Pasting content identical to a visible already-collapsed placeholder + expands that placeholder back to the full text in place instead of + adding a second placeholder — a repeat paste is treated as a request to + see the content in full. + + Args: + text: The full pasted text to collapse. + """ + if not self._text_area: + logger.debug("Dropping collapsed paste: text area not mounted") + return + visible_ids = { + int(match.group(1)) + for match in PASTE_PLACEHOLDER_PATTERN.finditer(self._text_area.text) + } + match_id = next( + ( + pid + for pid, stored in self._pasted_contents.items() + if pid in visible_ids and stored.content == text + ), + None, + ) + if match_id is not None and self._text_area.replace_placeholder_with_text( + match_id, text + ): + return + paste_id = self._next_paste_id + self._next_paste_id += 1 + self._pasted_contents[paste_id] = PastedContent(content=text) + placeholder = format_paste_ref(paste_id, count_lines(text)) + self._text_area.insert(placeholder) + self.app.notify(_PASTE_COLLAPSED_TOAST, timeout=5, markup=False) + + def _apply_inline_dropped_path_replacement(self, text: str) -> bool: + """Replace full dropped-path payload text with image placeholders. + + Some terminals insert drag-and-drop payloads as plain text rather than + dispatching a dedicated paste event. When the current text resolves to + one or more file paths and at least one path is an image, rewrite the + text inline to `[image N]` placeholders. + + Args: + text: Current text area content. + + Returns: + `True` if text was rewritten inline, otherwise `False`. + """ + if not self._text_area: + return False + + parsed = self._parse_dropped_path_payload(text) + if parsed is None: + return False + + replacement, attached = self._build_path_replacement( + text, parsed.paths, add_trailing_space=True + ) + if not attached or replacement == text: + return False + + self._applying_inline_path_replacement = True + self._text_area.text = replacement + self._text_area.move_cursor_to_end() + return True + + def _insert_pasted_paths(self, raw_text: str, paths: list[Path]) -> None: + """Insert pasted path payload, attaching images when possible. + + Args: + raw_text: Original paste payload text. + paths: Resolved file paths parsed from the payload. + """ + if not self._text_area: + return + replacement, attached = self._build_path_replacement( + raw_text, paths, add_trailing_space=True + ) + if attached: + self._text_area.insert(replacement) + return + self._text_area.insert(raw_text) + + def _build_path_replacement( + self, + raw_text: str, + paths: list[Path], + *, + add_trailing_space: bool, + ) -> tuple[str, bool]: + """Build replacement text for dropped paths and attach any images. + + Args: + raw_text: Original paste payload text. + paths: Resolved file paths parsed from the payload. + add_trailing_space: Whether to append a trailing space after the + last token when paths are separated by spaces. + + Returns: + Tuple of `(replacement, attached)` where `attached` indicates whether + at least one media attachment (image or video) was created. + """ + if not self._image_tracker: + return raw_text, False + + from deepagents_code.media_utils import ( + MAX_MEDIA_BYTES, + VIDEO_EXTENSIONS, + ImageData, + get_media_from_path, + is_media_path, + ) + + parts: list[str] = [] + attached = False + for path in paths: + media = get_media_from_path(path) + if media is not None: + kind = "image" if isinstance(media, ImageData) else "video" + existing_text = self._text_area.text if self._text_area else raw_text + parts.append( + self._image_tracker.add_media( + media, + kind, + existing_text=existing_text, + ) + ) + attached = True + continue + + # Check if it looked like media but failed validation + suffix = path.suffix.lower() + if is_media_path(path): + label = "Video" if suffix in VIDEO_EXTENSIONS else "Image" + try: + size = path.stat().st_size + if size > MAX_MEDIA_BYTES: + msg = ( + f"{label} too large: {path.name} " + f"({size // (1024 * 1024)} MB, max " + f"{MAX_MEDIA_BYTES // (1024 * 1024)} MB)" + ) + else: + msg = f"Could not attach {label.lower()}: {path.name}" + except OSError as exc: + logger.debug("Failed to stat media file %s: %s", path, exc) + msg = f"Could not attach {label.lower()}: {path.name}" + self.app.notify(msg, severity="warning", timeout=5, markup=False) + + # Not a supported media file, keep as path + logger.debug("Could not load media from dropped path: %s", path) + parts.append(str(path)) + + if not attached: + return raw_text, False + + separator = "\n" if "\n" in raw_text else " " + replacement = separator.join(parts) + if separator == " " and add_trailing_space: + replacement += " " + return replacement, True + + def _replace_submitted_paths_with_images(self, value: str) -> str: + """Replace dropped-path payloads in submitted text with image placeholders. + + Handles both full-path payloads and leading-path-with-suffix payloads + (for example, `'' what is this?`). When command mode previously + stripped a leading slash, this method also retries with the slash + restored before giving up. + + Args: + value: Stripped submitted text (without mode prefix). + + Returns: + Submitted text with image placeholders when attachment succeeded. + """ + candidate, parsed = self._parse_dropped_path_payload_with_command_recovery( + value, allow_leading_path=True + ) + if parsed is None: + return value + + if parsed.token_end is None: + replacement, attached = self._build_path_replacement( + candidate, parsed.paths, add_trailing_space=False + ) + if attached: + return replacement.strip() + # Even when full-payload parsing resolves, still retry explicit + # leading-token extraction before giving up. + candidate, leading_match = ( + self._extract_leading_dropped_path_with_command_recovery(value) + ) + if leading_match is None: + return value + leading_path, token_end = leading_match + else: + leading_path = parsed.paths[0] + token_end = parsed.token_end + + replacement, attached = self._build_path_replacement( + str(leading_path), [leading_path], add_trailing_space=False + ) + if attached: + suffix = candidate[token_end:].lstrip() + if suffix: + return f"{replacement.strip()} {suffix}".strip() + return replacement.strip() + return value + + @staticmethod + def _history_entry_mode_and_text(entry: str) -> tuple[str, str]: + """Return mode and stripped display text for a history entry. + + Args: + entry: Raw entry value read from history storage. + + Returns: + Tuple of `(mode, display_text)` where mode-trigger prefixes are + removed from `display_text`. + """ + if mode_match := detect_mode_prefix(entry): + prefix, mode = mode_match + return mode, entry[len(prefix) :] + return "normal", entry + + async def on_key(self, event: events.Key) -> None: + """Handle key events for completion navigation.""" + if not self._completion_manager or not self._text_area: + return + + # Backspace at the start of a mode prompt exits the current mode. Prefix + # characters are mode selectors, not hidden draft text, so exiting the + # mode does not restore `/`, `!`, or `!!` into the input. + if ( + event.key == "backspace" + and self.mode != "normal" + and self._get_cursor_offset() == 0 + and not self._text_area.text + ): + # Schedule the popup reset alongside the prompt/style update so both + # visual changes land before the next paint. + def _deferred_reset() -> None: + if self._completion_manager is not None: + self._completion_manager.reset() + + self.call_next(_deferred_reset) + self.mode = "normal" + event.prevent_default() + event.stop() + return + + text, cursor = self._completion_text_and_cursor() + result = self._completion_manager.on_key(event, text, cursor) + + match result: + case CompletionResult.HANDLED: + event.prevent_default() + event.stop() + case CompletionResult.SUBMIT: + event.prevent_default() + event.stop() + self._submit_value(self._text_area.text.strip()) + case CompletionResult.IGNORED if event.key == "space": + # Space was intercepted (prevent_default) so the active + # controller could attempt completion. The controller + # declined (e.g. file completion), so insert the space that + # TextArea would have inserted normally. + self._text_area.insert(" ") + case CompletionResult.IGNORED if event.key == "enter": + # Handle Enter when completion is not active (shell/normal modes) + value = self._text_area.text.strip() + if value: + event.prevent_default() + event.stop() + self._submit_value(value) + + def _get_cursor_offset(self) -> int: + """Get the cursor offset as a single integer. + + Returns: + Cursor position as character offset from start of text. + """ + if not self._text_area: + return 0 + + text = self._text_area.text + row, col = self._text_area.cursor_location + + if not text: + return 0 + + lines = text.split("\n") + row = max(0, min(row, len(lines) - 1)) + col = max(0, col) + + offset = sum(len(lines[i]) + 1 for i in range(row)) + return offset + min(col, len(lines[row])) + + def watch_mode(self, mode: str) -> None: + """Post mode changed message and update prompt indicator. + + The prompt glyph update is scheduled for the next message-loop turn so + callers which also schedule popup work can coalesce both visual changes + before the next paint. + """ + # Keep inline argument hints in sync for mode-only transitions + # (for example, exiting command mode via Escape or backspace). + self._update_argument_hint() + + glyph = MODE_DISPLAY_GLYPHS.get(mode) + if not glyph and mode != "normal": + logger.warning( + "No display glyph for mode %r; falling back to '>'", + mode, + ) + + def _apply() -> None: + self.remove_class("mode-shell", "mode-command", "mode-shell-incognito") + if glyph: + class_name = ( + "mode-shell-incognito" + if mode == "shell_incognito" + else f"mode-{mode}" + ) + self.add_class(class_name) + try: + prompt = self.query_one("#prompt", Static) + except NoMatches: + logger.warning("watch_mode._apply: prompt widget not found") + if mode == "shell_incognito": + # Privacy-sensitive: surface a visible warning so the user + # never types an incognito command without confirmation + # that the mode is active. + app = getattr(self, "app", None) + if app is not None: + with contextlib.suppress(Exception): + app.notify( + "Incognito mode UI failed to render; " + "switching back to normal input.", + severity="warning", + markup=False, + ) + self.mode = "normal" + return + prompt.update(glyph or ">") + if self._input_box is not None: + self._input_box.border_title = ( + "incognito" if mode == "shell_incognito" else None + ) + + self.call_next(_apply) + self.post_message(self.ModeChanged(mode)) + + def focus_input(self) -> None: + """Focus the input field.""" + if self._text_area: + self._text_area.focus() + + @property + def value(self) -> str: + """Current input value. + + Returns: + Current text in the input field. + """ + if self._text_area: + return self._text_area.text + return "" + + @value.setter + def value(self, val: str) -> None: + """Set the input value.""" + if self._text_area: + self._text_area.text = val + + def set_value_at_end(self, val: str) -> bool: + """Set the input value and place the cursor at the end of the text. + + Returns: + `True` when the value was written, `False` when the text area is + unavailable and the value could not be set. Callers that surface a + "moved to input" toast should gate it on this so the toast never + claims a write that did not happen. + """ + if not self._text_area: + return False + self._text_area.text = val + self._text_area.move_cursor_to_end() + return True + + def discard_text(self) -> bool: + """Clear the draft, keeping it restorable via undo (ctrl+z). + + Returns: + `True` when there was text to clear. + """ + if self._text_area is None: + return False + if self._text_area.text: + self._skip_media_sync_events += 1 + return self._text_area.discard_text() + + def on_input_action_button_clicked(self, event: InputActionButton.Clicked) -> None: + """Handle clicks on the `[ X ]` / `[ COPY ]` input buttons.""" + event.stop() + if event.action == "clear": + self._clear_via_button() + elif event.action == "copy": + self._copy_via_button() + else: + assert_never(event.action) + + def _clear_via_button(self) -> None: + """Clear the draft from the `[ X ]` button (undoable with ctrl+z). + + Also exits any active slash/shell mode, unlike the Esc-driven clear. + """ + cleared = self.discard_text() + self.exit_mode() + if cleared: + self.app.notify("Input cleared (ctrl+z to undo)", timeout=3, markup=False) + if self._text_area is not None: + self._text_area.focus() + + def _copy_via_button(self) -> None: + """Copy the current draft to the clipboard from the `[ COPY ]` button.""" + from deepagents_code.clipboard import copy_text_with_feedback + + text = expand_paste_refs(self.value, self._pasted_contents) + if text: + copy_text_with_feedback( + self.app, + text, + failure_noun="input", + success_message="Input copied to clipboard", + ) + # Refocus the input so clicking the button never strands focus on the + # (non-focusable) button. + if self._text_area is not None: + self._text_area.focus() + + @property + def input_widget(self) -> ChatTextArea | None: + """Underlying `TextArea` widget. + + Returns: + The `ChatTextArea` widget or `None` if not mounted. + """ + return self._text_area + + def set_disabled(self, *, disabled: bool) -> None: + """Enable or disable the input widget.""" + if self._text_area: + self._text_area.disabled = disabled + if disabled: + self._text_area.blur() + if self._completion_manager: + self._completion_manager.reset() + + def set_cursor_active(self, *, active: bool) -> None: + """Toggle input focus state (e.g., unfocus while agent is working). + + Args: + active: Whether the input should be focused and accepting input. + """ + if self._text_area: + self._text_area.set_app_focus(has_focus=active) + + def set_cursor_style(self, *, style: CursorStyle) -> None: + """Set the input cursor's visual style. + + Args: + style: Whether to render a block or underlined character cell. + """ + if self._text_area is not None: + self._text_area.set_class(style == "underline", "cursor-underline") + + def set_cursor_blink(self, *, blink: bool) -> None: + """Toggle the input's cursor blink without changing focus. + + Args: + blink: Whether the cursor should blink. + """ + if self._text_area is not None: + self._text_area.cursor_blink = blink + + def _notify_app_blur(self) -> None: + """Tell the text area the terminal window lost OS focus.""" + if self._text_area is not None: + self._text_area._notify_app_blur() + + def _notify_app_focus(self) -> None: + """Tell the text area the terminal window regained OS focus.""" + if self._text_area is not None: + self._text_area._notify_app_focus() + + def exit_mode(self) -> bool: + """Exit the current input mode (command/shell) back to normal. + + Returns: + True if mode was non-normal and has been reset. + """ + if self.mode == "normal": + return False + self.mode = "normal" + if self._completion_manager: + self._completion_manager.reset() + self.clear_completion_suggestions() + return True + + def dismiss_completion(self) -> bool: + """Dismiss completion: clear view and reset controller state. + + Returns: + True if completion was active and has been dismissed. + """ + if not self._current_suggestions: + return False + if self._completion_manager: + self._completion_manager.reset() + # Always clear local state so the popup is hidden even if the + # manager's active controller was already None (no-op reset). + self.clear_completion_suggestions() + return True + + # ========================================================================= + # CompletionView protocol implementation + # ========================================================================= + + def render_completion_suggestions( + self, suggestions: list[tuple[str, str]], selected_index: int + ) -> None: + """Render completion suggestions in the popup.""" + prev_suggestions = self._current_suggestions + self._current_suggestions = suggestions + self._current_selected_index = selected_index + + if self._popup: + # If only the selection changed (same items), skip full rebuild + if suggestions == prev_suggestions: + self._popup.update_selection(selected_index) + else: + self._popup.update_suggestions(suggestions, selected_index) + # Tell TextArea that completion is active so it yields navigation keys + if self._text_area: + self._text_area.set_completion_active(active=bool(suggestions)) + + def clear_completion_suggestions(self) -> None: + """Clear/hide the completion popup.""" + self._current_suggestions = [] + self._current_selected_index = 0 + + if self._popup: + self._popup.hide() + # Tell TextArea that completion is no longer active + if self._text_area: + self._text_area.set_completion_active(active=False) + + def on_completion_popup_option_clicked( + self, event: CompletionPopup.OptionClicked + ) -> None: + """Handle click on a completion option.""" + if not self._current_suggestions or not self._text_area: + return + + index = event.index + if index < 0 or index >= len(self._current_suggestions): + return + + # Get the selected completion + label, _ = self._current_suggestions[index] + text = self._text_area.text + cursor = self._get_cursor_offset() + + # Determine replacement range based on completion type. + # Slash completions use completion-space coordinates and are translated + # through the completion view adapter. + if label.startswith("/"): + if self._completion_view is None: + logger.warning( + "Slash completion clicked but _completion_view is not " + "initialized; this indicates a widget lifecycle issue." + ) + return + _, virtual_cursor = self._completion_text_and_cursor() + self._completion_view.replace_completion_range(0, virtual_cursor, label) + elif label.startswith("@"): + # File mention: replace from @ to cursor + at_index = text[:cursor].rfind("@") + if at_index >= 0: + self.replace_completion_range(at_index, cursor, label) + + # Reset completion state + if self._completion_manager: + self._completion_manager.reset() + + # Re-focus the text input after click + self._text_area.focus() + + def replace_completion_range(self, start: int, end: int, replacement: str) -> None: + """Replace text in the input field.""" + if not self._text_area: + return + + text = self._text_area.text + + start = max(0, min(start, len(text))) + end = max(start, min(end, len(text))) + + prefix = text[:start] + suffix = text[end:] + + # Add space after completion unless it's a directory path + if replacement.endswith("/"): + insertion = replacement + else: + insertion = replacement + " " if not suffix.startswith(" ") else replacement + + new_text = f"{prefix}{insertion}{suffix}" + self._text_area.text = new_text + + # Calculate new cursor position and move cursor + new_offset = start + len(insertion) + lines = new_text.split("\n") + remaining = new_offset + for row, line in enumerate(lines): + if remaining <= len(line): + self._text_area.move_cursor((row, remaining)) + break + remaining -= len(line) + 1 + + # Completion selections should render their final inline hint + # immediately, without waiting for the subsequent Changed event. + self._update_argument_hint() diff --git a/libs/code/deepagents_code/tui/widgets/codex_auth.py b/libs/code/deepagents_code/tui/widgets/codex_auth.py new file mode 100644 index 0000000000..85f321f81b --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/codex_auth.py @@ -0,0 +1,452 @@ +"""ChatGPT OAuth sign-in screen, reachable via `/auth` -> `openai_codex`. + +Mirrors the MCP loopback flow in `mcp_auth` from the user's POV: a modal +shows progress, surfaces the authorize URL inline (so headless / SSH users +can copy it when the browser launch fails), and dismisses once the OAuth +callback completes. The OAuth primitives themselves (PKCE, callback HTTP +server, token exchange, refresh, atomic file write) are delegated to +`langchain_openai.chatgpt_oauth` via the +`deepagents_code.integrations.openai_codex` adapter. + +Security notes: + +- The authorize URL displayed inline does not contain secrets — it carries + only the PKCE *challenge* (the verifier never leaves this process). +- The success / error messages reported back via `notify` never include + the access token, refresh token, or ID token. +""" + +from __future__ import annotations + +import logging +import threading +import webbrowser +from enum import StrEnum +from typing import TYPE_CHECKING, ClassVar + +from textual.binding import Binding, BindingType +from textual.color import Color as TColor +from textual.containers import Vertical +from textual.content import Content +from textual.screen import ModalScreen +from textual.style import Style as TStyle +from textual.widgets import Static +from textual.worker import Worker, WorkerCancelled, WorkerFailed, WorkerState + +from deepagents_code import theme +from deepagents_code.config import get_glyphs, is_ascii_mode +from deepagents_code.integrations import openai_codex as codex_integration +from deepagents_code.model_config import clear_caches +from deepagents_code.tui.widgets._links import open_style_link + +if TYPE_CHECKING: + from textual.app import ComposeResult + from textual.events import Click, MouseMove + +logger = logging.getLogger(__name__) + + +class _ScreenInteraction(codex_integration.CodexLoginInteraction): + """Bridge `CodexLoginInteraction` callbacks into the modal. + + `run_browser_login` runs from a Textual *async* worker (not a thread), + so it shares the app's event loop. That means the callbacks land on + the same thread Textual renders from and can mutate widgets directly — + no `call_from_thread` round-trip required (using it from the UI thread + would raise). + """ + + def __init__(self, screen: CodexAuthScreen) -> None: + """Bind the interaction to the modal it should drive.""" + self._screen = screen + + async def show_authorize_url( # awaited by the interaction protocol + self, url: str, *, opened_in_browser: bool + ) -> None: + self._screen.on_authorize_url(url, opened_in_browser) + + +class CodexAuthScreen(ModalScreen[bool]): + """Run the ChatGPT OAuth Authorization Code Flow with PKCE inline. + + Dismissal value: + + - `True`: a token was saved (caller should refresh provider lists / + retry the operation that needed the credential). + - `False`: the user cancelled, or the flow failed irrecoverably. + + The flow lives in a worker so the modal stays responsive to the cancel + keybinding while `_wait_for_oauth_callback` blocks for up to 5 minutes; + pressing Esc sets the worker's `cancel_event`, which frees the loopback + port within one poll interval rather than holding it until the timeout. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "cancel", "Cancel", show=False, priority=True), + Binding("ctrl+c", "cancel", "Cancel", show=False, priority=True), + ] + + CSS = """ + CodexAuthScreen { + align: center middle; + } + + CodexAuthScreen > Vertical { + width: 80; + max-width: 90%; + height: auto; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + CodexAuthScreen .codex-auth-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + CodexAuthScreen .codex-auth-copy { + height: auto; + color: $text; + margin-bottom: 1; + } + + CodexAuthScreen .codex-auth-status { + height: auto; + color: $text-muted; + margin-bottom: 1; + } + + CodexAuthScreen .codex-auth-url { + height: auto; + color: $text; + margin-bottom: 1; + text-style: italic; + } + + CodexAuthScreen .codex-auth-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def __init__(self) -> None: + """Initialize with no active worker; the flow starts on mount.""" + super().__init__() + self._cancel_event = threading.Event() + self._worker: Worker[codex_integration.CodexAuthStatus] | None = None + + def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual handler signature + """Compose the modal layout. + + Yields: + Title, copy, status line, URL line, and help footer widgets. + """ + glyphs = get_glyphs() + with Vertical(): + yield Static( + "Sign in with ChatGPT", + classes="codex-auth-title", + ) + yield Static( + Content.assemble( + "Authorize Deep Agents to call ChatGPT Codex models on " + "your behalf. We will open your default browser to " + "openai.com to sign in.", + ), + classes="codex-auth-copy", + ) + yield Static( + "Preparing OAuth flow...", + id="codex-auth-status", + classes="codex-auth-status", + ) + yield Static( + "", + id="codex-auth-url", + classes="codex-auth-url", + ) + yield Static( + f"Esc cancel {glyphs.bullet} a browser window will open shortly", + classes="codex-auth-help", + ) + + def on_mount(self) -> None: + """Apply ASCII border when needed and kick off the OAuth worker.""" + if is_ascii_mode(): + container = self.query_one(Vertical) + colors = theme.get_theme_colors(self) + container.styles.border = ("ascii", colors.success) + self._worker = self.run_worker( + self._run_login(), + name="codex-oauth", + exclusive=True, + thread=False, + ) + + def on_click(self, event: Click) -> None: # noqa: PLR6301 - Textual handler + """Open the authorize URL when the user clicks it.""" + open_style_link(event) + + def on_mouse_move(self, event: MouseMove) -> None: + """Show a pointer over inline authorization links.""" + self.styles.pointer = "pointer" if event.style.link else "default" + + def on_leave(self) -> None: + """Reset the pointer shape when the mouse leaves the modal.""" + self.styles.pointer = "default" + + async def _run_login(self) -> codex_integration.CodexAuthStatus: + """Worker body: drive the upstream OAuth flow with our UI hooks. + + Returns: + The fresh `CodexAuthStatus` returned by `run_browser_login`, used + by `on_worker_state_changed` to render the success toast. + + Raises: + CodexLoginCancelledError: Re-raised so the worker enters the + ERROR state and `on_worker_state_changed` can translate it + into a "cancelled" toast and modal dismissal. + """ + try: + status = await codex_integration.run_browser_login( + _ScreenInteraction(self), + cancel_event=self._cancel_event, + ) + except codex_integration.CodexLoginCancelledError: + logger.info("ChatGPT OAuth sign-in cancelled by user") + raise + clear_caches() + return status + + def on_authorize_url(self, url: str, opened_in_browser: bool) -> None: + """Render the authorize URL in the modal. + + Called on the event loop from the async sign-in worker (the worker is + started with `thread=False`), so it can mutate widgets directly. + """ + status = self.query_one("#codex-auth-status", Static) + url_label = self.query_one("#codex-auth-url", Static) + if opened_in_browser: + status.update("Waiting for you to finish signing in...") + else: + status.update("Could not launch a browser — open this URL manually:") + colors = theme.get_theme_colors(self) + ansi = self.app.theme in {"ansi-dark", "ansi-light"} + link_style: str | TStyle = ( + TStyle(bold=True, underline=True, link=url) + if ansi + else TStyle( + foreground=TColor.parse(colors.primary), + underline=True, + link=url, + ) + ) + # `Content.assemble` with a (text, style) tuple skips markup parsing, + # so a URL containing `[` (rare but possible in state params) cannot + # crash the renderer. + url_label.update(Content.assemble((url, link_style))) + + def on_worker_state_changed(self, event: Worker.StateChanged) -> None: + """React to worker completion: notify, then dismiss the modal.""" + if event.worker is not self._worker: + return + state = event.state + if state is WorkerState.SUCCESS: + result = event.worker.result + detail = "Signed in to ChatGPT." + if ( + isinstance(result, codex_integration.CodexAuthStatus) + and result.plan_type + ): + detail = f"Signed in to ChatGPT ({result.plan_type})." + self.app.notify(detail, markup=False) + self.dismiss(True) + elif state is WorkerState.CANCELLED: + self.app.notify("Sign-in cancelled.", markup=False) + self.dismiss(False) + elif state is WorkerState.ERROR: + error = event.worker.error + if isinstance(error, WorkerCancelled): + self.app.notify("Sign-in cancelled.", markup=False) + self.dismiss(False) + return + # `WorkerFailed` wraps the real exception (surfaced via + # `error.error`); unwrap it so we can both detect a cancellation + # that arrived via ERROR rather than CANCELLED and render an + # accurate message. Test `inner`, not the wrapper. + inner = ( + getattr(error, "error", error) + if isinstance(error, WorkerFailed) + else error + ) + if isinstance(inner, codex_integration.CodexLoginCancelledError): + self.app.notify("Sign-in cancelled.", markup=False) + self.dismiss(False) + return + detail = str(inner) if inner else "Sign-in failed." + logger.warning("ChatGPT OAuth sign-in failed: %s", detail) + self.app.notify( + f"Sign-in failed: {detail}", + severity="error", + markup=False, + ) + self.dismiss(False) + + def action_cancel(self) -> None: + """Cancel the sign-in flow and dismiss the modal.""" + self._cancel_event.set() + if self._worker is not None: + self._worker.cancel() + # `cancel()` triggers `WorkerState.CANCELLED` on the worker, which + # `on_worker_state_changed` translates into the dismissal. Don't + # dismiss eagerly here — that would race the success / error path + # if the callback already landed. + # However, if the worker hasn't been created yet (mount race), make + # sure the modal still goes away. + if self._worker is None: + self.dismiss(False) + + +class CodexSignedInAction(StrEnum): + """Outcome of the `CodexSignedInScreen` quick-action overlay. + + Encoded as an enum (mirroring `AuthResult`) rather than bare strings so a + typo in either the producing `action_*` method or the consuming dispatch + is a type error, not a silent no-op. + """ + + SIGN_OUT = "signout" + """Delete the stored ChatGPT token.""" + + REAUTH = "reauth" + """Open the OAuth flow again (e.g., to switch account).""" + + +class CodexSignedInScreen(ModalScreen["CodexSignedInAction | None"]): + """Quick-action overlay shown when `openai_codex` is already signed in. + + Dismissal values: + + - `CodexSignedInAction.SIGN_OUT`: delete the stored token. + - `CodexSignedInAction.REAUTH`: open the OAuth flow again. + - `None`: close without changes. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "cancel", "Cancel", show=False, priority=True), + Binding("s", "signout", "Sign out", show=False, priority=True), + Binding("r", "reauth", "Reauth", show=False, priority=True), + ] + + CSS = """ + CodexSignedInScreen { + align: center middle; + } + + CodexSignedInScreen > Vertical { + width: 64; + height: auto; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + CodexSignedInScreen .codex-signed-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + CodexSignedInScreen .codex-signed-copy { + height: auto; + margin-bottom: 1; + } + + CodexSignedInScreen .codex-signed-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual handler signature + """Compose the overlay. + + Yields: + Title + body + key-hint widgets. + """ + glyphs = get_glyphs() + status = codex_integration.get_status() + if status.plan_type and status.account_id: + body = ( + f"Signed in to ChatGPT ({status.plan_type}) as account " + f"{status.account_id}." + ) + elif status.plan_type: + body = f"Signed in to ChatGPT ({status.plan_type})." + else: + body = "Signed in to ChatGPT." + with Vertical(): + yield Static("ChatGPT sign-in", classes="codex-signed-title") + yield Static( + Content.from_markup("$body", body=body), + classes="codex-signed-copy", + ) + yield Static( + f"S sign out {glyphs.bullet} R sign in again {glyphs.bullet} Esc close", + classes="codex-signed-help", + ) + + def on_mount(self) -> None: + """Apply ASCII border when needed.""" + if is_ascii_mode(): + container = self.query_one(Vertical) + colors = theme.get_theme_colors(self) + container.styles.border = ("ascii", colors.success) + + def action_signout(self) -> None: + """Dismiss with `SIGN_OUT` so the manager deletes the stored token.""" + self.dismiss(CodexSignedInAction.SIGN_OUT) + + def action_reauth(self) -> None: + """Dismiss with `REAUTH` so the manager kicks off a new flow.""" + self.dismiss(CodexSignedInAction.REAUTH) + + def action_cancel(self) -> None: + """Close without changes.""" + self.dismiss(None) + + +def open_chatgpt_login_url() -> bool: + """Open the ChatGPT account page in the user's browser. + + Used by `/auth` to let signed-in users jump to chatgpt.com (e.g., to + change account, manage billing). + + Returns: + Whether a browser actually launched — callers can fall back to a + manual-URL toast on `False`. + """ + try: + return webbrowser.open("https://chatgpt.com/") + except (webbrowser.Error, OSError) as exc: + # `OSError` (not just `webbrowser.Error`) escapes when a configured + # launcher's binary is missing; treat it as "no browser launched" so + # the caller can fall back to a manual-URL toast. + logger.warning("Could not open chatgpt.com: %s", exc) + return False + + +__all__ = [ + "CodexAuthScreen", + "CodexSignedInAction", + "CodexSignedInScreen", + "open_chatgpt_login_url", +] diff --git a/libs/code/deepagents_code/tui/widgets/context_usage.py b/libs/code/deepagents_code/tui/widgets/context_usage.py new file mode 100644 index 0000000000..2c0e480f2c --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/context_usage.py @@ -0,0 +1,169 @@ +"""Color-coded context-window visualization for `/context`.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from textual.binding import Binding, BindingType +from textual.containers import VerticalScroll +from textual.content import Content +from textual.screen import ModalScreen +from textual.widgets import Static + +from deepagents_code import theme +from deepagents_code._session_stats import format_token_count +from deepagents_code.config import get_glyphs, is_ascii_mode + +if TYPE_CHECKING: + from textual.app import ComposeResult + + +class _ContextUsage(Static): + def __init__( + self, + *, + context_tokens: int | None, + conversation_tokens: int | None, + context_limit: int | None, + model_spec: str | None, + approximate: bool, + ) -> None: + super().__init__() + self._total = None if context_tokens is None else max(0, context_tokens) + self._conversation = ( + None if conversation_tokens is None else max(0, conversation_tokens) + ) + self._limit = context_limit if context_limit and context_limit > 0 else None + self._model = model_spec or "Unknown model" + self._approximate = approximate + + def render(self) -> Content: + colors = theme.get_theme_colors(self) + glyphs = get_glyphs() + usage = self._total if self._total is not None else self._conversation or 0 + maximum = format_token_count(self._limit) if self._limit else "unavailable" + prefix = "~" if self._approximate or self._total is None else "" + right = f"{prefix}{format_token_count(usage)} / {maximum}" + if self._total is not None and self._limit: + right += f" {self._total / self._limit * 100:.1f}%" + left = Content.assemble( + ("Context", f"bold {colors.primary}"), + (f" {glyphs.bullet} ", colors.muted), + self._model, + ) + gap = self.content_size.width - left.cell_length - len(right) + header = Content.assemble(left, " " * max(1, gap), (right, colors.muted)) + + categories: list[tuple[str, int, str]] = [] + if self._total is None: + if self._conversation: + categories.append( + ("Conversation estimate", self._conversation, colors.primary) + ) + elif self._total: + if self._conversation is None: + categories.append(("Used context", self._total, colors.secondary)) + else: + conversation = min(self._conversation, self._total) + if overhead := self._total - conversation: + categories.append( + ("System prompt + tools", overhead, colors.warning) + ) + if conversation: + categories.append(("Conversation", conversation, colors.primary)) + if self._total is not None and self._limit: + categories.append( + ("Free space", max(0, self._limit - self._total), colors.muted) + ) + + scale = max(self._limit or 0, sum(tokens for _, tokens, _ in categories), 1) + width = max(self.content_size.width, 1) + used = 0 + segments: list[Content] = [] + for _label, tokens, color in categories: + end = round(min(scale, used + tokens) / scale * width) + start = round(min(scale, used) / scale * width) + segments.append( + Content.styled(glyphs.box_horizontal_heavy * (end - start), color) + ) + used += tokens + bar = Content.assemble(*segments) + + rows: list[Content] = [] + marker = glyphs.box_horizontal_heavy * 2 + for label, tokens, color in categories: + percent = tokens / scale * 100 + value = f"{format_token_count(tokens)} {glyphs.bullet} {percent:.1f}%" + item = Content.assemble((marker, color), " ", label) + rows.append( + Content.assemble( + item, + " " * max(1, width - item.cell_length - len(value)), + (value, colors.muted), + ) + ) + if not rows: + rows.append(Content.styled("No context usage reported yet.", colors.muted)) + elif self._total is None: + rows.append(Content.styled("Total usage unavailable.", colors.muted)) + return Content("\n").join((header, bar, *rows)) + + +class ContextUsageScreen(ModalScreen[None]): + """Modal visualization of the current model context window.""" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "close", "Close", show=False) + ] + CSS = """ + ContextUsageScreen { align: center middle; } + ContextUsageScreen > VerticalScroll { + width: 94%; max-width: 120; height: auto; max-height: 90%; + background: $surface; border: solid $primary; padding: 1 2; + } + ContextUsageScreen _ContextUsage { height: auto; } + ContextUsageScreen .context-usage-help { + height: 1; color: $text-muted; margin-top: 2; + } + """ + + def __init__( + self, + *, + context_tokens: int | None, + conversation_tokens: int | None, + context_limit: int | None, + model_spec: str | None, + approximate: bool, + ) -> None: + """Initialize the modal from the latest usage measurements.""" + super().__init__() + self._usage = _ContextUsage( + context_tokens=context_tokens, + conversation_tokens=conversation_tokens, + context_limit=context_limit, + model_spec=model_spec, + approximate=approximate, + ) + + def compose(self) -> ComposeResult: + """Compose the context visualization and close hint. + + Yields: + Widgets that make up the modal. + """ + with VerticalScroll(): + yield self._usage + yield Static("Esc to close", classes="context-usage-help") + + def on_mount(self) -> None: + """Use an ASCII border when the terminal cannot render Unicode.""" + if is_ascii_mode(): + self.query_one(VerticalScroll).styles.border = ( + "ascii", + theme.get_theme_colors(self).primary, + ) + + def action_close(self) -> None: + """Dismiss the context visualization.""" + self.dismiss(None) diff --git a/libs/code/deepagents_code/tui/widgets/cwd_switch.py b/libs/code/deepagents_code/tui/widgets/cwd_switch.py new file mode 100644 index 0000000000..2802d8d10f --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/cwd_switch.py @@ -0,0 +1,325 @@ +"""Prompt for switching cwd when resuming or switching threads.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal, assert_never, cast + +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.content import Content +from textual.screen import ModalScreen +from textual.widgets import Static + +from deepagents_code.sessions import format_path + +if TYPE_CHECKING: + from textual.app import ComposeResult + + from deepagents_code.app import DeepAgentsApp + + +CwdSwitchChoice = Literal["switch", "stay", "abort"] +"""Outcome of the cwd switch prompt. + +`"abort"` is only offered when the prompt is opened with an `abort` mode set; +its meaning depends on that mode (see `CwdSwitchAbortMode`). +""" + +CwdSwitchAbortMode = Literal["resume", "thread_switch"] +"""Which flow opened an abort-capable prompt, selecting the abort wording. + +Passed as the prompt's `abort` argument; `None` there means abort is not +offered. `"resume"` is the launch-time `-r` resume (abort starts a new +session); `"thread_switch"` is the in-session `/threads` switcher (abort keeps +the current thread). Its members are kept disjoint from `CwdSwitchChoice`'s as a +naming convention -- not a type guarantee (these are distinct `Literal` types +used at distinct sites, so a checker already keeps them apart) -- so a mode token +is never mistaken for an outcome token in a log, test, or debugger. +`test_abort_mode_tokens_disjoint_from_choice` enforces it. +""" + + +class CwdSwitchPromptScreen(ModalScreen[CwdSwitchChoice]): + """Modal asking whether to switch cwd when resuming or switching to a thread.""" + + can_focus = True + can_focus_children = False + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "switch", "Switch", show=False, priority=True), + Binding("escape", "stay", "Stay", show=False, priority=True), + Binding("a", "abort", "Abort", 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), + ] + + CSS = """ + CwdSwitchPromptScreen { + align: center middle; + } + + CwdSwitchPromptScreen > Vertical { + width: 72; + max-width: 90%; + height: auto; + background: $surface; + border: solid $warning; + padding: 1 2; + } + + CwdSwitchPromptScreen .cwd-switch-title { + text-style: bold; + color: $warning; + text-align: center; + margin-bottom: 1; + } + + CwdSwitchPromptScreen .cwd-switch-body { + height: auto; + color: $text; + margin-bottom: 1; + } + + CwdSwitchPromptScreen .cwd-switch-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def __init__( + self, + *, + current_cwd: str, + thread_cwd: str, + project_settings_change_detected: bool = False, + abort: CwdSwitchAbortMode | None = None, + ) -> None: + """Initialize the prompt.""" + super().__init__() + self._current_cwd = current_cwd + self._thread_cwd = thread_cwd + self._project_settings_change_detected = project_settings_change_detected + self._abort: CwdSwitchAbortMode | None = abort + + def _title_text(self) -> str: + """Return the title, phrased for the flow that opened the prompt. + + The in-session `/threads` switcher (`"thread_switch"`) asks about + switching; every other flow (launch-time resume, or no abort mode) asks + about resuming. Structured for `assert_never` exhaustiveness so a new + mode fails statically here rather than silently inheriting the resume + wording. + """ + if self._abort is None or self._abort == "resume": + return "Resume from the thread's original directory?" + if self._abort == "thread_switch": + return "Switch to the thread's original directory?" + assert_never(self._abort) + + def _body_text(self) -> str: + """Return the prompt body text.""" + current = format_path(self._current_cwd) + target = format_path(self._thread_cwd) + settings_note = ( + "\n\nSwitching may also reload project-specific config like .env, " + "MCP, skills, and AGENTS.md." + if self._project_settings_change_detected + else "" + ) + if self._abort is None or self._abort == "thread_switch": + abort_note = "" + elif self._abort == "resume": + abort_note = "\n\nOr abort to start a new session instead of resuming." + else: + assert_never(self._abort) + return ( + "This thread was last used from:\n" + f" {target}\n\n" + "You're currently in:\n" + f" {current}\n\n" + "Switch if you want local context, project instructions, skills, " + "MCP config, and env files to match the original directory. Stay " + "here if you intentionally want to continue this thread against " + f"the current directory.{settings_note}{abort_note}" + ) + + def _help_text(self) -> str: + """Return the help line text, naming the mode's abort action if offered.""" + help_text = "Enter: switch · Esc: stay in cwd" + if self._abort is None: + return help_text + if self._abort == "resume": + abort_help = "A: don't resume" + elif self._abort == "thread_switch": + abort_help = "A: don't switch" + else: + assert_never(self._abort) + return f"{help_text} · {abort_help}" + + def compose(self) -> ComposeResult: + """Compose the confirmation dialog. + + Yields: + Widgets for the cwd switch prompt. + """ + with Vertical(): + yield Static( + self._title_text(), + classes="cwd-switch-title", + markup=False, + ) + yield Static( + self._body_text(), + classes="cwd-switch-body", + markup=False, + ) + yield Static( + self._help_text(), + classes="cwd-switch-help", + markup=False, + ) + + def on_mount(self) -> None: + """Focus the modal so screen bindings work after nested modal flows.""" + self.focus() + + def check_action( + self, + action: str, + parameters: tuple[object, ...], # noqa: ARG002 # required by Textual's DOMNode.check_action override signature + ) -> bool | None: + """Disable the `abort` binding unless the prompt was opened for it. + + Textual gates a binding's action on a truthy `check_action` result, so + both `False` and `None` stop `a` from dispatching `action_abort` -- in + neither case does the key fire the action, and it falls through the same + way. They differ only in footer presentation (`False` hides the binding, + `None` shows it grayed out), which is moot here anyway: every binding is + declared `show=False` and the modal renders its own help line instead of + a `Footer`. We return `False` to mark the disabled state explicitly; the + actual inertness backstop is `action_abort`'s own `self._abort is None` + guard, should the action ever be dispatched. + + Returns: + `self._abort is not None` for the `abort` action, so the binding is + enabled only when abort was offered; `True` for every other action. + """ + if action == "abort": + return self._abort is not None + return True + + def action_switch(self) -> None: + """Dismiss with `switch`.""" + self.dismiss("switch") + + def action_stay(self) -> None: + """Dismiss with `stay`.""" + self.dismiss("stay") + + def action_abort(self) -> None: + """Dismiss with `abort` to skip the resume/switch, when the prompt allows it.""" + if self._abort is None: + return + self.dismiss("abort") + + def action_cancel(self) -> None: + """Treat cancellation as staying in the current cwd.""" + self.action_stay() + + def action_quit_or_interrupt(self) -> None: + """Delegate Ctrl+C to the app-level quit/interrupt handler.""" + cast("DeepAgentsApp", self.app).action_quit_or_interrupt() + + def action_quit_app(self) -> None: + """Delegate Ctrl+D to the app-level quit handler.""" + cast("DeepAgentsApp", self.app).action_quit_app() + + +HookTrustChoice = Literal["allow_once", "always_allow", "deny"] + + +class HookTrustScreen(ModalScreen[HookTrustChoice]): + """Ask how project hooks in a newly entered workspace should be trusted.""" + + can_focus = True + can_focus_children = False + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "allow_once", "Allow once", show=False, priority=True), + Binding("a", "always_allow", "Always allow", show=False, priority=True), + Binding("escape", "deny", "Deny", show=False, priority=True), + ] + + CSS = CwdSwitchPromptScreen.CSS.replace( + "CwdSwitchPromptScreen", "HookTrustScreen" + ).replace("width: 72;", "width: 76;") + + def __init__(self, *, project_root: str, config_path: str) -> None: + """Initialize the project-hooks trust prompt. + + Args: + project_root: Workspace root governing the trust decision. + config_path: Project hooks file that may execute commands. + """ + super().__init__() + self._project_root = project_root + self._config_path = config_path + + def compose(self) -> ComposeResult: + """Compose the project-hooks trust dialog. + + Yields: + Title, warning body, and keyboard help widgets. + """ + with Vertical(): + yield Static( + "Project hooks can run arbitrary shell commands on your machine", + classes="cwd-switch-title", + markup=False, + ) + yield Static( + Content.from_markup( + "[bold]$root[/bold] contains project hooks at " + "[bold]$path[/bold]. Only trust projects you control. " + '"Allow once" runs the file as it is now; "always allow" ' + "trusts [bold]$root[/bold] for future sessions and future " + "edits.", + root=self._project_root, + path=self._config_path, + ), + classes="cwd-switch-body", + markup=False, + ) + yield Static( + "Enter: allow once · A: always allow · Esc: deny", + classes="cwd-switch-help", + markup=False, + ) + + def on_mount(self) -> None: + """Focus the modal so its bindings receive keyboard input.""" + self.focus() + + def action_allow_once(self) -> None: + """Approve the current file contents for this session.""" + self.dismiss("allow_once") + + def action_always_allow(self) -> None: + """Approve this workspace persistently.""" + self.dismiss("always_allow") + + def action_deny(self) -> None: + """Deny project hooks in this workspace.""" + self.dismiss("deny") + + def action_cancel(self) -> None: + """Treat app-level cancellation as deny.""" + self.action_deny() diff --git a/libs/code/deepagents_code/tui/widgets/debug_console.py b/libs/code/deepagents_code/tui/widgets/debug_console.py new file mode 100644 index 0000000000..bbd0677a23 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/debug_console.py @@ -0,0 +1,1268 @@ +r"""Read-only in-app Debug Console modal. + +Toggled with `Ctrl+\` (or the hidden `/debug` command), this overlay shows a +live session/runtime snapshot plus a live tail of recent +`deepagents_code.*` log records sourced from the in-memory ring buffer in +`_debug_buffer`. The snapshot is seeded at open and, when the host supplies a +`snapshot_provider`, rebuilt on the same refresh tick as the log tail. It never +mutates session state. +""" + +from __future__ import annotations + +import asyncio +import bisect +import logging +from typing import TYPE_CHECKING, ClassVar, Literal, NamedTuple, cast, get_args + +from rich.segment import Segment +from rich.style import Style as RichStyle +from textual.binding import Binding, BindingType +from textual.cache import LRUCache +from textual.containers import Horizontal, Vertical +from textual.content import Content +from textual.geometry import Offset, Size +from textual.screen import ModalScreen +from textual.scroll_view import ScrollView +from textual.strip import Strip +from textual.style import Style as TStyle +from textual.widgets import Checkbox, Select, Static +from textual.widgets._select import ( # noqa: PLC2701 # needed to keep Tab navigation inside the open Select overlay + SelectCurrent, + SelectOverlay, +) + +from deepagents_code import theme +from deepagents_code._debug import LOG_LEVELS +from deepagents_code._debug_buffer import ( + DEFAULT_CAPACITY, + InMemoryLogRecord, + get_log_buffer, + retention_bucket_for_level, +) +from deepagents_code.clipboard import copy_text_to_clipboard +from deepagents_code.tui.widgets._copy_spans import copy_span_style, copy_span_target +from deepagents_code.tui.widgets._links import open_style_link +from deepagents_code.unicode_security import sanitize_control_chars + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + from textual import events + from textual.app import ComposeResult + +logger = logging.getLogger(__name__) + +DEBUG_TOGGLE_KEY = "ctrl+backslash" +r"""Textual key name for the `Ctrl+\` chord that toggles the console.""" + + +class SnapshotField(NamedTuple): + """A single row in the console's session snapshot. + + The four named fields keep the display strings and their interaction metadata + explicit at construction sites. `copyable` opts a row into click-to-copy, and + `thread_id` enables a resolvable `(open in langsmith)` trace link for the + thread row. + """ + + label: str + value: str + copyable: bool = False + """Whether `value` can be clicked to copy it to the clipboard.""" + thread_id: str | None = None + """A LangSmith thread id whose ``(open in langsmith)`` trace link is appended + to the row once the URL resolves. `None` disables the link.""" + + +_REFRESH_INTERVAL = 0.5 +"""Seconds between log-tail refresh ticks.""" + +_RECORD_LIMIT = DEFAULT_CAPACITY +"""Maximum records retained per level by an open debug console view. + +Matches the buffer's per-level `deque` bound so the console mirrors the buffer's +level-partitioned retention instead of re-flattening it into a single window.""" + +_FILTER_SELECT_ID = "debug-level-filter" +_CLICK_TO_COPY_ID = "debug-click-to-copy" +"""Id of the checkbox that opts click-to-copy in for the console.""" +_CLICK_TO_COPY_DEFAULT = False +"""Whether click-to-copy is enabled before the user toggles the checkbox.""" +_FOCUS_CYCLE = f"#{_FILTER_SELECT_ID}, #{_CLICK_TO_COPY_ID}, #debug-log" +"""Tab-cycle selector spanning the toolbar controls and the log view.""" +FilterValue = Literal[ + "all", + "min:DEBUG", + "min:INFO", + "min:WARNING", + "min:ERROR", + "min:CRITICAL", + "only:DEBUG", + "only:INFO", + "only:WARNING", + "only:ERROR", + "only:CRITICAL", +] +_BASE_FILTER_OPTIONS: tuple[tuple[str, FilterValue], ...] = ( + ("All", "all"), + ("INFO", "min:INFO"), + ("WARNING", "min:WARNING"), + ("ERROR", "min:ERROR"), + ("CRITICAL", "min:CRITICAL"), + ("Only INFO", "only:INFO"), + ("Only WARNING", "only:WARNING"), + ("Only ERROR", "only:ERROR"), + ("Only CRITICAL", "only:CRITICAL"), +) +_DEBUG_FILTER_OPTIONS: tuple[tuple[str, FilterValue], ...] = ( + ("DEBUG", "min:DEBUG"), + ("Only DEBUG", "only:DEBUG"), +) +_VALID_FILTER_VALUES: frozenset[str] = frozenset(get_args(FilterValue)) +"""Every legal `FilterValue`, used to validate values crossing the Select +boundary before they are trusted as a `FilterValue`.""" +_LEVEL_STYLES = { + "DEBUG": "dim", + "INFO": "cyan", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "bold red", +} +_EMPTY_STYLE = RichStyle() + + +def _sanitize_display_text(text: str, *, keep_newlines: bool = False) -> str: + """Return text safe to render in the debug console.""" + return sanitize_control_chars( + text, + keep_newlines=keep_newlines, + collapse_whitespace=False, + ) + + +def _debug_records_enabled() -> bool: + """Return whether the package logger can emit DEBUG records.""" + return logging.getLogger("deepagents_code").isEnabledFor(logging.DEBUG) + + +def _filter_options() -> tuple[tuple[str, FilterValue], ...]: + """Return level filter options valid for the current logging configuration.""" + if not _debug_records_enabled(): + return _BASE_FILTER_OPTIONS + all_option = _BASE_FILTER_OPTIONS[:1] + rest = _BASE_FILTER_OPTIONS[1:] + return (*all_option, *_DEBUG_FILTER_OPTIONS, *rest) + + +def _record_matches_filter( + record: InMemoryLogRecord, level_filter: FilterValue +) -> bool: + """Return whether *record* should be visible for *level_filter*.""" + if level_filter == "all": + return True + mode, selected_level = level_filter.split(":", maxsplit=1) + if mode == "only": + return record.level == selected_level + threshold = LOG_LEVELS.get(selected_level) + if threshold is None: + # An unrecognized level should never reach here (FilterValue enumerates + # only LOG_LEVELS keys), but a diagnostic must not hide records on a bad + # filter: show everything rather than raise on the poll timer. + return True + return record.levelno >= threshold + + +def _record_to_content(record: InMemoryLogRecord) -> Content: + """Render a structured log record as styled Textual content. + + Returns: + Styled content for the log view. + """ + timestamp = _sanitize_display_text(record.timestamp) + level = _sanitize_display_text(record.level) + logger = _sanitize_display_text(record.logger) + message = _sanitize_display_text(record.message, keep_newlines=True) + level_style = _LEVEL_STYLES.get(record.level, "dim") + return Content.assemble( + (timestamp, "dim"), + " ", + (f"{level:<8}", level_style), + " ", + (logger, "dim"), + " ", + message, + ) + + +class _LogLevelOverlay(SelectOverlay): + """Select overlay that treats Tab and Shift+Tab like down and up arrows.""" + + def key_tab(self, event: events.Key) -> None: + """Move the highlighted option down while the menu is open.""" + event.prevent_default() + event.stop() + self.action_cursor_down() + + def key_shift_tab(self, event: events.Key) -> None: + """Move the highlighted option up while the menu is open.""" + event.prevent_default() + event.stop() + self.action_cursor_up() + + def key_escape(self, event: events.Key) -> None: + """Close the dropdown without dismissing the debug console.""" + event.prevent_default() + event.stop() + self.action_dismiss() + + def check_consume_key(self, key: str, character: str | None = None) -> bool: + """Prevent screen-level focus traversal while the menu is open. + + Returns: + `True` when this overlay should handle the key itself. + """ + return key in {"escape", "tab", "shift+tab"} or super().check_consume_key( + key, character + ) + + +class _LogLevelSelect(Select[FilterValue]): + """Level dropdown whose open menu treats Tab like arrow navigation.""" + + def compose(self) -> ComposeResult: + """Compose the select with a Tab-aware overlay. + + Yields: + Current value display and dropdown overlay widgets. + """ + yield SelectCurrent(self.prompt) + yield _LogLevelOverlay(type_to_search=self._type_to_search).data_bind( + compact=Select.compact + ) + + +class _DebugLogView(ScrollView, can_focus=True): + """Scrollable styled log view with logical-record hover and click handling.""" + + def __init__( + self, + on_copy_record: Callable[[InMemoryLogRecord], None], + *, + widget_id: str | None = None, + classes: str | None = None, + click_to_copy: bool = _CLICK_TO_COPY_DEFAULT, + ) -> None: + super().__init__(id=widget_id, classes=classes) + self._on_copy_record = on_copy_record + self.click_to_copy = click_to_copy + """Whether clicking a log line copies it. Enter always copies.""" + self._records: list[InMemoryLogRecord] = [] + self._notice: Content | None = None + self._contents: list[Content] = [] + self._wrap_counts: list[int] = [] + self._wrap_prefix: list[int] = [0] + self._total_visual = 0 + self._cached_width = 0 + self._hover_index: int | None = None + self._selected_index: int | None = None + self._render_line_cache: LRUCache[ + tuple[int, int, int, int | None, int | None], Strip + ] = LRUCache(1024) + + @property + def line_count(self) -> int: + """The current visual line count.""" + return self._total_visual + + @property + def records(self) -> Sequence[InMemoryLogRecord]: + """The currently visible logical records.""" + return self._records + + def set_records( + self, records: Sequence[InMemoryLogRecord], *, scroll_end: bool = True + ) -> None: + """Replace the visible records and optionally scroll to the bottom.""" + self._notice = None + self._records = list(records) + self._hover_index = None + self._selected_index = self._coerce_selected_index(self._selected_index) + self._rebuild_contents() + self._reflow() + if scroll_end: + self.scroll_end(animate=False, immediate=True, x_axis=False) + + def append_records(self, records: Sequence[InMemoryLogRecord]) -> None: + """Append records to the visible view.""" + if not records: + return + if self._notice is not None: + self.set_records(records) + return + at_bottom = self.is_vertical_scroll_end + start = len(self._contents) + self._records.extend(records) + self._contents.extend(_record_to_content(record) for record in records) + width = self._cached_width or self.size.width + if width <= 0: + # Not yet sized (e.g. first poll before layout). Assume one visual + # line per new content so `_wrap_counts` stays 1:1 with `_contents`; + # the first `on_resize` reflow recomputes real counts. Skipping this + # would leave the new records out of `_wrap_prefix`/`_total_visual` + # and invisible until that resize. + self._wrap_counts.extend(1 for _ in self._contents[start:]) + self._recompute_prefix() + self.refresh() + return + self._cached_width = width + counts = [ + self._wrap_count(content, width) for content in self._contents[start:] + ] + self._wrap_counts.extend(counts) + self._recompute_prefix() + self.virtual_size = Size(width, self._total_visual) + self._render_line_cache.clear() + self.refresh() + if at_bottom: + self.scroll_end(animate=False, immediate=True, x_axis=False) + + def clear_records(self) -> None: + """Clear the visible records.""" + self._notice = None + self._records.clear() + self._contents.clear() + self._wrap_counts.clear() + self._wrap_prefix = [0] + self._total_visual = 0 + self._hover_index = None + self._selected_index = None + self._render_line_cache.clear() + self.virtual_size = Size(self.size.width, 0) + self.refresh() + + def show_notice(self, message: str) -> None: + """Render a one-line notice in place of log records.""" + self._records.clear() + self._notice = Content.styled(_sanitize_display_text(message), "dim italic") + self._hover_index = None + self._selected_index = None + self._rebuild_contents() + self._reflow() + + def _rebuild_contents(self) -> None: + if self._notice is not None: + self._contents = [self._notice] + return + self._contents = [_record_to_content(record) for record in self._records] + + @staticmethod + def _wrap_count(content: Content, width: int) -> int: + if width <= 0: + return 1 + return max(1, len(content.wrap(width))) + + def _recompute_prefix(self) -> None: + self._wrap_prefix = [0] + for count in self._wrap_counts: + self._wrap_prefix.append(self._wrap_prefix[-1] + count) + self._total_visual = self._wrap_prefix[-1] + + def _reflow(self) -> None: + width = self.size.width + if width <= 0: + width = self._cached_width + if width <= 0: + self._wrap_counts = [1 for _content in self._contents] + self._recompute_prefix() + self.refresh() + return + self._cached_width = width + self._render_line_cache.clear() + self._wrap_counts = [ + self._wrap_count(content, width) for content in self._contents + ] + self._recompute_prefix() + self.virtual_size = Size(width, self._total_visual) + self.refresh() + + def _content_index_at_visual_y(self, visual_y: int) -> int | None: + if visual_y < 0 or visual_y >= self._total_visual: + return None + index = bisect.bisect_right(self._wrap_prefix, visual_y) - 1 + if 0 <= index < len(self._contents): + return index + return None + + def _record_at_visual_y(self, visual_y: int) -> InMemoryLogRecord | None: + if self._notice is not None: + return None + index = self._content_index_at_visual_y(visual_y) + if index is None or index >= len(self._records): + return None + return self._records[index] + + def _coerce_selected_index(self, index: int | None) -> int | None: + if not self._records: + return None + if index is None: + return None + return min(max(index, 0), len(self._records) - 1) + + def _select_record(self, index: int) -> None: + if not self._records: + return + self._selected_index = min(max(index, 0), len(self._records) - 1) + self._hover_index = None + self._render_line_cache.clear() + self._scroll_selected_visible() + self.refresh() + + def _scroll_selected_visible(self) -> None: + if self._selected_index is None or not self._wrap_prefix: + return + start = self._wrap_prefix[self._selected_index] + end = self._wrap_prefix[self._selected_index + 1] - 1 + _scroll_x, scroll_y = self.scroll_offset + height = max(self.size.height, 1) + if start < scroll_y: + self.scroll_to(y=start, animate=False, immediate=True) + elif end >= scroll_y + height: + self.scroll_to(y=end - height + 1, animate=False, immediate=True) + + def _copy_selected_record(self) -> None: + if self._selected_index is None: + if not self._records: + return + self._selected_index = len(self._records) - 1 + record = self._records[self._selected_index] + self._on_copy_record(record) + + def render_line(self, y: int) -> Strip: + _scroll_x, scroll_y = self.scroll_offset + abs_y = scroll_y + y + width = self.size.width + key = ( + abs_y, + width, + self._cached_width, + self._hover_index, + self._selected_index, + ) + cached = self._render_line_cache.get(key) + if cached is not None: + return cached + if abs_y >= self._total_visual: + return Strip.blank(width, self.rich_style) + + content_index = self._content_index_at_visual_y(abs_y) + if content_index is None: + return Strip.blank(width, self.rich_style) + content = self._contents[content_index] + row_style: RichStyle | None = None + if self._selected_index == content_index and self._notice is None: + colors = theme.get_theme_colors(self) + row_style = RichStyle( + color=colors.background, + bgcolor=colors.primary, + bold=True, + ) + elif self._hover_index == content_index and self._notice is None: + colors = theme.get_theme_colors(self) + row_style = RichStyle(bgcolor=colors.panel) + wrapped = content.wrap(self._cached_width or width) + base = self._wrap_prefix[content_index] + line = wrapped[abs_y - base] if abs_y - base < len(wrapped) else Content() + segments = [ + segment + if segment.style is not None + else Segment(segment.text, _EMPTY_STYLE) + for segment in line.render_segments(end="") + ] + strip = Strip(segments, line.cell_length).crop_extend(0, width, self.rich_style) + if row_style is not None: + strip = Strip( + Segment.apply_style(strip, None, row_style), + strip.cell_length, + ) + self._render_line_cache[key] = strip + return strip + + def notify_style_update(self) -> None: + """Clear cached render lines after a style update.""" + super().notify_style_update() + self._render_line_cache.clear() + + def on_resize(self, event: events.Resize) -> None: + """Re-wrap log entries when the view width changes.""" + if event.size.width != self._cached_width: + self._reflow() + + def on_mouse_move(self, event: events.MouseMove) -> None: + """Highlight the logical log record under the pointer.""" + _scroll_x, scroll_y = self.scroll_offset + hover_index = self._content_index_at_visual_y(scroll_y + event.y) + if self._notice is not None: + hover_index = None + self.styles.pointer = "pointer" if hover_index is not None else "default" + if hover_index == self._hover_index: + return + self._hover_index = hover_index + self._render_line_cache.clear() + self.refresh() + + def on_leave(self) -> None: + """Clear hover highlighting when the pointer leaves the log.""" + self.styles.pointer = "default" + if self._hover_index is None: + return + self._hover_index = None + self._render_line_cache.clear() + self.refresh() + + def on_focus(self) -> None: + """Select the latest log record when keyboard focus enters the log.""" + if self._selected_index is None and self._records: + self._select_record(len(self._records) - 1) + + def key_up(self, event: events.Key) -> None: + """Move keyboard selection to the previous logical log record.""" + event.prevent_default() + event.stop() + if not self._records: + return + index = ( + len(self._records) if self._selected_index is None else self._selected_index + ) + self._select_record(index - 1) + + def key_down(self, event: events.Key) -> None: + """Move keyboard selection to the next logical log record.""" + event.prevent_default() + event.stop() + if not self._records: + return + index = -1 if self._selected_index is None else self._selected_index + self._select_record(index + 1) + + def key_enter(self, event: events.Key) -> None: + """Copy the selected logical log record.""" + event.prevent_default() + event.stop() + self._copy_selected_record() + + def key_tab(self, event: events.Key) -> None: + """Move focus from the log to the next toolbar control.""" + event.prevent_default() + event.stop() + self.screen.focus_next(_FOCUS_CYCLE) + + def key_shift_tab(self, event: events.Key) -> None: + """Move focus from the log to the previous toolbar control.""" + event.prevent_default() + event.stop() + self.screen.focus_previous(_FOCUS_CYCLE) + + def on_click(self, event: events.Click) -> None: + """Select the clicked log record, copying it when click-to-copy is on.""" + _scroll_x, scroll_y = self.scroll_offset + record = self._record_at_visual_y(scroll_y + event.y) + if record is None: + return + index = self._content_index_at_visual_y(scroll_y + event.y) + if index is not None: + self._select_record(index) + event.stop() + if self.click_to_copy: + self._on_copy_record(record) + + +def _snapshot_copy_success_message(label: str) -> str: + """Build the toast shown after copying a snapshot field value. + + Args: + label: The snapshot row label (e.g. `"Thread"`, `"Version"`). + + Returns: + A short success toast for the copied field. + """ + # The thread row is labeled "Thread" in the snapshot, but the value users + # copy is specifically the thread id — keep that wording for the toast. + if label == "Thread": + return "Thread ID copied" + return f"{label} copied" + + +class _SnapshotView(Static): + """Snapshot header that copies marked spans and opens link spans on click.""" + + # Match WelcomeBanner: disabling auto_links avoids a hover-refresh flicker + # loop caused by link styles getting a fresh random id on every render. + auto_links = False + + def __init__( + self, + on_copy: Callable[[str, str], None], + *, + classes: str | None = None, + ) -> None: + """Initialize with a callback used to copy a clicked span's text. + + Args: + on_copy: Called with `(text, label)` when a copyable span is clicked. + classes: Optional space-separated CSS classes. + """ + super().__init__(classes=classes) + self._on_copy = on_copy + + def on_click(self, event: events.Click) -> None: + """Copy a marked span or open a link span under the click. + + Copyable snapshot spans (e.g. the thread id) always copy on click; the + console's "Click to copy" checkbox governs only the log lines, never the + snapshot. + """ + if getattr(event.style, "link", None): + open_style_link(event) + return + target = copy_span_target(event.style) + if target is not None: + event.stop() + text, label = target + self._on_copy(text, label) + + def on_mouse_move(self, event: events.MouseMove) -> None: + """Show a hand pointer over clickable spans and reset it elsewhere.""" + clickable = bool(getattr(event.style, "link", None)) or ( + copy_span_target(event.style) is not None + ) + self.styles.pointer = "pointer" if clickable else "default" + + def on_leave(self) -> None: + """Reset the pointer shape when the mouse leaves the snapshot.""" + self.styles.pointer = "default" + + +class DebugConsoleScreen(ModalScreen[None]): + """Modal showing a session snapshot and a live tail of recent log records.""" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "close", "Close", show=False), + Binding(DEBUG_TOGGLE_KEY, "close", "Close", show=False, priority=True), + Binding("ctrl+l", "clear_view", "Clear view", show=False, priority=True), + # Not `priority`: a priority `c` would pre-empt type-to-search in the + # open level dropdown (e.g. typing "c" to reach CRITICAL). The log view + # has no `c` binding, so it still bubbles up to this copy action. + Binding("c", "copy", "Copy", show=False), + ] + """The toggle-key close (`ctrl+backslash`) and `ctrl+l` clear-view are + `priority`. Escape close and `c` copy are deliberately *not* `priority`: + Escape must reach the open level dropdown's overlay first so it closes only + the menu (a priority Escape would tear down the whole console instead), and + `c` must not pre-empt the dropdown's type-to-search.""" + + CSS = """ + DebugConsoleScreen { + align: center middle; + } + + DebugConsoleScreen > Vertical { + width: 100; + max-width: 95%; + height: 85%; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + DebugConsoleScreen .debug-console-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + DebugConsoleScreen .debug-console-snapshot { + margin-bottom: 1; + } + + DebugConsoleScreen .debug-console-toolbar { + height: auto; + margin-bottom: 1; + } + + DebugConsoleScreen .debug-console-filter-label { + width: auto; + content-align: center middle; + color: $text-muted; + margin-right: 1; + } + + DebugConsoleScreen #debug-level-filter { + width: 18; + } + + DebugConsoleScreen .debug-console-click-to-copy { + margin-left: 2; + color: $text-muted; + } + + DebugConsoleScreen .debug-console-log { + height: 1fr; + min-height: 5; + scrollbar-gutter: stable; + background: $background; + border: solid $primary; + overflow-x: hidden; + overflow-y: scroll; + } + + DebugConsoleScreen .debug-console-log:focus { + border: solid $primary-lighten-2; + } + + DebugConsoleScreen .debug-console-help { + height: 1; + color: $text-muted; + text-style: italic; + margin-top: 1; + text-align: center; + } + """ + + def __init__( + self, + snapshot: Sequence[SnapshotField], + *, + snapshot_provider: Callable[[], Sequence[SnapshotField]] | None = None, + cleared_upto: int = 0, + on_clear: Callable[[int], None] | None = None, + click_to_copy: bool = _CLICK_TO_COPY_DEFAULT, + on_click_to_copy_change: Callable[[bool], None] | None = None, + ) -> None: + """Initialize with a captured *snapshot* of session/runtime fields. + + Args: + snapshot: Ordered `SnapshotField` rows rendered on first paint. + snapshot_provider: Optional callable that rebuilds the snapshot from + live host state. When set, the header is refreshed on the same + tick as the log tail whenever the provider returns a different + row list. Omit for a freeze-frame header (e.g. unit tests). + cleared_upto: Absolute emission index a prior `Ctrl+L` cleared up to. + The console starts rendering from here so a clear persists across + close/reopen; records emitted after it still appear. + on_clear: Invoked with the new clear cursor whenever `Ctrl+L` clears + the view, letting the owner persist it for the next open. + click_to_copy: Initial state of the "Click to copy" checkbox, + restored from the persisted preference. + on_click_to_copy_change: Called with the new value whenever the + checkbox is toggled, so the host can persist the preference. + """ + super().__init__() + self._snapshot = list(snapshot) + self._snapshot_provider = snapshot_provider + self._records: list[InMemoryLogRecord] = [] + # Absolute index of the next unrendered log record (incremental writes), + # seeded from any persisted clear so reopening honors the last Ctrl+L. + self._rendered_upto = cleared_upto + self._on_clear = on_clear + # One-shot guard so the "buffer unavailable" notice is written only once. + self._missing_notice_shown = False + self._level_filter: FilterValue = "all" + self._click_to_copy = click_to_copy + self._on_click_to_copy_change = on_click_to_copy_change + # Seed links resolved elsewhere in this process (normally the welcome + # banner) so reopening the console does not briefly render without one. + self._langsmith_urls = self._cached_langsmith_urls() + # Thread ids a lookup worker has already been scheduled for, so the + # refresh tick never stacks overlapping lookups for the same thread. + self._langsmith_attempted: set[str] = set() + # Whether the last provider poll raised, so a persistent failure logs a + # single WARNING instead of one per refresh tick. + self._snapshot_poll_failing = False + + def _cached_langsmith_urls(self) -> dict[str, str]: + """Return immediately available LangSmith URLs for snapshot threads.""" + from deepagents_code.config import get_cached_langsmith_thread_url + + urls: dict[str, str] = {} + thread_ids = {field.thread_id for field in self._snapshot if field.thread_id} + for thread_id in thread_ids: + try: + url = get_cached_langsmith_thread_url(thread_id) + except Exception: # a diagnostic overlay must always be able to open + logger.warning( + "Cached LangSmith thread URL lookup errored for %r", + thread_id, + exc_info=True, + ) + continue + if url: + urls[thread_id] = url + return urls + + def compose(self) -> ComposeResult: + """Lay out the title, snapshot, filter, log tail, and key-hint footer. + + Yields: + The child widgets composing the console. + """ + with Vertical(): + yield Static("Debug Console", classes="debug-console-title") + snapshot_view = _SnapshotView( + self._copy_snapshot_value, + classes="debug-console-snapshot", + ) + snapshot_view.update(self._render_snapshot()) + yield snapshot_view + with Horizontal(classes="debug-console-toolbar"): + yield Static("Level", classes="debug-console-filter-label") + yield _LogLevelSelect( + _filter_options(), + value="all", + allow_blank=False, + id=_FILTER_SELECT_ID, + compact=True, + ) + yield Checkbox( + "Click to copy", + value=self._click_to_copy, + id=_CLICK_TO_COPY_ID, + compact=True, + classes="debug-console-click-to-copy", + ) + yield _DebugLogView( + self._copy_record, + widget_id="debug-log", + classes="debug-console-log", + click_to_copy=self._click_to_copy, + ) + yield Static(self._render_help(), classes="debug-console-help") + + def on_mount(self) -> None: + """Start the refresh timer and render the current buffer contents.""" + self.set_interval(_REFRESH_INTERVAL, self._on_refresh_tick) + self._on_refresh_tick() + self._resolve_langsmith_links() + self.call_after_refresh(self.query_one("#debug-log", _DebugLogView).focus) + + def _on_refresh_tick(self) -> None: + """Rebuild the snapshot header (when live) and append new log records.""" + self._poll_snapshot() + self._poll_logs() + + def _poll_snapshot(self) -> None: + """Rebuild the snapshot header from the host provider, if configured. + + Reuses the log-tail timer so the header tracks live session state (message + counts, tokens, thread id, …) without a second schedule. Failures are + swallowed with a WARNING so a misbehaving provider cannot tear down the + diagnostic overlay or its log tail. Only the transition into failure logs + at WARNING: a provider that keeps raising would otherwise emit a + traceback every tick into the very ring buffer the console is tailing, + flooding out the records the user opened it to read. Repeats stay at + DEBUG, and a later success re-arms the WARNING. + """ + if self._snapshot_provider is None: + return + try: + self._poll_snapshot_once() + except Exception: # a diagnostic must never crash the app it inspects + if self._snapshot_poll_failing: + logger.debug("Debug console snapshot poll failed again", exc_info=True) + else: + self._snapshot_poll_failing = True + logger.warning("Debug console snapshot poll failed", exc_info=True) + else: + self._snapshot_poll_failing = False + + def _poll_snapshot_once(self) -> None: + """Refresh `self._snapshot` from the provider when its rows changed.""" + if self._snapshot_provider is None: + return + next_snapshot = list(self._snapshot_provider()) + if next_snapshot == self._snapshot: + return + self._snapshot = next_snapshot + self._refresh_snapshot() + # A thread switch mid-open needs a fresh LangSmith resolve for any new + # ids; unchanged ids keep their cached URLs and are skipped inside. + self._resolve_langsmith_links() + + def _resolve_langsmith_links(self) -> None: + """Kick off background resolution of `(open in langsmith)` snapshot links. + + Each thread id gets at most one lookup per console open. The refresh tick + calls this on every snapshot change, and a resolved URL is only recorded + on success, so without a separate guard a slow or failing lookup would be + restarted every 500 ms. `asyncio.wait_for` cannot cancel the blocking SDK + call inside `asyncio.to_thread`, so those retries would pile up executor + threads and network requests for as long as the console stays open. + Attempted ids therefore stay marked even after the worker finishes; + reopening the console retries with a fresh screen. + """ + thread_ids = { + field.thread_id + for field in self._snapshot + if field.thread_id + and field.thread_id not in self._langsmith_urls + and field.thread_id not in self._langsmith_attempted + } + for thread_id in thread_ids: + self._langsmith_attempted.add(thread_id) + self.run_worker( + self._fetch_langsmith_link(thread_id), + exclusive=False, + group="debug-console-langsmith", + ) + + async def _fetch_langsmith_link(self, thread_id: str) -> None: + """Resolve a thread's LangSmith URL and re-render the snapshot. + + Follows the welcome banner's thread + short-timeout pattern so an + unreachable LangSmith never blocks the console, but splits error + handling by expectedness: an expected timeout/I/O failure degrades + quietly to no link, while an unexpected error is logged loudly so a + genuine resolution bug is not hidden inside the diagnostic overlay. + """ + 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, OSError): + # Expected: the outer timeout fired or a network error escaped the + # helper. A passive convenience link merely fails to appear. + logger.debug( + "LangSmith thread URL lookup timed out/failed for %r", + thread_id, + exc_info=True, + ) + return + except Exception: # a diagnostic overlay must not crash on a lookup bug + # Unexpected: a real defect in URL resolution. WARNING (not DEBUG) so + # the traceback lands in the always-on in-memory buffer and is visible + # in the console itself; the package logger sits at INFO by default, + # which drops DEBUG. + logger.warning( + "LangSmith thread URL lookup errored unexpectedly for %r", + thread_id, + exc_info=True, + ) + return + if url: + self._langsmith_urls[thread_id] = url + self._refresh_snapshot() + + def _refresh_snapshot(self) -> None: + """Re-render the snapshot header in place (e.g. after a link resolves).""" + from textual.css.query import NoMatches + + try: + self.query_one(".debug-console-snapshot", _SnapshotView).update( + self._render_snapshot() + ) + except NoMatches: + # The console was dismissed before the worker returned. + logger.debug("Debug console snapshot refresh skipped (widget unavailable)") + + def key_tab(self, event: events.Key) -> None: + """Cycle focus between the toolbar controls and log lines.""" + if self._level_select().expanded: + return + event.prevent_default() + event.stop() + self.focus_next(_FOCUS_CYCLE) + + def key_shift_tab(self, event: events.Key) -> None: + """Cycle focus between the log lines and toolbar controls.""" + if self._level_select().expanded: + return + event.prevent_default() + event.stop() + self.focus_previous(_FOCUS_CYCLE) + + def on_mouse_down(self, event: events.MouseDown) -> None: + """Dismiss transient control state when the user clicks outside it. + + Clicking a focusable control already moves focus, but clicking a + non-focusable area (the snapshot, labels, help, or empty modal space) + does not. Mirror that outside-click behavior for the open level dropdown + and the focused "Click to copy" checkbox. + """ + offset = event.screen_offset + select = self._level_select() + if select.expanded and not self._point_in_level_select(select, offset): + overlay = select.query_one(SelectOverlay) + select.expanded = False + # Re-focus the select only when focus is still trapped on the now + # hidden overlay; if the click already moved focus to another + # control, leave it there. + if self.focused is overlay: + select.focus() + checkbox = self.query_one(f"#{_CLICK_TO_COPY_ID}", Checkbox) + if self.focused is checkbox and not checkbox.region.contains( + offset.x, offset.y + ): + self.set_focus(None) + + @staticmethod + def _point_in_level_select(select: Select[FilterValue], offset: Offset) -> bool: + """Return whether *offset* falls on the select box or its open overlay.""" + if select.region.contains(offset.x, offset.y): + return True + overlay = select.query_one(SelectOverlay) + return overlay.display and overlay.region.contains(offset.x, offset.y) + + def on_select_changed(self, event: Select.Changed) -> None: + """Refresh visible records when the log-level filter changes.""" + if event.select.id != _FILTER_SELECT_ID: + return + value = str(event.value) + if value == self._level_filter: + return + if value not in _VALID_FILTER_VALUES: + # The Select only offers known options, so this is unreachable in + # practice; validate anyway so an unexpected value degrades to the + # current filter instead of being trusted as a FilterValue. + logger.warning("Ignoring unknown debug level filter %r", value) + return + self._level_filter = cast("FilterValue", value) + self._refresh_log_view(scroll_end=True) + + def on_checkbox_changed(self, event: Checkbox.Changed) -> None: + """Toggle click-to-copy for the log lines. + + The checkbox governs only the log lines; copyable snapshot spans (e.g. + the thread id) always copy on click regardless of this setting. + """ + if event.checkbox.id != _CLICK_TO_COPY_ID: + return + self._click_to_copy = event.value + self.query_one("#debug-log", _DebugLogView).click_to_copy = event.value + if self._on_click_to_copy_change is not None: + self._on_click_to_copy_change(event.value) + + def _render_snapshot(self) -> Content: + """Build the right-aligned `label: value` snapshot block. + + Returns: + The formatted snapshot block. + """ + if not self._snapshot: + return Content.styled("(no session data)", "dim italic") + width = max(len(field.label) for field in self._snapshot) + lines = [self._render_snapshot_row(field, width) for field in self._snapshot] + return Content("\n").join(lines) + + def _render_snapshot_row(self, field: SnapshotField, width: int) -> Content: + """Render a single snapshot row, wiring up copy and link spans. + + Args: + field: The snapshot field to render. + width: Column width the labels are right-aligned to. + + Returns: + The formatted row content. + """ + parts: list[str | tuple[str, str | TStyle]] = [ + (f"{field.label:>{width}} ", "bold") + ] + if field.copyable and field.value: + parts.append((field.value, copy_span_style(field.value, field.label))) + else: + parts.append(field.value) + url = self._langsmith_urls.get(field.thread_id) if field.thread_id else None + if url: + parts.extend((" ", ("(open in langsmith)", TStyle(link=url)))) + return Content.assemble(*parts) + + @staticmethod + def _render_help() -> Content: + """Build the footer key-hint line. + + Returns: + The formatted key-hint line. + """ + return Content.styled( + "Esc close · Ctrl+L clear view · c copy visible logs · Enter copy line", + "dim italic", + ) + + def _poll_logs(self) -> None: + """Append log records emitted since the last tick, guarding the timer. + + Runs on a repeating `set_interval` timer, so an unhandled exception here + would propagate out of the callback and tear down the whole host app. + A diagnostic overlay must degrade instead: a tick that races teardown + (`NoMatches`) is logged at DEBUG and skipped, and any other failure + degrades the tail to a notice rather than crashing the app it exists to + inspect. + """ + from textual.css.query import NoMatches + + try: + self._poll_logs_once() + except NoMatches: + # Expected when a queued tick races console teardown: the log widget + # is already gone. Logged at DEBUG (not swallowed outright) so a + # genuine missing/mis-typed-widget bug still leaves a breadcrumb in + # the buffer instead of silently rendering nothing forever. + logger.debug("Debug console poll skipped (widget unavailable)") + return + except Exception: # a diagnostic must never crash the app it inspects + logger.warning("Debug console log poll failed", exc_info=True) + try: + self.query_one("#debug-log", _DebugLogView).show_notice( + "(log tail unavailable)" + ) + except Exception: # best-effort notice; never re-raise from here + logger.debug("Debug console poll-error notice failed", exc_info=True) + + def _poll_logs_once(self) -> None: + """Append any log records emitted since the last tick to the log view.""" + log = self.query_one("#debug-log", _DebugLogView) + buffer = get_log_buffer() + if buffer is None: + if not self._missing_notice_shown: + log.show_notice("(log buffer unavailable)") + self._missing_notice_shown = True + return + records, total = buffer.snapshot_records_since(self._rendered_upto) + self._records.extend(records) + pruned = self._prune_records() + self._rendered_upto = total + if pruned: + self._refresh_log_view(scroll_end=log.is_vertical_scroll_end) + return + log.append_records(self._visible_records(records)) + + def _prune_records(self) -> bool: + """Trim retained records to the ring buffer capacity, per level. + + Mirrors the buffer's level-partitioned retention: each standard level + keeps at most `_RECORD_LIMIT` records, while custom levels share the + buffer's fallback bucket. Only the oldest entries of an over-capacity + bucket are dropped; chronological order is preserved. + + Returns: + `True` when records were pruned. + """ + counts: dict[str, int] = {} + for record in self._records: + bucket = retention_bucket_for_level(record.level) + counts[bucket] = counts.get(bucket, 0) + 1 + overflow = { + level: count - _RECORD_LIMIT + for level, count in counts.items() + if count > _RECORD_LIMIT + } + if not overflow: + return False + kept: list[InMemoryLogRecord] = [] + for record in self._records: + bucket = retention_bucket_for_level(record.level) + remaining = overflow.get(bucket, 0) + if remaining > 0: + overflow[bucket] = remaining - 1 + continue + kept.append(record) + self._records = kept + return True + + def _visible_records( + self, records: Sequence[InMemoryLogRecord] + ) -> list[InMemoryLogRecord]: + """Return the subset of *records* matching the current level filter.""" + return [ + record + for record in records + if _record_matches_filter(record, self._level_filter) + ] + + def _refresh_log_view(self, *, scroll_end: bool) -> None: + """Rebuild the log view using the current filter.""" + self.query_one("#debug-log", _DebugLogView).set_records( + self._visible_records(self._records), scroll_end=scroll_end + ) + + def action_clear_view(self) -> None: + """Clear the on-screen log view; the in-memory buffer keeps accruing. + + Advances the render cursor past everything emitted so far and reports it + via `on_clear` so the owner can persist the clear across close/reopen. + """ + self.query_one("#debug-log", _DebugLogView).clear_records() + self._records.clear() + buffer = get_log_buffer() + if buffer is not None: + self._rendered_upto = buffer.total_emitted + if self._on_clear is not None: + self._on_clear(self._rendered_upto) + + def action_copy(self) -> None: + """Copy visible retained log records since the last clear to the clipboard.""" + lines = [record.plain_line for record in self._visible_records(self._records)] + self._copy_lines(lines, empty_message="No visible log lines to copy") + + def _copy_record(self, record: InMemoryLogRecord) -> None: + """Copy a clicked logical log record to the clipboard.""" + self._copy_lines([record.plain_line], empty_message="No log line to copy") + + def _copy_snapshot_value(self, text: str, label: str) -> None: + """Copy a clicked snapshot value to the clipboard. + + Args: + text: The field value to put on the clipboard. + label: The snapshot row label used to word the success toast. + """ + self._copy_lines( + [text], + empty_message="Nothing to copy", + success_message=_snapshot_copy_success_message(label), + ) + + def _level_select(self) -> Select[FilterValue]: + """Return the level-filter dropdown.""" + return cast( + "Select[FilterValue]", self.query_one("#debug-level-filter", Select) + ) + + def _copy_lines( + self, + lines: Sequence[str], + *, + empty_message: str, + success_message: str = "Debug log copied", + ) -> None: + """Copy lines to clipboard with user-visible feedback.""" + text = "\n".join(lines) + if not text: + self.app.notify( + empty_message, severity="information", timeout=2, markup=False + ) + return + success, error = copy_text_to_clipboard(self.app, text) + if success: + self.app.notify( + success_message, severity="information", timeout=2, markup=False + ) + return + suffix = f": {error}" if error else "" + self.app.notify( + f"Failed to copy{suffix}", + severity="warning", + timeout=3, + markup=False, + ) + + def action_close(self) -> None: + """Close the open level dropdown, or close the debug console.""" + level_select = self._level_select() + if level_select.expanded: + level_select.expanded = False + level_select.focus() + return + self.dismiss(None) diff --git a/libs/code/deepagents_code/tui/widgets/diff.py b/libs/code/deepagents_code/tui/widgets/diff.py new file mode 100644 index 0000000000..967cb111f1 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/diff.py @@ -0,0 +1,686 @@ +"""Renderers turning a unified diff into one `Static` per row. + +Rows carry a line-number gutter, a `+`/`-` marker, syntax highlighting lifted +from whole-file lexer state, and word-level emphasis on the spans that actually +changed between a paired removed/added line. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from difflib import SequenceMatcher +from functools import lru_cache +from itertools import accumulate, groupby, pairwise +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, get_args + +from textual.content import Content +from textual.geometry import Offset +from textual.highlight import highlight +from textual.selection import Selection +from textual.widgets import Static + +from deepagents_code import theme +from deepagents_code.config import get_glyphs +from deepagents_code.diff_utils import ( + HUNK_RE, + DiffStats, + file_header_indexes, + is_truncation_marker, + split_diff_lines, +) + +if TYPE_CHECKING: + from textual.app import ComposeResult + from textual.widget import Widget + +logger = logging.getLogger(__name__) + +_TOKEN_RE = re.compile(r"\w+|\s+|.") +"""Splits a line into word / whitespace / single-character tokens. + +Total over any string, so `"".join(findall(s)) == s` and token offsets index +back into the original line. +""" + +_SIMILARITY_FLOOR = 0.4 +"""Minimum word-level similarity before a removed/added pair gets emphasis. + +Below this the two lines are treated as unrelated rewrites, where emphasising +"changed" spans would just tint the whole line and add noise. +""" + +_MAX_EMPHASIS_LEN = 400 +"""Longest line eligible for word emphasis. + +`SequenceMatcher` is quadratic in token count, and `_TOKEN_RE` degenerates to +one token per character on punctuation-dense lines — so minified JS or +single-line JSON would stall the compose path. Longer lines render unemphasised. +""" + +MAX_HIGHLIGHT_CHARS = 100_000 +"""Largest source prefix worth lexing for syntax highlighting. + +Above this the side is skipped and its rows render as plain text. + +The prefix has to start at line 1 — the lexer needs the preceding source to know +whether the changed lines sit inside a string or comment — so its size is set by +how far into the file the edit is, not by how much of it is rendered. That makes +this constant the bound on two separate costs. + +Lexing runs synchronously in `compose`. Measured 2026-07 on an M-series Mac, +CPython 3.12: roughly 0.72 ms per 1,000 characters, so ~70 ms per side at this +limit, plus a one-off ~150 ms to build the lexer on the session's first diff. +Both sides are lexed back to back, so the steady-state worst case is ~140 ms of +blocked message pump and the session's first diff is ~290 ms. Re-measure before +trusting these; they set the limit but nothing enforces them. + +The prefix is also retained per message by `MessageData` so a rehydrated diff can +re-highlight, at up to this many characters per side. Raising it slows the diff +mount and grows the transcript's memory in step, and the transcript has no cap. +""" + +_Range = tuple[int, int] +_DiffRowKind = Literal["context", "added", "removed"] +_RowKind = Literal["context", "added", "removed", "separator", "truncated", "note"] + + +@dataclass(frozen=True, kw_only=True) +class _RowStyle: + """How one kind of source row renders. + + Keyword-only because all four fields are `str`: positional construction + lets `marker_style` and `emphasis` swap into a wrong-but-plausible render + that nothing would flag. Same hazard `DiffStats` is `kw_only` to rule out, + and the reason this is a dataclass rather than a `NamedTuple` — it is only + ever read by attribute, so nothing needs the tuple behavior. + + Attributes: + gutter: Style for the line-number column. + marker: The `+`/`-`/space shown between gutter and text. + marker_style: Style for `marker`. + emphasis: Style laid over the spans that changed, empty when the kind + takes no emphasis. + """ + + gutter: str + marker: str + marker_style: str + emphasis: str + + +# A changed row's color builds up in three tiers of the same hue, each darker +# than the last: the row background from `.diff-line-added`/`.diff-line-removed` +# in `app.tcss` (10%), the gutter (20%), and the words that actually changed +# (30%, applied per-span in `_compose_diff_content`). Keep them ordered that way +# — equal tiers flatten the row and lose the distinction. +# +# Keyed over `_DiffRowKind`. Decoration kinds live only in the wider `_RowKind` +# and are handled by the early `continue`s in `_compose_diff_content`, which is +# what narrows `row.kind` to a valid key. One map rather than three so a kind +# cannot be added to some of them and missed in the rest. +_ROW_STYLES: dict[_DiffRowKind, _RowStyle] = { + "added": _RowStyle( + gutter="$text-success 80% on $success 20%", + marker="+", + marker_style="$text-success", + emphasis="on $success 30%", + ), + "removed": _RowStyle( + gutter="$text-error 80% on $error 20%", + marker="-", + marker_style="$text-error", + emphasis="on $error 30%", + ), + "context": _RowStyle( + gutter="$foreground 30% on $foreground 3%", + marker=" ", + marker_style="", + emphasis="", + ), +} + +# A `dict` literal is not checked for exhaustiveness — neither mypy nor ty flags +# a missing key, and `_ROW_STYLES[row.kind]` type-checks fine while raising +# `KeyError` at render time. Check it at import so a new numbered row kind fails +# loudly on startup instead of on whichever diff first happens to contain one. +# A raise rather than an `assert`, which `-O` strips and ruff bans. +if _missing_row_styles := set(get_args(_DiffRowKind)) - _ROW_STYLES.keys(): + _msg = f"_ROW_STYLES is missing entries for {sorted(_missing_row_styles)}" + raise RuntimeError(_msg) + + +# Which row kinds are read from which side's source, keyed by `_Row.number`. +# +# Follows the numbering in `_Row.number`: only removed rows are numbered in the +# old file. Named once because `highlight_source_prefixes` sizes each prefix and +# `_highlighted_rows` reads from it, and a row kind sized against one side but +# read from the other loses its highlighting with nothing to explain why. +# +# A comment rather than a docstring under `_AFTER_KINDS`, which would attribute +# the shared rule to that name alone and leave `_BEFORE_KINDS` reading as +# undocumented. +_BEFORE_KINDS: tuple[_DiffRowKind, ...] = ("removed",) +_AFTER_KINDS: tuple[_DiffRowKind, ...] = ("added", "context") + + +class _Row(NamedTuple): + """One rendered line of a diff. + + Attributes: + kind: What the row represents. `context`/`added`/`removed` are numbered + source lines; `separator`/`truncated`/`note` are decorations. + text: The line with its diff marker stripped. Empty for `separator` and + `truncated`. + number: Line number in the file the user can still open — the *new* file + for `added` and `context`, the *old* file for `removed`, which is + the only kind that no longer exists in the new one. Numbering + context from the old file instead would make every row after an + insertion disagree with the file on disk, and repeat numbers the + added rows had just used. `None` for decoration rows, which have no + line to name. + """ + + kind: _RowKind + text: str + number: int | None + + +class _DiffRowStatic(Static): + """A numbered diff row whose gutter is excluded from text selections. + + The gutter (line number, `+`/`-` marker, and the spaces around them) is + decorative: a copy taken from a diff should hold the source text, so a + paste into an editor does not need the numbers stripped back out. The + exclusion is applied to the stored `Selection` itself — see + `clamp_selection` — because Textual paints the selection highlight from + that same geometry, and a `get_selection` override would leave the gutter + visually selected while absent from the copy. + """ + + def __init__(self, content: Content, prefix_len: int, **kwargs: Any) -> None: + """Initialize the row. + + Args: + content: The row's full content, gutter included. + prefix_len: Cell width of the leading gutter (number, marker, and + their separating spaces). + **kwargs: Forwarded to `Static`. + """ + super().__init__(content, **kwargs) + self.selection_prefix = prefix_len + + selection_prefix: int + """Cells at the row's left edge a selection must not cover.""" + + +def clamp_selection(widget: Widget, selection: Selection) -> Selection | None: + """Return `selection` shifted past a diff row's gutter, if one is set. + + Every form a selection can take over a single-line row covers the gutter + unless its start is moved past it: + + - `Selection(None, None)` — the row sits mid-selection. Textual extracts + the row's full text, so the start must move to the gutter's end even + though no endpoint lands here. + - `Selection(None, end)` — entered from above; same move, plus an `end` + still inside the gutter means nothing selectable is covered, reported + as `None` so the row drops out of the screen's selection map. + - `Selection(start, None)` / `Selection(start, end)` — pull any endpoint + inside the gutter forward to its end; a range that then collapses + (wholly gutter) is `None`. + + An endpoint counts as "inside the gutter" only on the row's first visual + line (`y == 0`). A row wrapped by Textual continues at column 0 of each + following visual line, where the gutter no longer exists — an `x` there + already indexes source text, so a continuation coordinate must pass through + untouched or a drag starting on a continuation would skip its first + gutter-width characters, and one ending within them would drop source the + user visibly selected. + + Args: + widget: The row the selection applies to. Anything that is not a + `_DiffRowStatic` is returned unchanged. + selection: The geometry Textual computed for this row. + + Returns: + The clamped selection, the original selection, or `None` when the + covered range lies entirely in the gutter and the row should drop out + of the screen's selection map. + """ + if not isinstance(widget, _DiffRowStatic): + return selection + prefix = widget.selection_prefix + start, end = selection.start, selection.end + if start is None: + if end is not None and end.y == 0 and end.x <= prefix: + return None + start = Offset(prefix, 0) + elif start.y == 0 and start.x < prefix: + start = Offset(prefix, start.y) + if end is not None and end.y == 0 and end.x <= prefix: + end = Offset(prefix, end.y) + if end is not None and end.transpose <= start.transpose: + return None + return Selection(start, end) + + +def compose_diff_lines( + diff: str, + max_lines: int | None = 100, + *, + path: str = "", + before: str = "", + after: str = "", + show_numbers: bool = True, +) -> ComposeResult: + """Yield syntax-highlighted widgets for a unified diff. + + Args: + diff: Unified diff string. + max_lines: Maximum number of *rendered rows* to show (None for + unlimited). Rows are not diff lines: file and hunk headers are + dropped and hunk separators added, so this does not correspond to a + line count in `diff`. Rows are dropped from the end; emphasis is + computed after the drop, so splitting a removed/added run costs the + whole run its word emphasis, not just the clipped half. + path: Path of the diffed file, used to pick a syntax highlighter. + before: Source aligned to the diff's *old* line numbers. May be a + truncated prefix or empty; rows whose text does not match the lexed + source are left unhighlighted (logged once per side at warning). + after: Source aligned to the diff's *new* line numbers, same contract. + Context rows are read from here, not from `before` — see + `_Row.number`. + show_numbers: Whether to render the line-number gutter. Pass `False` + when the diff's line numbers are not the file's — e.g. a diff of + edit fragments, whose hunks always start at 1. + + Yields: + One `Static` per rendered row, plus a trailing count when rows were + dropped to fit `max_lines`. An empty `diff` yields a single "no changes" + row; both callers already handle that case in their own output, so this + is a defensive fallback rather than the live path. + """ + if not diff: + yield Static(Content.styled("No changes detected", "dim")) + else: + yield from _compose_diff_content( + diff, max_lines, path, before, after, show_numbers=show_numbers + ) + + +def format_diff_stats(stats: DiffStats) -> Content: + """Format addition/deletion counts as styled `+N -M` content. + + Takes the pair as a `DiffStats` rather than two ints so the counts cannot be + transposed on the way to the places the user reads them — the `DiffMessage` + header and the approval prompt's `File:` header. + + Args: + stats: Line counts for the change. + + Returns: + Styled content, empty when both counts are zero. + """ + colors = theme.get_theme_colors() + parts: list[str | tuple[str, str] | Content] = [] + if stats.additions: + parts.append((f"+{stats.additions}", colors.success)) + if stats.deletions: + if parts: + parts.append(" ") + parts.append((f"-{stats.deletions}", colors.error)) + return Content.assemble(*parts) if parts else Content("") + + +def highlight_source_prefixes(diff: str, before: str, after: str) -> tuple[str, str]: + """Keep the bounded source prefixes needed to highlight a diff. + + Idempotent, and rehydration depends on it: `DiffMessage.__init__` calls this + on whatever it is handed, which is the full file from the live path but an + already-trimmed prefix from `MessageData`. Re-trimming a prefix must return it + unchanged, so any future trimming rule has to stay keyed on the diff's line + numbers rather than on a count relative to the input, and has to survive the + split/join round trip — see the trailing-newline case in + `_highlight_source_prefix`. `test_trimming_a_prefix_again_returns_it_unchanged` + pins this. + + Args: + diff: Unified diff string. + before: Content before the change — the whole file, or a prefix this + function previously returned. + after: Content after the change, same contract. + + Returns: + Before and after prefixes, with oversized sides omitted. + """ + rows = _parse_rows(split_diff_lines(diff)) + before_line = _max_number(rows, _BEFORE_KINDS) + after_line = _max_number(rows, _AFTER_KINDS) + return ( + _highlight_source_prefix(before, before_line), + _highlight_source_prefix(after, after_line), + ) + + +def _max_number(rows: list[_Row], kinds: tuple[_DiffRowKind, ...]) -> int: + """Return the highest line number among rows of `kinds`, or 0 for none.""" + return max((row.number or 0 for row in rows if row.kind in kinds), default=0) + + +def _highlight_source_prefix(source: str, line: int) -> str: + """Return the highlightable prefix ending at `line`.""" + if not source or line <= 0: + return "" + # Never split more than the limit itself. An edit near the end of a large + # file asks for a prefix that is going to be rejected anyway, and splitting + # the whole source first would allocate a near-full copy of the file per + # side, per compose, only to throw it away. One char past the limit is + # enough to tell "fits" from "does not". + oversized = len(source) > MAX_HIGHLIGHT_CHARS + lines = (source[: MAX_HIGHLIGHT_CHARS + 1] if oversized else source).splitlines() + # With a truncated head, `line` is only reached within the limit when a + # further line follows it — otherwise the last entry is a partial line and + # the real prefix runs past the limit. + if oversized and len(lines) <= line: + return "" + kept = lines[:line] + prefix = "\n".join(kept) + if kept and not kept[-1]: + # Re-splitting drops a trailing empty line, because a terminating + # newline yields no final entry — so without this the next trim would + # see one line fewer and return a shorter prefix. Joining with `"\n"` + # rather than keeping the original terminators is deliberate: it + # normalizes `\r`, U+2028 and the rest that `splitlines()` breaks on but + # `Content.split("\n")` does not, keeping row numbers aligned to the + # lexed output. + prefix += "\n" + return prefix if len(prefix) <= MAX_HIGHLIGHT_CHARS else "" + + +def _compose_diff_content( + diff: str, + max_lines: int | None, + path: str, + before: str, + after: str, + *, + show_numbers: bool = True, +) -> ComposeResult: + """Yield styled widgets for a non-empty diff.""" + glyphs = get_glyphs() + rows = _parse_rows(split_diff_lines(diff)) + total = len(rows) + if max_lines is not None: + rows = rows[:max_lines] + hidden = total - len(rows) + emphasis = _emphasis_by_row(rows) + highlighted = _highlighted_rows(rows, path, before, after) + width = max(2, len(str(max((row.number or 0 for row in rows), default=0)))) + + for index, row in enumerate(rows): + if row.kind == "separator": + yield Static( + Content.styled(glyphs.hunk_break, "bold $text-primary"), + classes="diff-hunk-break", + ) + continue + if row.kind == "truncated": + yield Static(Content.styled("... diff truncated", "dim")) + continue + if row.kind == "note": + yield Static(Content.from_markup("[dim]$text[/dim]", text=row.text)) + continue + body = highlighted.get(index) or Content(row.text) + style = _ROW_STYLES[row.kind] + if style.emphasis: + for start, end in emphasis.get(index, []): + body = body.stylize(style.emphasis, start, end) + parts: list[Content | str | tuple[str, str]] = [] + numbered = show_numbers and row.number is not None + if numbered: + parts += [(f"{row.number:>{width}}", style.gutter), " "] + parts += [(style.marker, style.marker_style), " ", body] + # The selectable prefix is everything before the source text: the + # padded number and a space, plus the marker and a space. + prefix_len = (width + 1 if numbered else 0) + 2 + yield _DiffRowStatic( + Content.assemble(*parts), + prefix_len, + classes=f"diff-line-{row.kind}" if row.kind != "context" else "", + ) + if hidden: + yield Static(Content.styled(f"\n... ({hidden} more lines)", "dim")) + + +def _highlighted_rows( + rows: list[_Row], path: str, before: str, after: str +) -> dict[int, Content]: + """Return a `{row index: highlighted content}` map. + + Each side is lexed from the start of the supplied source through the last + referenced line so multi-line constructs (docstrings, block comments) resolve + correctly rather than reopening at the hunk boundary. Rows outside the + prefix, or whose text has drifted from the source, are omitted and render as + plain text. + + That holds only as far as the caller's source really is file-aligned. The + approval prompt's main path now passes full before/after contents, but its + fallback still passes edit fragments: the lexer starts mid-file and treats + the fragment as if it began at line 1. The drift check below does not catch + this — the fragment's diff is generated *from* those same strings, so the + row text matches and every row is highlighted. A fragment cut from inside a + docstring or block comment is therefore colored as code, and nothing + detects it. Cosmetic, and confined to the approval prompt's fallback. + + Assumes a single-file diff, as `before`/`after` are one file's contents: rows + are matched to source by line number, which restarts per file in a multi-file + diff and would collide. + """ + if not path: + return {} + highlighted: dict[int, Content] = {} + for kinds, code in ((_BEFORE_KINDS, before), (_AFTER_KINDS, after)): + wanted = { + row.number: i + for i, row in enumerate(rows) + if row.kind in kinds and row.number is not None + } + if not wanted or not code: + continue + head = _highlight_source_prefix(code, max(wanted)) + if not head: + continue + lines = _highlight_lines(head, path) + if lines is None: + continue + drifted = 0 + for number, index in wanted.items(): + line = lines[number - 1] if 0 < number <= len(lines) else None + if line is None: + continue + if line.plain != rows[index].text: + drifted += 1 + continue + highlighted[index] = line + if drifted: + # The source no longer matches the diff it came with — a stale + # rehydration, or `before`/`after` belonging to another file. + # Rendering plain is right, but it also hides a real misalignment, + # so leave a trace. Once per side at warning rather than per row at + # debug: a whole drifted side reports thousands of rows, and debug + # sits below both the default level and the in-app console's ring + # buffer, so the trace was invisible where it mattered. + logger.warning( + "Highlight source drifted from diff at %s (%s): %d of %d rows", + path, + "/".join(kinds), + drifted, + len(wanted), + ) + return highlighted + + +@lru_cache(maxsize=4) +def _highlight_lines_cached(code: str, path: str) -> tuple[Content, ...] | None: + """Return highlighted source lines, or `None` if lexing fails. + + Cached because scrolling rebuilds a `DiffMessage` from `MessageData` on every + pass, and each mount would otherwise re-lex both sides. Two entries per diff, + so `maxsize=4` holds the last two diffs — the scrolling case it exists for. + + Sized small on purpose. `MAX_HIGHLIGHT_CHARS` bounds the *input*, not what is + retained: an entry is one `Content` per line, each carrying a span list, and + measures several times its source. Nothing clears this cache, so its cost is + held for the process lifetime — size it against measured entries, not against + the character limit. + + *Expected* failures are cached too: a file whose lexer cannot parse it will + not parse on the next scroll either, and retrying would pay the cost to fail + again. Unexpected ones deliberately propagate to `_highlight_lines`, which + handles them outside the cache — memoizing a genuine bug would log it once + per `(code, path)` and then hide it for the rest of the process. + """ + try: + return tuple(highlight(code, path=path, tab_size=0).split("\n")) + except (ValueError, LookupError) as e: + # No usable lexer. Not reachable through an unknown extension — + # `highlight` guesses a lexer rather than raising, so `m.unknownext` + # and a bare `noext` both return styled output. This covers a lexer + # that fails on the content itself, and any future `highlight` that + # stops guessing; debug rather than warning because degrading to plain + # text is a complete, if plainer, render. + logger.debug("No usable lexer for %s: %s", path, e) + return None + + +def _highlight_lines(code: str, path: str) -> tuple[Content, ...] | None: + """Return highlighted source lines, or `None` if highlighting fails. + + Wraps the cache rather than living inside it so an unexpected failure stays + visible on every attempt. `lru_cache` does not memoize raised exceptions, so + letting them escape `_highlight_lines_cached` is what keeps the retry. + + Returns: + One `Content` per line, or `None` when the source could not be lexed. + """ + try: + return _highlight_lines_cached(code, path) + except Exception: + # Anything not caught inside is a bug here or a Textual API change, not + # a missing lexer. Highlighting is cosmetic, so still degrade to plain + # text, but say so at a level that will actually be seen. + logger.warning( + "Syntax highlighting failed unexpectedly for %s", path, exc_info=True + ) + return None + + +def _parse_rows(lines: list[str]) -> list[_Row]: + """Return renderable rows parsed from unified-diff lines.""" + rows: list[_Row] = [] + header_indexes = file_header_indexes(lines) + old = new = 0 + seen_hunk = False + for index, line in enumerate(lines): + if index in header_indexes: + continue + if match := HUNK_RE.match(line): + old, new = int(match.group(1)), int(match.group(3)) + if seen_hunk: + rows.append(_Row("separator", "", None)) + seen_hunk = True + elif line.startswith("-"): + rows.append(_Row("removed", line[1:], old)) + old += 1 + elif line.startswith("+"): + rows.append(_Row("added", line[1:], new)) + new += 1 + elif line.startswith(" "): + # Numbered from `new`, not `old` — see `_Row.number`. Both walkers + # still advance: `old` is what the *next* removed row is numbered + # from. + rows.append(_Row("context", line[1:], new)) + old += 1 + new += 1 + elif is_truncation_marker(line): + # Checked after the marker prefixes above, so a context or added + # line whose own text is `...` stays a source row. Reordering these + # branches would render it as "diff truncated". + rows.append(_Row("truncated", "", None)) + else: + rows.append(_Row("note", line, None)) + return rows + + +def _emphasis_by_row(rows: list[_Row]) -> dict[int, list[_Range]]: + """Return changed ranges for equal-length removed/added runs. + + A removed run is paired with the added run that immediately follows it, row + by row in order, and only when the two are the same length — with no + one-to-one correspondence there is nothing to diff a row against. Any other + row kind between them (including a `note`, which is what a "no newline at + end of file" marker parses to) breaks the adjacency and leaves the pair + unemphasised. + """ + runs = [ + (kind, [index for index, _ in group]) + for kind, group in groupby(enumerate(rows), key=lambda pair: pair[1].kind) + ] + ranges: dict[int, list[_Range]] = {} + for (kind, old_indexes), (next_kind, new_indexes) in pairwise(runs): + if ( + kind != "removed" + or next_kind != "added" + or len(old_indexes) != len(new_indexes) + ): + continue + for old_index, new_index in zip(old_indexes, new_indexes, strict=True): + old, new = _emphasis_ranges(rows[old_index].text, rows[new_index].text) + if old: + ranges[old_index] = old + if new: + ranges[new_index] = new + return ranges + + +def _is_related(old_tokens: list[str], new_tokens: list[str]) -> bool: + """Return whether two lines are similar enough for word emphasis.""" + old_words = [token for token in old_tokens if token.strip()] + new_words = [token for token in new_tokens if token.strip()] + if not old_words or not new_words: + return False + matcher = SequenceMatcher(a=old_words, b=new_words, autojunk=False) + # `quick_ratio` is a cheap upper bound on `ratio`, so a failure there rules + # the pair out without running the full match. + return ( + matcher.quick_ratio() >= _SIMILARITY_FLOOR + and matcher.ratio() >= _SIMILARITY_FLOOR + ) + + +def _emphasis_ranges(old: str, new: str) -> tuple[list[_Range], list[_Range]]: + """Return changed ranges within a related removed/added pair.""" + if not old or not new or max(len(old), len(new)) > _MAX_EMPHASIS_LEN: + return [], [] + old_tokens = _TOKEN_RE.findall(old) + new_tokens = _TOKEN_RE.findall(new) + if not _is_related(old_tokens, new_tokens): + return [], [] + matcher = SequenceMatcher(a=old_tokens, b=new_tokens, autojunk=False) + old_offsets = [0, *accumulate(len(token) for token in old_tokens)] + new_offsets = [0, *accumulate(len(token) for token in new_tokens)] + old_ranges: list[_Range] = [] + new_ranges: list[_Range] = [] + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + continue + if i2 > i1: + old_ranges.append((old_offsets[i1], old_offsets[i2])) + if j2 > j1: + new_ranges.append((new_offsets[j1], new_offsets[j2])) + # No total-coverage bail-out is needed: `_is_related` has already found + # shared word tokens, so `get_opcodes` always yields at least one `equal` + # block and the ranges can never span the whole line on both sides. + return old_ranges, new_ranges diff --git a/libs/code/deepagents_code/tui/widgets/effort_selector.py b/libs/code/deepagents_code/tui/widgets/effort_selector.py new file mode 100644 index 0000000000..fbe7377b5d --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/effort_selector.py @@ -0,0 +1,188 @@ +"""Interactive reasoning effort selector for `/effort`.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.content import Content +from textual.css.query import NoMatches +from textual.screen import ModalScreen +from textual.widgets import OptionList, Static +from textual.widgets.option_list import Option + +if TYPE_CHECKING: + from textual.app import ComposeResult + +from deepagents_code import theme +from deepagents_code.config import get_glyphs, is_ascii_mode + + +class EffortSelectorScreen(ModalScreen[str | None]): + """Modal dialog for selecting a reasoning effort level.""" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "cancel", "Cancel", show=False), + Binding("tab", "cursor_down", "Next", show=False, priority=True), + Binding("shift+tab", "cursor_up", "Previous", show=False, priority=True), + ] + + CSS = """ + EffortSelectorScreen { + align: center middle; + } + + EffortSelectorScreen > Vertical { + width: 54; + max-width: 90%; + height: auto; + max-height: 80%; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + EffortSelectorScreen .effort-selector-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + EffortSelectorScreen .effort-selector-subtitle { + height: auto; + color: $text-muted; + text-align: center; + margin-bottom: 1; + } + + EffortSelectorScreen OptionList { + height: auto; + max-height: 10; + background: $background; + } + + EffortSelectorScreen .effort-selector-help { + height: auto; + color: $text-muted; + text-style: italic; + margin-top: 1; + text-align: center; + } + """ + + def __init__( + self, + *, + model_spec: str, + efforts: tuple[str, ...], + current_effort: str | None = None, + default_effort: str | None = None, + ) -> None: + """Initialize the effort selector. + + Args: + model_spec: Active `provider:model` spec. + efforts: Supported effort labels for `model_spec`. + current_effort: Current per-session effort override, if any. + default_effort: Provider default effort for `model_spec`, if known. + """ + super().__init__() + self._model_spec = model_spec + self._efforts = efforts + self._current_effort = current_effort + self._default_effort = default_effort + + def compose(self) -> ComposeResult: + """Compose the screen layout. + + Yields: + Widgets for the effort selector UI. + """ + glyphs = get_glyphs() + options = [ + Option(self._format_label(effort), id=effort) for effort in self._efforts + ] + highlighted_effort = self._current_effort or self._default_effort + try: + highlighted = self._efforts.index(highlighted_effort) + except ValueError: + highlighted = 0 + help_text = ( + f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab switch" + f" {glyphs.bullet} Enter select" + f" {glyphs.bullet} Esc cancel" + ) + with Vertical(): + yield Static("Select Reasoning Effort", classes="effort-selector-title") + yield Static(self._model_spec, classes="effort-selector-subtitle") + option_list = OptionList(*options, id="effort-options") + option_list.highlighted = highlighted + yield option_list + yield Static(help_text, classes="effort-selector-help") + + def _format_label(self, effort: str) -> Content: + """Render an effort label with a current marker. + + Args: + effort: Effort label. + + Returns: + Styled option label. + """ + markers = [] + if effort == self._current_effort: + markers.append("current") + if effort == self._default_effort: + markers.append("default") + if markers: + suffix = ", ".join(markers) + return Content.from_markup( + "$effort [dim]($suffix)[/dim]", effort=effort, suffix=suffix + ) + return Content.from_markup("$effort", effort=effort) + + def on_mount(self) -> None: + """Apply ASCII border if needed.""" + if is_ascii_mode(): + container = self.query_one(Vertical) + colors = theme.get_theme_colors(self) + container.styles.border = ("ascii", colors.success) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + """Dismiss with the selected effort. + + Args: + event: The option selected event. + """ + effort = event.option.id + # Every option is built with a non-empty `id` (the effort label), so + # this is always truthy today. Guard anyway: a future id-less option + # (e.g. a separator) would otherwise dismiss with `None`, which reads + # as a cancel — a silent no-op rather than a clearly-impossible branch. + if effort is not None: + self.dismiss(effort) + + def action_cancel(self) -> None: + """Cancel without changing effort.""" + self.dismiss(None) + + def action_cursor_down(self) -> None: + """Move the option list cursor down.""" + option_list = self._option_list() + if option_list is not None: + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + """Move the option list cursor up.""" + option_list = self._option_list() + if option_list is not None: + option_list.action_cursor_up() + + def _option_list(self) -> OptionList | None: + """Return the option list if it is mounted.""" + try: + return self.query_one("#effort-options", OptionList) + except NoMatches: + return None diff --git a/libs/code/deepagents_code/tui/widgets/goal_review.py b/libs/code/deepagents_code/tui/widgets/goal_review.py new file mode 100644 index 0000000000..3d854817ad --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/goal_review.py @@ -0,0 +1,416 @@ +"""Goal acceptance-criteria review widget.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal, TypedDict + +from textual.binding import Binding, BindingType +from textual.containers import Container, Vertical, VerticalScroll +from textual.content import Content +from textual.message import Message +from textual.widgets import Markdown, Static + +if TYPE_CHECKING: + import asyncio + + from textual import events + from textual.app import ComposeResult + +from deepagents_code.config import get_glyphs +from deepagents_code.editor import editor_display_name +from deepagents_code.tui.widgets._inline_prompt import ( + InlinePromptCompletion, + InlinePromptOption, + InlinePromptTextArea, + apply_inline_prompt_border, + newline_hint, + stop_inline_prompt_blur, +) + +# Menu options in display order: (label, `action_*` suffix). The list index is +# the cursor position, so labels and dispatch stay aligned from one source. +_OPTIONS: tuple[tuple[str, str], ...] = ( + ("1. Accept proposed criteria (y)", "accept"), + ("2. Edit criteria (e)", "edit"), + ("3. Reject with message (r)", "reject_with_message"), + ("4. Cancel (n)", "cancel"), +) + + +def _editor_hint() -> str: + """Return the current editor shortcut hint.""" + editor = editor_display_name() + return ( + f"Ctrl+X edit in {editor}" if editor is not None else "Ctrl+X external editor" + ) + + +class GoalReviewAccepted(TypedDict): + """Widget result when the generated criteria are accepted unchanged.""" + + type: Literal["accepted"] + """Discriminator tag for accepting generated criteria unchanged.""" + + +class GoalReviewEdited(TypedDict): + """Widget result when the user submits revised criteria.""" + + type: Literal["edited"] + """Discriminator tag for submitting revised criteria.""" + + criteria: str + """User-edited acceptance criteria to activate for the goal.""" + + +class GoalReviewRejected(TypedDict): + """Widget result when the user rejects criteria with feedback.""" + + type: Literal["rejected"] + """Discriminator tag for regenerating criteria from user feedback.""" + + message: str + """User feedback explaining how the criteria should be regenerated.""" + + +class GoalReviewCancelled(TypedDict): + """Widget result when the user cancels the proposal.""" + + type: Literal["cancelled"] + """Discriminator tag for cancelling the pending goal proposal.""" + + +GoalReviewResult = ( + GoalReviewAccepted | GoalReviewEdited | GoalReviewRejected | GoalReviewCancelled +) + + +class GoalReviewTextArea(InlinePromptTextArea): + """Text input that keeps goal-review edit keystrokes inside the editor.""" + + class Submitted(InlinePromptTextArea.Submitted): + """Posted when the user presses Enter to submit goal-review text.""" + + class CancelEdit(Message): + """Posted when Escape should leave goal criteria edit mode.""" + + async def _on_key(self, event: events.Key) -> None: + if event.key == "escape": + event.prevent_default() + event.stop() + self.post_message(self.CancelEdit()) + return + + await super()._on_key(event) + + +class GoalReviewMenu(Container): + """Inline review widget for generated goal acceptance criteria.""" + + can_focus = True + """Allow the menu itself to receive navigation and quick-key focus.""" + + can_focus_children = True + """Allow the inline criteria editor to receive text input focus.""" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", "Up", show=False), + Binding("k", "move_up", "Up", show=False), + Binding("down", "move_down", "Down", show=False), + Binding("j", "move_down", "Down", show=False), + Binding("enter", "select", "Select", show=False), + Binding("1", "accept", "Accept", show=False), + Binding("y", "accept", "Accept", show=False), + Binding("2", "edit", "Edit", show=False), + Binding("e", "edit", "Edit", show=False), + Binding("3", "reject_with_message", "Reject with message", show=False), + Binding("r", "reject_with_message", "Reject with message", show=False), + Binding("4", "cancel", "Cancel", show=False), + Binding("n", "cancel", "Cancel", show=False), + Binding("escape", "cancel", "Cancel", show=False), + ] + """Keyboard bindings for navigation, accepting, editing, and cancelling.""" + + class Decided(Message): + """Message sent when the user accepts, edits, or cancels.""" + + def __init__( + self, + result: GoalReviewResult, + widget: GoalReviewMenu | None = None, + ) -> None: + """Initialize a decision message.""" + super().__init__() + self.result = result + """Decision payload emitted by the review widget.""" + + self.widget = widget + """Review widget that emitted the decision.""" + + def __init__( + self, + objective: str, + criteria: str, + *, + amendment: bool = False, + id: str | None = None, # noqa: A002 + ) -> None: + """Initialize the goal review menu.""" + super().__init__( + id=id or "goal-review-menu", + classes="inline-prompt goal-review-menu", + ) + self._objective = objective + """Goal objective whose generated criteria are being reviewed.""" + + self._criteria = criteria + """Generated acceptance criteria proposed for the goal.""" + + self._amendment = amendment + """Whether this review updates an existing goal.""" + + self._selected = 0 + """Index of the currently highlighted action option.""" + + self._option_widgets: list[InlinePromptOption] = [] + """Mounted option widgets updated when selection changes.""" + + self._help_widget: Static | None = None + """Mounted keyboard-help widget, populated during composition.""" + + self._edit_input: GoalReviewTextArea | None = None + """Inline editor used for revised criteria or rejection feedback.""" + + self._input_mode: Literal["edit", "reject"] | None = None + """Whether an inline text input is currently active.""" + + self._completion: InlinePromptCompletion[GoalReviewResult] = ( + InlinePromptCompletion() + ) + """One-shot resolver for accepted, edited, rejected, or cancelled results.""" + + def set_future(self, future: asyncio.Future[GoalReviewResult]) -> None: + """Set the future to resolve when the user decides.""" + self._completion.set_future(future) + + def compose(self) -> ComposeResult: + """Compose the review widget. + + Yields: + Widgets for the title, criteria preview, actions, editor, and help text. + """ + glyphs = get_glyphs() + title = "Review goal amendment" if self._amendment else "Review goal criteria" + yield Static( + Content.from_markup("$cursor $title", cursor=glyphs.cursor, title=title), + classes="inline-prompt-title goal-review-title", + ) + with ( + VerticalScroll(classes="goal-review-content"), + Vertical(classes="goal-review-body"), + ): + source = f"**Proposed criteria**\n\n{self._criteria}" + if self._amendment: + source = ( + f"**Proposed objective**\n\n{self._objective}\n\n" + f"**Proposed criteria**\n\n{self._criteria}" + ) + yield Markdown(source, classes="goal-review-markdown") + with Container(classes="goal-review-options-container"): + for i, (label, _) in enumerate(_OPTIONS): + widget = InlinePromptOption( + label, + i, + selected=i == self._selected, + selected_class="goal-review-option-selected", + classes="goal-review-option", + ) + self._option_widgets.append(widget) + yield widget + self._edit_input = GoalReviewTextArea(classes="goal-review-edit-input") + self._edit_input.text = self._criteria + self._edit_input.display = False + yield self._edit_input + self._help_widget = Static( + "", + classes="inline-prompt-help goal-review-help", + ) + yield self._help_widget + + async def on_mount(self) -> None: + """Focus the menu and render options after mount.""" + apply_inline_prompt_border(self) + self._update_options() + self.focus() + + def focus_active(self) -> None: + """Focus the active control.""" + if self._input_mode is not None and self._edit_input is not None: + self._edit_input.focus() + return + self.focus() + + def action_move_up(self) -> None: + """Move selection up.""" + if self._input_mode is not None: + return + self._selected = (self._selected - 1) % len(_OPTIONS) + self._update_options() + + def action_move_down(self) -> None: + """Move selection down.""" + if self._input_mode is not None: + return + self._selected = (self._selected + 1) % len(_OPTIONS) + self._update_options() + + def action_select(self) -> None: + """Select the highlighted option.""" + if self._input_mode is not None: + return + action_name = _OPTIONS[self._selected][1] + getattr(self, f"action_{action_name}")() + + def action_accept(self) -> None: + """Accept the proposed criteria unchanged.""" + if self._input_mode is not None: + return + self._submit({"type": "accepted"}) + + def action_edit(self) -> None: + """Open the inline editor for revised criteria.""" + if self._completion.resolved or self._input_mode is not None: + return + self._input_mode = "edit" + if self._edit_input is not None: + self._edit_input.text = self._criteria + self._edit_input.reset_paste_state() + self._edit_input.display = True + self._edit_input.focus() + self._update_options() + + def action_reject_with_message(self) -> None: + """Open the inline feedback input for regenerating criteria.""" + if self._completion.resolved or self._input_mode is not None: + return + self._input_mode = "reject" + if self._edit_input is not None: + self._edit_input.text = "" + self._edit_input.reset_paste_state() + self._edit_input.display = True + self._edit_input.focus() + self._update_options() + + def action_cancel(self) -> None: + """Cancel editing or cancel the whole proposal.""" + if self._completion.resolved: + return + if self._input_mode is not None: + self._input_mode = None + if self._edit_input is not None: + self._edit_input.display = False + self._update_options() + self.focus() + return + self._submit({"type": "cancelled"}) + + def on_goal_review_text_area_submitted( + self, + event: GoalReviewTextArea.Submitted, + ) -> None: + """Submit edited criteria when Enter is pressed in the editor.""" + if event.text_area is not self._edit_input: + return + event.stop() + if self._input_mode == "edit": + self._submit_edit() + return + if self._input_mode == "reject": + self._submit_rejection() + + def on_goal_review_text_area_cancel_edit( + self, + event: GoalReviewTextArea.CancelEdit, + ) -> None: + """Return from edit mode when Escape is pressed in the editor.""" + event.stop() + self.action_cancel() + + def on_blur(self, event: events.Blur) -> None: # noqa: PLR6301 # Textual event handler + """Prevent blur from dismissing the review prompt.""" + stop_inline_prompt_blur(event) + + def _submit_edit(self) -> None: + """Submit the current editor text as revised criteria.""" + if self._edit_input is None: + return + criteria = self._edit_input.submitted_value.strip() + if not criteria: + self._hint_empty_submission("criteria") + return + self._submit({"type": "edited", "criteria": criteria}) + + def _submit_rejection(self) -> None: + """Submit the current editor text as regeneration feedback.""" + if self._edit_input is None: + return + message = self._edit_input.submitted_value.strip() + if not message: + self._hint_empty_submission("feedback") + return + self._submit({"type": "rejected", "message": message}) + + def _hint_empty_submission(self, what: str) -> None: + """Explain why an empty editor submission did nothing. + + Without this the editor silently no-ops on an empty Enter, leaving the + user unsure whether the keypress registered. + + Args: + what: Noun for the missing content (e.g. `criteria`, `feedback`). + """ + if self._help_widget is None: + return + glyphs = get_glyphs() + self._help_widget.update( + f"Enter some {what}, or press Esc to go back {glyphs.bullet} " + f"{newline_hint()} {glyphs.bullet} {_editor_hint()}" + ) + + def _submit(self, result: GoalReviewResult) -> None: + """Resolve the result future and post the decision message.""" + if self._completion.resolved: + return + self.display = False + if self._completion.resolve(result): + self.post_message(self.Decided(result, self)) + + def _update_options(self) -> None: + """Render option labels and help text.""" + for i, widget in enumerate(self._option_widgets): + widget.set_state( + cursor=i == self._selected, + highlighted=i == self._selected and self._input_mode is None, + ) + + if self._help_widget is None: + return + glyphs = get_glyphs() + if self._input_mode == "edit": + self._help_widget.update( + f"Enter save edits {glyphs.bullet} " + f"{newline_hint()} {glyphs.bullet} " + f"{_editor_hint()} {glyphs.bullet} Esc back" + ) + return + if self._input_mode == "reject": + self._help_widget.update( + f"Enter regenerate {glyphs.bullet} " + f"{newline_hint()} {glyphs.bullet} " + f"{_editor_hint()} {glyphs.bullet} Esc back" + ) + return + self._help_widget.update( + f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate {glyphs.bullet} " + f"Enter select {glyphs.bullet} y/e/r/n quick keys {glyphs.bullet} " + "Esc cancel" + ) diff --git a/libs/code/deepagents_code/tui/widgets/goal_status.py b/libs/code/deepagents_code/tui/widgets/goal_status.py new file mode 100644 index 0000000000..bd97a89c1a --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/goal_status.py @@ -0,0 +1,50 @@ +"""Persistent inline display for the current goal.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from textual.content import Content +from textual.widgets import Static + +if TYPE_CHECKING: + from deepagents_code.resume_state import GoalStatus + + +class GoalStatusPanel(Static): + """Keep the current goal and lifecycle state visible above the input.""" + + def __init__(self, *, id: str | None = None) -> None: # noqa: A002 + """Initialize an empty hidden goal panel.""" + super().__init__("", id=id, classes="goal-status-panel") + self.display = False + + def set_goal( + self, + objective: str | None, + status: GoalStatus | None, + note: str | None, + ) -> None: + """Render the current goal or hide the panel when no goal exists. + + Args: + objective: Persisted goal objective, if set. + status: Current lifecycle state. + note: Blocker or completion note associated with the state. + """ + if not objective: + self.update("") + self.display = False + return + + current = status or "active" + label = "completed" if current == "complete" else current + content = Content.from_markup( + "[bold]Goal · $status[/bold]\n$objective", + status=label, + objective=objective, + ) + if note and current in {"blocked", "complete"}: + content += Content.from_markup("\n[dim]$note[/dim]", note=note) + self.update(content) + self.display = True diff --git a/libs/cli/deepagents_cli/widgets/history.py b/libs/code/deepagents_code/tui/widgets/history.py similarity index 92% rename from libs/cli/deepagents_cli/widgets/history.py rename to libs/code/deepagents_code/tui/widgets/history.py index 26ec62f31a..c9362fefa7 100644 --- a/libs/cli/deepagents_cli/widgets/history.py +++ b/libs/code/deepagents_code/tui/widgets/history.py @@ -97,8 +97,14 @@ def add(self, text: str) -> None: text: The command text to add """ text = text.strip() - # Skip empty or slash commands - if not text or text.startswith("/"): + # Skip empty input and slash commands, except the explicit + # `/skill:` form (case-insensitive), which is kept so users can + # recall it with up-arrow. Note: history stores the raw submitted text + # *before* app-layer alias rewriting, so convenience aliases such as + # `/remember` (later rewritten to `/skill:remember`) are dropped here + # despite being skill invocations. + lower_text = text.lower() + if not text or (text.startswith("/") and not lower_text.startswith("/skill:")): return # Skip duplicates of the last entry diff --git a/libs/code/deepagents_code/tui/widgets/install_confirm.py b/libs/code/deepagents_code/tui/widgets/install_confirm.py new file mode 100644 index 0000000000..57e091ea8b --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/install_confirm.py @@ -0,0 +1,388 @@ +"""Confirmation modal for `/install --package` in the TUI. + +Arbitrary packages have no curated allowlist to vet against, so installing +one pulls in third-party code. Rather than forcing the user to re-run with +`--force`, this non-blocking modal asks for explicit confirmation before the +install runs. `--force` (or `--yes`) still bypasses the prompt. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, ClassVar + +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.content import Content +from textual.screen import ModalScreen +from textual.style import Style as TStyle +from textual.widgets import Static + +from deepagents_code.tui.widgets._links import event_targets_link, open_style_link + +if TYPE_CHECKING: + from textual.app import ComposeResult + from textual.events import Click, MouseMove + +logger = logging.getLogger(__name__) + + +def _package_link(package: str, *, hovered: bool = False) -> Content: + """Render a package name as a bold, underlined PyPI link. + + Args: + package: The distribution name to display and link. Callers must only + pass names known to exist on PyPI -- the URL is built by + interpolation and is never validated against the index. + hovered: Whether to add a reverse-video highlight, matching the + pointer cursor shown while the mouse is over the link. + + Returns: + Styled content linking to the package's PyPI project page. + """ + url = f"https://pypi.org/project/{package}/" + style = TStyle(bold=True, underline=True, reverse=hovered, link=url) + return Content.assemble((package, style)) + + +class _InstallConfirmScreen(ModalScreen[bool]): + """Base screen adding link hover/click affordances to install prompts. + + Subclasses define `_body_content(*, hovered: bool) -> Content` to rebuild + their body text with the package link's highlight toggled, and must compose + exactly one `Static.install-confirm-body` initialized from it; + `_refresh_body(hovered=...)` rewrites that widget in place as the pointer + enters and leaves the link. + + Subclassing `ModalScreen[bool]` directly, rather than composing a plain + mixin, keeps `styles`/`query_one` visible to the type checker without a + `Protocol` or multiple inheritance. The trade-off is that this is not + reusable by modals with a different dismiss type. + """ + + _hovered: bool = False + + def _body_content(self, *, hovered: bool = False) -> Content: + """Return the body `Content` with the link hover state applied.""" + msg = f"{type(self).__name__} must override _body_content" + raise NotImplementedError(msg) + + def _refresh_body(self, *, hovered: bool) -> None: + """Rewrite the body widget with the link's hover highlight toggled. + + The widget is looked up on every call rather than cached: a screen + instance that is popped and re-pushed re-composes, and a cached + `Static` would leave `update` silently writing to a detached widget. + + Args: + hovered: Whether the link should render highlighted. + """ + body = self.query_one(".install-confirm-body", Static) + body.update(self._body_content(hovered=hovered)) + + def on_click(self, event: Click) -> None: + """Open style-embedded hyperlinks on single click.""" + # Pass `app` explicitly: `open_style_link` otherwise reflects it off the + # event, and silently drops its failure toasts when that lookup misses. + open_style_link(event, app=self.app) + + def on_mouse_move(self, event: MouseMove) -> None: + """Show a pointer cursor over the link and highlight it on hover.""" + over_link = event_targets_link(event) + self.styles.pointer = "pointer" if over_link else "default" + if over_link != self._hovered: + self._hovered = over_link + self._refresh_body(hovered=over_link) + + def on_leave(self) -> None: + """Reset the cursor and clear any hover highlight.""" + self.styles.pointer = "default" + if self._hovered: + self._hovered = False + self._refresh_body(hovered=False) + + +class InstallPackageConfirmScreen(_InstallConfirmScreen): + """Confirmation overlay for installing an arbitrary `--package`. + + Dismisses with `True` when the user confirms and `False` when the user + cancels. Esc is treated as cancel so the user is never forced into an + install they did not explicitly choose. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "confirm", "Install", show=False, priority=True), + Binding("escape", "cancel", "Cancel", show=False, priority=True), + ] + + CSS = """ + InstallPackageConfirmScreen { + align: center middle; + } + + InstallPackageConfirmScreen > Vertical { + width: 64; + max-width: 90%; + height: auto; + background: $surface; + border: solid $warning; + padding: 1 2; + } + + InstallPackageConfirmScreen .install-confirm-title { + text-style: bold; + color: $warning; + text-align: center; + margin-bottom: 1; + } + + InstallPackageConfirmScreen .install-confirm-body { + height: auto; + color: $text; + margin-bottom: 1; + } + + InstallPackageConfirmScreen .install-confirm-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def __init__(self, package: str) -> None: + """Initialize the prompt. + + Args: + package: The package name to install, surfaced in the body. + """ + super().__init__() + self._package = package + + def _body_content(self, *, hovered: bool = False) -> Content: + """Build the body text, toggling the link's hover highlight. + + Args: + hovered: Whether the PyPI link should render highlighted. + + Returns: + The body `Content` with the package link styled for `hovered`. + """ + return Content.assemble( + "Installing ", + _package_link(self._package, hovered=hovered), + " runs third-party code in the dcode environment.", + ) + + def compose(self) -> ComposeResult: + """Compose the install confirmation dialog. + + Yields: + Title, body, and help-row widgets parented inside a `Vertical`. + """ + with Vertical(): + yield Static( + "Install package?", + classes="install-confirm-title", + markup=False, + ) + yield Static( + self._body_content(), + classes="install-confirm-body", + markup=False, + ) + yield Static( + "Enter to install, Esc to cancel", + classes="install-confirm-help", + markup=False, + ) + + def action_confirm(self) -> None: + """Dismiss with `True`.""" + self.dismiss(True) + + def action_cancel(self) -> None: + """Dismiss with `False`. + + The method name must stay `cancel`: the app owns a priority `escape` + binding that, for an active `ModalScreen`, dispatches to + `action_cancel` if present and otherwise falls through to + `dismiss(None)`. Renaming this would silently regress Esc to a + `None` dismiss instead of an explicit cancel. + """ + self.dismiss(False) + + +class InstallProviderConfirmScreen(_InstallConfirmScreen): + """Confirmation overlay for installing a model provider's extra. + + Shown from the model selector when the user picks a model whose provider + integration package is not installed. Dismisses with `True` to install and + `False` to cancel; Esc cancels so the user is never forced into an install. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "confirm", "Install", show=False, priority=True), + Binding("escape", "cancel", "Cancel", show=False, priority=True), + ] + + CSS = """ + InstallProviderConfirmScreen { + align: center middle; + } + + InstallProviderConfirmScreen > Vertical { + width: 64; + max-width: 90%; + height: auto; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + InstallProviderConfirmScreen .install-confirm-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + InstallProviderConfirmScreen .install-confirm-body { + height: auto; + color: $text; + margin-bottom: 1; + } + + InstallProviderConfirmScreen .install-confirm-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def __init__( + self, provider: str, extra: str, model_spec: str | None = None + ) -> None: + """Initialize the prompt. + + Args: + provider: The provider whose integration is missing. + extra: The `deepagents-code` extra that installs the provider. + model_spec: The selected `provider:model` spec, surfaced in the body + by the model selector. Omitted by the `/auth` manager, which + installs a provider so a key can be added rather than to switch + to a specific model. + """ + super().__init__() + self._provider = provider + self._extra = extra + self._model_spec = model_spec + + def _provider_label(self) -> str: + """Return a human-readable label for the provider. + + Reuses the auth UI's curated labels (e.g. `google_genai` -> "Google + Gemini") so the prompt reads naturally, falling back to a title-cased + provider key. Avoids the event-loop config read in + `provider_display_name`, which is overkill for static prompt text. + + Returns: + The curated display name, or the title-cased provider key. + """ + from deepagents_code.tui.widgets.auth import PROVIDER_DISPLAY_NAMES + + return PROVIDER_DISPLAY_NAMES.get( + self._provider, self._provider.replace("_", " ").title() + ) + + def _package_content(self, *, hovered: bool) -> Content: + """Render the package name, linked to PyPI when the name is known. + + Extras are `deepagents-code` extra names, not distribution names, and + several of them (`vertex`, `bedrock`, ...) collide with unrelated real + PyPI projects. So an uncurated provider falls back to plain bold text + rather than a confident link to the wrong package. + + Args: + hovered: Whether the link should render highlighted. + + Returns: + The package name as a PyPI link, or as unlinked bold text. + """ + from deepagents_code.config_manifest import provider_package_name + + package = provider_package_name(self._provider) + if package is None: + logger.warning( + "No curated PyPI package for provider %r; rendering extra %r " + "without a link", + self._provider, + self._extra, + ) + return Content.assemble((self._extra, "bold")) + return _package_link(package, hovered=hovered) + + def _body_content(self, *, hovered: bool = False) -> Content: + """Build the body text, toggling the link's hover highlight. + + Args: + hovered: Whether the PyPI link should render highlighted. + + Returns: + The body `Content` with the package link styled for `hovered`. + """ + package = self._package_content(hovered=hovered) + if self._model_spec is not None: + return Content.assemble( + "To use ", + (self._model_spec, "bold"), + ", dcode needs to install the ", + package, + " integration. This will add the provider package to your " + "dcode environment.", + ) + return Content.assemble( + "To add a key for ", + (self._provider_label(), "bold"), + ", dcode needs to install the ", + package, + " integration. This will add the provider package to your " + "dcode environment.", + ) + + def compose(self) -> ComposeResult: + """Compose the provider-install confirmation dialog. + + Yields: + Title, body, and help-row widgets parented inside a `Vertical`. + """ + with Vertical(): + yield Static( + f"Install {self._provider_label()} support?", + classes="install-confirm-title", + markup=False, + ) + yield Static( + self._body_content(), + classes="install-confirm-body", + markup=False, + ) + yield Static( + "Enter to install, Esc to cancel", + classes="install-confirm-help", + markup=False, + ) + + def action_confirm(self) -> None: + """Dismiss with `True`.""" + self.dismiss(True) + + def action_cancel(self) -> None: + """Dismiss with `False`. + + The method name must stay `cancel` for the same reason as + `InstallPackageConfirmScreen.action_cancel`: the app's priority + `escape` binding dispatches to it for an active `ModalScreen`. + """ + self.dismiss(False) diff --git a/libs/code/deepagents_code/tui/widgets/launch_init.py b/libs/code/deepagents_code/tui/widgets/launch_init.py new file mode 100644 index 0000000000..2e436a619c --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/launch_init.py @@ -0,0 +1,657 @@ +"""Onboarding screens for the interactive TUI.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, ClassVar + +from textual.app import ScreenStackError +from textual.binding import Binding, BindingType +from textual.containers import Vertical, VerticalScroll +from textual.content import Content +from textual.css.query import NoMatches +from textual.screen import ModalScreen +from textual.widgets import Input, OptionList, Static +from textual.widgets.option_list import Option + +if TYPE_CHECKING: + from collections.abc import Callable + + from textual.app import ComposeResult + from textual.screen import Screen + + from deepagents_code.extras_info import ExtraDependencyStatus + +from deepagents_code import theme +from deepagents_code.config import get_glyphs, is_ascii_mode +from deepagents_code.extras_info import ( + MODEL_PROVIDER_EXTRAS, + SANDBOX_EXTRAS, + STANDALONE_EXTRAS, +) + +logger = logging.getLogger(__name__) + +_DEPENDENCY_BODY_MAX_HEIGHT = 16 +"""Upper bound (in cells) for the scrollable dependency list. + +Keep in sync with the `max-height: 16` in the `#launch-dependencies-body` CSS; +Textual CSS cannot reference Python constants, so the static cap and the +runtime `_fit_dependencies_body` clamp must agree. +""" +_DEPENDENCY_BODY_MIN_HEIGHT = 1 +"""Floor (in cells) so the list never collapses to zero on tiny terminals.""" + + +def _normalize_name(value: str) -> str: + """Normalize submitted onboarding names for display. + + Args: + value: Raw submitted name. + + Returns: + The stripped name, title-cased when it was entered in lowercase. + """ + name = value.strip() + if name.islower(): + return name.title() + return name + + +class LaunchGoalCriteriaPreferenceScreen(ModalScreen[bool]): + """One-time choice for how Auto mode handles generated goal criteria.""" + + AUTO_FOCUS = "#launch-goal-criteria-options" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "review", "Review", show=False, priority=True), + Binding("tab", "cursor_down", "Next", show=False, priority=True), + Binding("shift+tab", "cursor_up", "Previous", show=False, priority=True), + ] + + CSS = """ + LaunchGoalCriteriaPreferenceScreen { + align: center middle; + } + + LaunchGoalCriteriaPreferenceScreen > Vertical { + width: 72; + max-width: 90%; + height: auto; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + LaunchGoalCriteriaPreferenceScreen .launch-init-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + LaunchGoalCriteriaPreferenceScreen .launch-init-copy { + height: auto; + color: $text; + margin-bottom: 1; + } + + LaunchGoalCriteriaPreferenceScreen OptionList { + height: auto; + max-height: 4; + background: $background; + margin-bottom: 1; + } + + LaunchGoalCriteriaPreferenceScreen .launch-init-note { + height: auto; + color: $text-muted; + margin-bottom: 1; + } + + LaunchGoalCriteriaPreferenceScreen .launch-init-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def __init__( + self, + *, + continue_screen: Screen[Any] | None = None, + on_continue: Callable[[bool], None] | None = None, + on_continue_failed: Callable[[bool], None] | None = None, + ) -> None: + """Initialize the goal criteria preference screen. + + Args: + continue_screen: Optional screen to switch to after choosing. + on_continue: Optional callback invoked with the selected preference + before switching to `continue_screen`. + on_continue_failed: Optional callback invoked when switching to + `continue_screen` fails. + """ + super().__init__() + self._continue_screen = continue_screen + self._on_continue = on_continue + self._on_continue_failed = on_continue_failed + + def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual override + """Compose the preference selector. + + Yields: + Widgets for the prompt and its two choices. + """ + glyphs = get_glyphs() + with Vertical(): + yield Static( + "How should Auto mode handle goal criteria?", + classes="launch-init-title", + ) + yield Static( + "When you create or update a goal, dcode drafts acceptance " + "criteria before starting.", + classes="launch-init-copy", + ) + options = OptionList( + Option("Review before applying (recommended)", id="review"), + Option("Apply automatically in Auto mode", id="auto"), + id="launch-goal-criteria-options", + ) + options.highlighted = 0 + yield options + yield Static( + "You can change this at any time in ~/.deepagents/config.toml " + "or with DEEPAGENTS_CODE_GOAL_AUTO_ACCEPT_CRITERIA.", + classes="launch-init-note", + ) + yield Static( + f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab switch" + f" {glyphs.bullet} Enter select" + f" {glyphs.bullet} Esc review", + classes="launch-init-help", + ) + + def on_mount(self) -> None: + """Apply the ASCII border when needed.""" + if is_ascii_mode(): + container = self.query_one(Vertical) + colors = theme.get_theme_colors(self) + container.styles.border = ("ascii", colors.success) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + """Continue with the selected preference. + + Args: + event: The selected option. + """ + if event.option.id == "auto": + self._finish(True) + elif event.option.id == "review": + self._finish(False) + + def action_review(self) -> None: + """Use the fail-closed review preference and continue.""" + self._finish(False) + + def action_cancel(self) -> None: + """Treat the global cancel action as the safe review choice.""" + self.action_review() + + def action_cursor_down(self) -> None: + """Move the option cursor down.""" + self.query_one(OptionList).action_cursor_down() + + def action_cursor_up(self) -> None: + """Move the option cursor up.""" + self.query_one(OptionList).action_cursor_up() + + def _finish(self, auto_accept: bool) -> None: + """Resolve the choice before replacing or dismissing this screen.""" + if self._on_continue is not None: + self._on_continue(auto_accept) + if self._continue_screen is None: + self.dismiss(auto_accept) + return + try: + self.app.switch_screen(self._continue_screen) + except ScreenStackError: + logger.warning( + "Could not switch from goal preference screen; dismissing instead", + exc_info=True, + ) + if self._on_continue_failed is not None: + self._on_continue_failed(auto_accept) + self.dismiss(auto_accept) + + +class LaunchNameScreen(ModalScreen[str | None]): + """Onboarding screen that asks for the user's name. + + Dismissal values: + + - Non-empty stripped/title-cased name when the user submits one. + - `""` when the user submits an empty input (continue, but skip name memory). + - `None` when the user dismisses with Escape (skip remaining onboarding). + """ + + AUTO_FOCUS = "#launch-name-input" + + def __init__( + self, + *, + continue_screen: Screen[Any] | None = None, + on_continue: Callable[[str], None] | None = None, + on_continue_failed: Callable[[str], None] | None = None, + on_skip: Callable[[], None] | None = None, + ) -> None: + """Initialize the name-entry screen. + + Args: + continue_screen: Optional screen to switch to after submitting a name. + on_continue: Optional callback invoked with the submitted name before + switching to `continue_screen`. + on_continue_failed: Optional callback invoked with the submitted + name when switching to `continue_screen` fails. + on_skip: Optional callback invoked before Escape dismisses the screen. + """ + super().__init__() + self._continue_screen = continue_screen + self._on_continue = on_continue + self._on_continue_failed = on_continue_failed + self._on_skip = on_skip + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "skip", "Skip", show=False, priority=True), + ] + + CSS = """ + LaunchNameScreen { + align: center middle; + } + + LaunchNameScreen > Vertical { + width: 64; + max-width: 90%; + height: auto; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + LaunchNameScreen .launch-init-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + LaunchNameScreen .launch-init-copy { + height: auto; + color: $text; + margin-bottom: 1; + } + + LaunchNameScreen #launch-name-input { + margin-bottom: 1; + border: solid $primary-lighten-2; + } + + LaunchNameScreen #launch-name-input:focus { + border: solid $primary; + } + + LaunchNameScreen .launch-init-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual override + """Compose the name-entry screen. + + Yields: + Widgets for the modal content. + """ + with Vertical(): + yield Static("Welcome to Deep Agents Code", classes="launch-init-title") + yield Static( + Content.assemble("What should Deep Agents call you?"), + classes="launch-init-copy", + ) + yield Input( + placeholder="Your name (optional)", + id="launch-name-input", + ) + yield Static( + "Enter to continue", + classes="launch-init-help", + ) + + def on_mount(self) -> None: + """Apply ASCII border when needed.""" + if is_ascii_mode(): + container = self.query_one(Vertical) + colors = theme.get_theme_colors(self) + container.styles.border = ("ascii", colors.success) + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Continue with the submitted name. + + Args: + event: The input submission event. + """ + event.stop() + value = _normalize_name(event.value) + if self._continue_screen is None: + self.dismiss(value) + return + if self._on_continue is not None: + self._on_continue(value) + try: + self.app.switch_screen(self._continue_screen) + except ScreenStackError: + logger.warning( + "Could not switch from launch name screen; dismissing instead", + exc_info=True, + ) + if self._on_continue_failed is not None: + self._on_continue_failed(value) + self.dismiss(value) + + def action_skip(self) -> None: + """Skip the onboarding sequence.""" + if self._on_skip is not None: + self._on_skip() + self.dismiss(None) + + def action_cancel(self) -> None: + """Alias for `action_skip` invoked by the global Esc binding. + + Textual's `Screen.action_cancel` is the conventional cancel hook used + by the app-level Esc handler in `DeepAgentsApp`; routing it to + `action_skip` keeps the screen-specific binding and the global path + in sync. + """ + self.action_skip() + + +class LaunchDependenciesScreen(ModalScreen[bool | None]): + """Onboarding screen that summarizes installed optional integrations.""" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "continue", "Continue", show=False, priority=True), + Binding("escape", "skip", "Skip", show=False, priority=True), + ] + + CSS = """ + LaunchDependenciesScreen { + align: center middle; + } + + LaunchDependenciesScreen > Vertical { + width: 76; + max-width: 90%; + height: auto; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + LaunchDependenciesScreen .launch-init-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + LaunchDependenciesScreen .launch-init-copy { + height: auto; + color: $text-muted; + margin-bottom: 1; + } + + LaunchDependenciesScreen #launch-dependencies-body { + height: auto; + max-height: 16; /* keep in sync with `_DEPENDENCY_BODY_MAX_HEIGHT` */ + scrollbar-gutter: stable; + margin-bottom: 1; + } + + LaunchDependenciesScreen .launch-dependencies-section { + height: auto; + color: $text; + } + + LaunchDependenciesScreen .launch-dependencies-section.is-available { + margin-top: 1; + } + + LaunchDependenciesScreen .launch-init-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def __init__( + self, + statuses: tuple[ExtraDependencyStatus, ...] | None = None, + *, + continue_screen: Screen[Any] | None = None, + on_done: Callable[[bool | None], None] | None = None, + ) -> None: + """Initialize the dependency summary screen. + + Args: + statuses: Optional dependency statuses to display. When omitted, + the status is read from the installed package metadata. + continue_screen: Optional screen to switch to when the user + continues, avoiding an intermediate base-screen frame. + on_done: Optional callback invoked when this screen finishes without + switching to `continue_screen`. + """ + super().__init__() + if statuses is None: + from deepagents_code.extras_info import get_optional_dependency_status + + statuses = get_optional_dependency_status() + self._statuses = statuses + self._continue_screen = continue_screen + self._on_done = on_done + + def compose(self) -> ComposeResult: + """Compose the dependency summary screen. + + Yields: + Widgets for the modal content. + """ + glyphs = get_glyphs() + with Vertical(): + yield Static("Installed Integrations", classes="launch-init-title") + yield Static( + "Model providers and sandboxes are enabled by optional add-on " + "packages. The ones already present in your environment are " + "ready to use now.", + classes="launch-init-copy", + ) + if self._statuses: + with VerticalScroll(id="launch-dependencies-body"): + yield Static( + self._format_section( + title="Ready now", + ready=True, + glyph=glyphs.checkmark, + empty="Nothing installed yet — add one below.", + ), + classes="launch-dependencies-section", + ) + yield Static( + self._format_section( + title="Available to add", + ready=False, + glyph=glyphs.circle_empty, + empty="All bundled integrations are installed.", + ), + classes="launch-dependencies-section is-available", + ) + yield Static( + "Pick a model on the next screen and its provider installs " + "automatically. Add more anytime with `/install`.", + classes="launch-init-copy", + ) + else: + # `get_optional_dependency_status` returns an empty tuple when + # `importlib.metadata` cannot find the distribution (editable + # install renamed, dev checkout without dist-info). Render a + # single explanatory line rather than empty status sections. + yield Static( + "Could not read installed dependency metadata. Reinstall " + "with `/install ` to populate.", + classes="launch-dependencies-section", + ) + yield Static( + "Enter to continue", + classes="launch-init-help", + ) + + def on_mount(self) -> None: + """Apply ASCII border when needed.""" + if is_ascii_mode(): + container = self.query_one(Vertical) + colors = theme.get_theme_colors(self) + container.styles.border = ("ascii", colors.success) + self.call_after_refresh(self._fit_dependencies_body) + + def on_resize(self) -> None: + """Refit the scroll body when terminal dimensions change.""" + self.call_after_refresh(self._fit_dependencies_body) + + def _fit_dependencies_body(self) -> None: + """Cap the dependency list height so modal controls stay in view.""" + # `#launch-dependencies-body` is only composed when statuses are + # non-empty (see `compose`); skip the structural always-empty case + # here. The `NoMatches` catch below still handles the teardown race. + if not self._statuses: + return + + try: + container = self.query_one(Vertical) + body = self.query_one("#launch-dependencies-body", VerticalScroll) + except NoMatches: + # This runs deferred via `call_after_refresh`; the screen may have + # been popped or recomposed before it fires (e.g. a resize racing + # dismissal). Sizing is cosmetic, so skip quietly but leave a + # breadcrumb rather than letting it surface in the event loop. + logger.debug( + "Skipping dependency-body refit; widgets not mounted", + exc_info=True, + ) + return + non_body_height = max(0, container.region.height - body.region.height) + available_height = self.size.height - non_body_height + max_height = max( + _DEPENDENCY_BODY_MIN_HEIGHT, + min(_DEPENDENCY_BODY_MAX_HEIGHT, available_height), + ) + current = body.styles.max_height + if current is not None and current.cells == max_height: + return + body.styles.max_height = max_height + + def _format_section( + self, *, title: str, ready: bool, glyph: str, empty: str + ) -> str: + """Format one status section as per-extra rows grouped by category. + + Every matching extra is listed (no truncation); each category that + has matches is shown under a sub-header, and the section title carries + a total count. When nothing matches, the `empty` placeholder is shown + in place of the sub-headers. + + Args: + title: Section title. + ready: Whether to include ready or not-yet-ready extras. + glyph: Status glyph rendered before each extra name. + empty: Placeholder line shown when the section has no extras. + + Returns: + Multi-line section text. + """ + groups: tuple[tuple[str, frozenset[str]], ...] = ( + ("Model providers", MODEL_PROVIDER_EXTRAS), + ("Sandboxes", SANDBOX_EXTRAS), + ("Other", STANDALONE_EXTRAS), + ) + grouped = [ + (label, self._extra_names(names, ready=ready)) for label, names in groups + ] + total = sum(len(extras) for _, extras in grouped) + lines = [f"{title} ({total})"] + if total == 0: + lines.append(f" {empty}") + return "\n".join(lines) + for label, extras in grouped: + if not extras: + continue + lines.append(f" {label}") + lines.extend(f" {glyph} {name}" for name in extras) + return "\n".join(lines) + + def _extra_names(self, names: frozenset[str], *, ready: bool) -> list[str]: + """Return sorted extra names matching a category and readiness state. + + Args: + names: Category names to include. + ready: Desired readiness state. + + Returns: + Sorted matching extra names. + """ + return sorted( + status.name + for status in self._statuses + if status.name in names and status.ready is ready + ) + + def action_continue(self) -> None: + """Continue onboarding.""" + if self._continue_screen is not None: + try: + self.app.switch_screen(self._continue_screen) + except ScreenStackError: + # Stack was torn down (app exiting, screen popped under us). + # Fall back to dismissal so the launch-init task can finish + # rather than leaving the user staring at this modal. + logger.warning( + "Could not switch to continue screen; dismissing instead", + exc_info=True, + ) + self.app.notify( + "Could not open the model selector. Use /model to pick " + "one when you're ready.", + severity="warning", + markup=False, + ) + self._finish(True) + return + self._finish(True) + + def action_skip(self) -> None: + """Skip the remaining onboarding sequence.""" + self._finish(None) + + def _finish(self, result: bool | None) -> None: + """Resolve the screen-specific callback before dismissing.""" + if self._on_done is not None: + self._on_done(result) + self.dismiss(result) + + def action_cancel(self) -> None: + """See `LaunchNameScreen.action_cancel`.""" + self.action_skip() diff --git a/libs/code/deepagents_code/tui/widgets/loading.py b/libs/code/deepagents_code/tui/widgets/loading.py new file mode 100644 index 0000000000..8fbe357de8 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/loading.py @@ -0,0 +1,227 @@ +"""Loading widget with animated spinner for agent activity.""" + +from __future__ import annotations + +from time import time +from typing import TYPE_CHECKING + +from textual.containers import Horizontal +from textual.content import Content +from textual.widgets import Static + +from deepagents_code.config import get_glyphs +from deepagents_code.formatting import format_duration + +if TYPE_CHECKING: + from textual.app import ComposeResult + from textual.await_remove import AwaitRemove + from textual.timer import Timer + + +class Spinner: + """Animated spinner using charset-appropriate frames.""" + + def __init__(self) -> None: + """Initialize spinner.""" + self._position = 0 + + @property + def frames(self) -> tuple[str, ...]: + """Spinner frames from glyphs config.""" + return get_glyphs().spinner_frames + + def next_frame(self) -> str: + """Get next animation frame. + + Returns: + The next spinner character in the animation sequence. + """ + frames = self.frames + frame = frames[self._position] + self._position = (self._position + 1) % len(frames) + return frame + + def current_frame(self) -> str: + """Get current frame without advancing. + + Returns: + The current spinner character. + """ + return self.frames[self._position] + + +class LoadingWidget(Static): + """Animated loading indicator with status text and elapsed time. + + Displays: Thinking... (3s, esc to interrupt) + """ + + DEFAULT_CSS = """ + LoadingWidget { + height: auto; + padding: 0 1; + margin: 0 0 1 0; + } + + LoadingWidget .loading-container { + height: auto; + width: 100%; + } + + LoadingWidget .loading-spinner { + width: auto; + color: $primary; + } + + LoadingWidget .loading-status { + width: auto; + color: $primary; + } + + LoadingWidget .loading-hint { + width: auto; + color: $text-muted; + margin-left: 1; + } + """ + + def __init__(self, status: str = "Thinking") -> None: + """Initialize loading widget. + + Args: + status: Initial status text to display + """ + super().__init__() + self._status = status + self._spinner = Spinner() + self._start_time: float | None = None + self._spinner_widget: Static | None = None + self._status_widget: Static | None = None + self._hint_widget: Static | None = None + self._animation_timer: Timer | None = None + self._paused = False + self._paused_elapsed: float = 0.0 + + def compose(self) -> ComposeResult: + """Compose the loading widget layout. + + Yields: + Widgets for spinner, status text, and hint. + """ + with Horizontal(classes="loading-container"): + self._spinner_widget = Static( + self._spinner.current_frame(), classes="loading-spinner" + ) + yield self._spinner_widget + + self._status_widget = Static( + f" {self._status}... ", classes="loading-status" + ) + yield self._status_widget + + self._hint_widget = Static("(0s, esc to interrupt)", classes="loading-hint") + yield self._hint_widget + + def on_mount(self) -> None: + """Start animation on mount. + + Preserves `_start_time` when the widget is remounted (e.g., after + being removed and re-added for repositioning) so the elapsed-time + counter doesn't reset. Repositioning via `move_child` avoids the + remount path entirely, but this guard keeps the behavior correct + if any caller ever falls back to remove + mount. + """ + if self._start_time is None: + self._start_time = time() + self._animation_timer = self.set_interval(0.1, self._update_animation) + + def on_unmount(self) -> None: + """Stop the animation timer when the widget leaves the DOM.""" + self._stop_timer() + + def remove(self) -> AwaitRemove: + """Stop animation before delegating DOM removal to Textual. + + Returns: + Awaitable that completes once the widget is removed from the DOM. + """ + self._stop_timer() + return super().remove() + + def _stop_timer(self) -> None: + """Stop the animation timer if it is running.""" + if self._animation_timer is not None: + self._animation_timer.stop() + self._animation_timer = None + + def _update_animation(self) -> None: + """Update spinner and elapsed time.""" + if self._paused: + return + + if self._spinner_widget: + frame = self._spinner.next_frame() + self._spinner_widget.update(frame) + + if self._hint_widget and self._start_time is not None: + elapsed = int(time() - self._start_time) + self._hint_widget.update(f"({format_duration(elapsed)}, esc to interrupt)") + + def set_status(self, status: str) -> None: + """Update the status text. + + Args: + status: New status text + """ + self._status = status + if self._status_widget: + self._status_widget.update(f" {self._status}... ") + + def pause(self, status: str = "Awaiting decision") -> None: + """Pause the animation and update status. + + Args: + status: Status to show while paused + """ + self._paused = True + if self._start_time is not None: + self._paused_elapsed = time() - self._start_time + self._status = status + if self._status_widget: + self._status_widget.update(f" {status}... ") + if self._hint_widget: + # Display whole seconds to match the live counter in + # `_update_animation`; `_paused_elapsed` stays a float only so + # `resume()` can rebase `_start_time` with sub-second precision. + self._hint_widget.update( + f"(paused at {format_duration(int(self._paused_elapsed))})" + ) + if self._spinner_widget: + self._spinner_widget.update(Content.styled(get_glyphs().pause, "dim")) + + def resume(self) -> None: + """Resume the animation, excluding the paused interval from elapsed time. + + Rebases `_start_time` forward by the paused duration so the elapsed-time + counter continues from where it paused rather than counting the wait. + + No-op when not currently paused. This method is wired both as a + `Future.add_done_callback` and as a self-healing net in the app's + `_set_spinner`, so it can fire on a widget that was never paused (e.g. + one created to replace the paused spinner mid-approval). Returning early + there avoids rebasing the start time or clobbering that widget's status. + """ + if not self._paused: + # Load-bearing guard: resuming a never-paused (or replacement) + # widget must not rebase `_start_time` with a stale + # `_paused_elapsed`, which would silently jump its timer. + return + self._start_time = time() - self._paused_elapsed + self._paused = False + self._status = "Thinking" + if self._status_widget: + self._status_widget.update(f" {self._status}... ") + + def stop(self) -> None: + """Stop the animation (widget will be removed by caller).""" + self._stop_timer() diff --git a/libs/code/deepagents_code/tui/widgets/mcp_login.py b/libs/code/deepagents_code/tui/widgets/mcp_login.py new file mode 100644 index 0000000000..ebcb61ae2d --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/mcp_login.py @@ -0,0 +1,539 @@ +"""In-TUI MCP OAuth login modal. + +`MCPLoginScreen` is both a Textual `ModalScreen` and an implementation of +`OAuthInteraction`. The login worker awaits its interaction methods while +the user sees and acts on the modal's widgets — authorize URLs become +clickable links, paste-back callback URLs go through an inline input row, +device-code instructions render inline, and the modal closes itself on +success. + +The screen runs on the Textual event loop (same loop as the worker), so +methods called from the worker can `await` modal-bound futures directly +without `app.call_from_thread`. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, ClassVar, Literal + +from textual.binding import Binding, BindingType +from textual.containers import Vertical, VerticalScroll +from textual.content import Content +from textual.events import ( + Click, # noqa: TC002 - needed at runtime for Textual event dispatch + MouseMove, # noqa: TC002 - needed at runtime for Textual event dispatch +) +from textual.screen import ModalScreen +from textual.style import Style as TStyle +from textual.widgets import Input, Static + +from deepagents_code import theme +from deepagents_code.config import get_glyphs, is_ascii_mode +from deepagents_code.tui.widgets._links import open_style_link +from deepagents_code.tui.widgets.loading import Spinner + +if TYPE_CHECKING: + from textual.app import ComposeResult + from textual.timer import Timer + + +LoginOutcome = Literal["success", "cancelled", "failed"] +"""Discriminator returned by the modal when it dismisses.""" + + +class MCPLoginCancelledError(RuntimeError): + """Raised by `MCPLoginScreen.action_cancel` when the user cancels the flow.""" + + +_PROMPT_CALLBACK = "Paste the full callback URL after approving in the browser:" + + +class MCPLoginScreen(ModalScreen[LoginOutcome]): + """Modal that renders the OAuth login flow and collects user input. + + Implements the `OAuthInteraction` Protocol structurally so a + `mcp_auth.login(..., ui=screen)` call drives the same modal. Each + interaction method updates a status line, a clickable link area, and + an inline input prompt for the callback URL. Slack workspace selection + is deferred to Slack's browser page rather than prompted inline. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "toggle_authorize_url", "Toggle URL", show=False), + Binding("escape", "cancel", "Cancel", show=False, priority=True), + ] + """Esc unblocks the worker; the worker performs the actual shutdown. + + Cancellation completes any outstanding input future with + `MCPLoginCancelledError`. The worker (`_run_mcp_login_worker`) sees + that exception, calls `finish(success=False)`, and tears down the + OAuth handshake. Doing the teardown here would race the worker. + """ + + CSS = """ + MCPLoginScreen { + align: center middle; + } + + MCPLoginScreen > Vertical { + width: 80; + max-width: 92%; + height: auto; + max-height: 85%; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + MCPLoginScreen .ml-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + MCPLoginScreen .ml-status { + height: auto; + color: $text; + margin-bottom: 1; + } + + MCPLoginScreen .ml-link { + height: auto; + color: $accent; + margin-bottom: 1; + } + + MCPLoginScreen .ml-history { + height: auto; + max-height: 8; + background: $surface-lighten-1; + margin-bottom: 1; + } + + MCPLoginScreen .ml-history-line { + height: auto; + color: $text-muted; + padding: 0 1; + } + + MCPLoginScreen .ml-prompt { + height: 1; + color: $text; + } + + MCPLoginScreen #ml-input { + margin-bottom: 1; + border: solid $primary-lighten-2; + } + + MCPLoginScreen #ml-input:focus { + border: solid $primary; + } + + MCPLoginScreen .ml-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def __init__(self, server_name: str) -> None: + """Initialize a login modal for `server_name`. + + Args: + server_name: MCP server name shown in the modal title. + """ + super().__init__() + self._server_name = server_name + self._status = f"Starting OAuth login for {server_name}…" + self._title_widget: Static | None = None + self._status_widget: Static | None = None + self._link_widget: Static | None = None + self._history_widget: VerticalScroll | None = None + self._prompt_widget: Static | None = None + self._input_widget: Input | None = None + self._help_widget: Static | None = None + self._spinner = Spinner() + self._spinner_timer: Timer | None = None + self._authorize_url: str | None = None + self._authorize_url_opened_in_browser = False + self._authorize_url_expanded = False + self._waiting_for_authorization = False + + # Each prompt method blocks on a fresh Future; cancellation completes + # it with MCPLoginCancelledError so the worker unblocks rather than + # hanging on a dismissed modal. + self._pending_input: asyncio.Future[str] | None = None + self._cancelled = False + self._done = False + self._outcome: LoginOutcome | None = None + self._last_history_line: str | None = None + + @property + def is_done(self) -> bool: + """`True` once the modal has been told to finish (success or failure). + + Public accessor for callers that need to coordinate teardown from + outside the screen, e.g. the worker's `BaseException` branch that + unblocks dismiss without re-finishing. + """ + return self._done + + def compose(self) -> ComposeResult: + """Compose the modal body inside a `Vertical` container. + + Yields: + Title, status, optional link, history, prompt label, input, + and help footer widgets, all parented inside a `Vertical`. + """ + with Vertical(): + self._title_widget = Static( + Content.from_markup("MCP login: $name", name=self._server_name), + classes="ml-title", + markup=False, + ) + yield self._title_widget + self._status_widget = Static( + self._status, classes="ml-status", markup=False + ) + yield self._status_widget + self._link_widget = Static("", classes="ml-link") + self._link_widget.display = False + yield self._link_widget + self._history_widget = VerticalScroll(classes="ml-history") + self._history_widget.display = False + yield self._history_widget + self._prompt_widget = Static("", classes="ml-prompt", markup=False) + self._prompt_widget.display = False + yield self._prompt_widget + self._input_widget = Input(id="ml-input") + self._input_widget.display = False + yield self._input_widget + self._help_widget = Static("Esc to cancel", classes="ml-help") + yield self._help_widget + + def on_mount(self) -> None: + """Apply ASCII border when configured and start the spinner ticker.""" + if is_ascii_mode(): + container = self.query_one(Vertical) + colors = theme.get_theme_colors(self) + container.styles.border = ("ascii", colors.primary) + self._spinner_timer = self.set_interval(0.1, self._tick_spinner) + + def on_click(self, event: Click) -> None: + """Open style links, or expand/collapse the manual authorize URL.""" + if ( + event.widget is self._link_widget + and self._authorize_url is not None + and self._authorize_url_opened_in_browser + and not event.style.link + ): + self._toggle_authorize_url() + event.stop() + return + open_style_link(event) + + def on_mouse_move(self, event: MouseMove) -> None: + """Show a pointer over links and the manual URL disclosure row.""" + self.styles.pointer = ( + "pointer" + if event.style.link or event.widget is self._link_widget + else "default" + ) + + def on_leave(self) -> None: + """Reset the pointer shape when the mouse leaves the modal.""" + self.styles.pointer = "default" + + # ------------------------------------------------------------------ + # OAuthInteraction implementation. + # ------------------------------------------------------------------ + + async def show_authorize_url(self, url: str, *, opened_in_browser: bool) -> None: + """Render browser-open status and only reveal the URL when needed.""" + self._authorize_url = url + self._authorize_url_opened_in_browser = opened_in_browser + self._authorize_url_expanded = not opened_in_browser + self._waiting_for_authorization = opened_in_browser + if opened_in_browser: + self._render_authorization_wait_status(self._spinner.next_frame()) + else: + self._set_status( + f"Open the authorization URL manually to connect {self._server_name}.", + ) + self._render_authorize_url() + + async def request_callback_url(self) -> str: + """Wait for the user to paste back the OAuth callback URL. + + Returns: + The trimmed callback URL. + """ + return await self._await_input(_PROMPT_CALLBACK) + + async def show_device_code( + self, + *, + verification_uri: str, + user_code: str, + expires_in: int, + ) -> None: + """Render RFC 8628 device-code instructions inline.""" + self._waiting_for_authorization = False + self._set_status( + f"Visit the URL below and enter the code (expires in {expires_in}s):", + ) + if self._link_widget is not None: + self._link_widget.display = True + self._link_widget.update( + Content.assemble( + ("Verification URL: ", "bold"), + (verification_uri, TStyle(link=verification_uri, underline=True)), + ("\nUser code: ", "bold"), + (user_code, "bold"), + ), + ) + self._append_history( + f"Device code: visit {verification_uri} and enter {user_code}", + ) + + async def show_success(self, message: str) -> None: + """Render a success status line without leaving OAuth fallback UI behind.""" + self._waiting_for_authorization = False + self._hide_authorize_url() + self._set_status(message) + + async def show_notice(self, message: str) -> None: + """Append a progress notice without disrupting the active prompt.""" + self._append_history(message) + + async def show_error(self, message: str) -> None: + """Render a fatal (flow-ending) error status line and history entry.""" + self._waiting_for_authorization = False + self._set_status(message) + self._append_history(message) + + # ------------------------------------------------------------------ + # Internal helpers. + # ------------------------------------------------------------------ + + def _hide_authorize_url(self) -> None: + """Hide any OAuth authorization URL or fallback affordance.""" + self._authorize_url = None + self._authorize_url_opened_in_browser = False + self._authorize_url_expanded = False + self._waiting_for_authorization = False + self._render_authorize_url() + self._set_help("Esc to cancel") + + def _toggle_authorize_url(self) -> None: + """Toggle the manual authorize URL when the fallback affordance is active.""" + if ( + self._pending_input is not None + or self._authorize_url is None + or not self._authorize_url_opened_in_browser + ): + return + self._authorize_url_expanded = not self._authorize_url_expanded + self._render_authorize_url() + + def _render_authorize_url(self) -> None: + """Render the manual authorize URL affordance.""" + if self._link_widget is None: + return + url = self._authorize_url + if url is None: + self._link_widget.display = False + self._link_widget.update("") + return + self._link_widget.display = True + glyphs = get_glyphs() + if self._authorize_url_opened_in_browser and not self._authorize_url_expanded: + self._link_widget.update( + Content.assemble( + ("Having trouble? Show manual authorization URL ", "dim"), + (glyphs.cursor, "dim"), + ), + ) + self._set_help("Enter to show URL · Esc to cancel") + return + prefix = ( + f"Having trouble? Hide manual authorization URL {glyphs.arrow_down}\n" + if self._authorize_url_opened_in_browser + else "Authorization URL:\n" + ) + self._link_widget.update( + Content.assemble( + (prefix, "bold"), + (url, TStyle(link=url, underline=True)), + ), + ) + if self._authorize_url_opened_in_browser: + self._set_help("Enter to hide URL · Esc to cancel") + else: + self._set_help("Esc to cancel") + + def _render_authorization_wait_status(self, frame: str) -> None: + """Render the browser-opened waiting state with an animated status line.""" + self._set_status( + f"We opened your browser to connect {self._server_name}.\n" + "Complete authorization there to continue.\n\n" + f"Status: {frame} Waiting…", + ) + + def _set_status(self, message: str) -> None: + """Update the top status line.""" + self._status = message + if self._status_widget is not None: + self._status_widget.update(Content.from_markup("$msg", msg=message)) + + def _set_help(self, message: str) -> None: + """Update the footer help text.""" + if self._help_widget is not None: + self._help_widget.update(message) + + def _hide_history(self) -> None: + """Hide the history pane for clean terminal states.""" + if self._history_widget is not None: + self._history_widget.display = False + self._last_history_line = None + + def _append_history(self, line: str) -> None: + """Append a line to the scrolling history pane.""" + if self._history_widget is None or line == self._last_history_line: + return + self._last_history_line = line + self._history_widget.display = True + self._history_widget.mount( + Static(line, classes="ml-history-line", markup=False) + ) + self._history_widget.scroll_end(animate=False) + + async def _await_input(self, prompt: str) -> str: + """Show `prompt`, wait for `Enter`, and return the typed value. + + Returns: + The raw input value the user submitted. + + Raises: + MCPLoginCancelledError: When the modal is cancelled before or + during submission. + RuntimeError: When a concurrent prompt is already active. + """ + if self._cancelled: + msg = "MCP login was cancelled before the prompt could be shown." + raise MCPLoginCancelledError(msg) + if self._pending_input is not None: + msg = ( + "MCP login modal cannot have two concurrent input prompts; " + "the previous prompt was not resolved." + ) + raise RuntimeError(msg) + + loop = asyncio.get_running_loop() + future: asyncio.Future[str] = loop.create_future() + self._pending_input = future + if self._prompt_widget is not None: + self._prompt_widget.display = True + self._prompt_widget.update(prompt) + if self._input_widget is not None: + self._input_widget.value = "" + self._input_widget.display = True + self._input_widget.focus() + try: + return await future + finally: + self._pending_input = None + if self._input_widget is not None: + self._input_widget.display = False + if self._prompt_widget is not None: + self._prompt_widget.display = False + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Resolve the active prompt with the submitted value.""" + if event.input.id != "ml-input": + return + future = self._pending_input + if future is not None and not future.done(): + future.set_result(event.value) + + def action_toggle_authorize_url(self) -> None: + """Toggle the manual authorize URL fallback via Enter.""" + self._toggle_authorize_url() + + def action_cancel(self) -> None: + """Cancel the login flow. + + Sets a cancelled flag, completes any outstanding prompt with + `MCPLoginCancelledError` so the worker unblocks, and dismisses + the modal with the `cancelled` outcome. + """ + if self._done: + return + self._cancelled = True + self._done = True + self._outcome = "cancelled" + future = self._pending_input + if future is not None and not future.done(): + future.set_exception( + MCPLoginCancelledError("MCP login was cancelled by the user.") + ) + self._stop_spinner_timer() + self.dismiss("cancelled") + + def finish(self, *, success: bool, message: str | None = None) -> None: + """Close the modal from the worker, reporting the final outcome. + + Dismiss is deferred by 0.6s so the user sees the final status + before the modal disappears. + + Args: + success: `True` when login succeeded; drives the dismiss value. + message: Optional final status line shown before close. + """ + if self._done: + return + self._done = True + self._outcome = "success" if success else "failed" + self._stop_spinner_timer() + self._waiting_for_authorization = False + if success: + self._hide_authorize_url() + self._hide_history() + if message is not None: + self._set_status(message) + if not success: + self._append_history(message) + glyphs = get_glyphs() + marker = glyphs.checkmark if success else glyphs.error + if self._title_widget is not None: + self._title_widget.update( + Content.from_markup( + "MCP login: $name $marker", + name=self._server_name, + marker=marker, + ) + ) + + def _deferred_dismiss() -> None: + # Must be a def (not a lambda): Textual's `_invoke` auto-awaits + # any awaitable return, and `dismiss()` returns `AwaitComplete`; + # awaiting it inside the screen's own message pump raises + # `ScreenError`. A `None`-returning def discards the awaitable. + self.dismiss(self._outcome or "failed") + + self.set_timer(0.6, _deferred_dismiss) + + def _tick_spinner(self) -> None: + """Advance the status-line spinner while waiting for browser auth.""" + if self._done or not self._waiting_for_authorization: + return + self._render_authorization_wait_status(self._spinner.next_frame()) + + def _stop_spinner_timer(self) -> None: + if self._spinner_timer is not None: + self._spinner_timer.stop() + self._spinner_timer = None diff --git a/libs/code/deepagents_code/tui/widgets/mcp_reconnect.py b/libs/code/deepagents_code/tui/widgets/mcp_reconnect.py new file mode 100644 index 0000000000..a452d7aab3 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/mcp_reconnect.py @@ -0,0 +1,297 @@ +"""Confirmation modals for MCP changes that need a server restart. + +Restarting the LangGraph server is required for newly minted MCP tokens +and for `/mcp` disable/enable toggles to take effect, but auto-restarting +interrupts users who want to make several MCP changes back-to-back. The +two `_ReconnectPromptScreen` subclasses let the user choose between +restarting now and deferring until later. + +`MCPReconnectForceConfirmScreen` is the exception: it guards +`/mcp reconnect --force` when nothing is queued, so its Esc cancels the +restart outright rather than deferring it. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal + +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.content import Content +from textual.screen import ModalScreen +from textual.widgets import Static + +from deepagents_code.config import get_glyphs + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + from textual.app import ComposeResult + + +ReconnectChoice = Literal["reconnect", "later"] +"""Outcome of the prompt: restart the server now or keep the current one. + +Callers must also handle `None`, which Textual passes when a screen is +dismissed programmatically rather than by a user keypress. That is not a +choice, and callers deliberately stay quiet for it rather than narrating +an action the user did not take. +""" + + +class _ReconnectPromptScreen(ModalScreen[ReconnectChoice]): + """Shared base for the reconnect-or-defer MCP modals. + + Subclasses supply only their title and body copy; the base owns the + bindings, layout, styling, and the `"reconnect"`/`"later"` dismissal + contract. The `DEFAULT_CSS` type selector matches subclasses because + Textual resolves type selectors against every class name in the MRO. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "reconnect", "Reconnect", show=False, priority=True), + Binding("escape", "later", "Later", show=False, priority=True), + ] + + DEFAULT_CSS = """ + _ReconnectPromptScreen { + align: center middle; + } + + _ReconnectPromptScreen > Vertical { + width: 64; + max-width: 90%; + height: auto; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + _ReconnectPromptScreen .mcp-reconnect-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + _ReconnectPromptScreen .mcp-reconnect-body { + height: auto; + color: $text; + margin-bottom: 1; + } + + _ReconnectPromptScreen .mcp-reconnect-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def __init__(self, *, title: str | Content, body: str | Content) -> None: + """Store the dialog copy for `compose`. + + Args: + title: Bold heading shown at the top of the dialog. + body: Explanatory paragraph beneath the title. + """ + super().__init__() + self._title = title + self._body = body + + def compose(self) -> ComposeResult: + """Compose the confirmation dialog. + + Yields: + Title, body, and help-row widgets parented inside a `Vertical`. + """ + with Vertical(): + yield Static( + self._title, + classes="mcp-reconnect-title", + markup=False, + ) + yield Static( + self._body, + classes="mcp-reconnect-body", + markup=False, + ) + yield Static( + "Enter to reconnect, Esc to defer", + classes="mcp-reconnect-help", + markup=False, + ) + + def action_reconnect(self) -> None: + """Dismiss with `"reconnect"`.""" + self.dismiss("reconnect") + + def action_later(self) -> None: + """Dismiss with `"later"`.""" + self.dismiss("later") + + def action_cancel(self) -> None: + """Alias for `action_later` so the app-level Esc handler defers. + + The app's `action_interrupt` (`escape` binding, `priority=True`) + fires before this screen's own `escape` binding. When the active + screen is a `ModalScreen`, it dispatches to `action_cancel` if + present, else falls through to `dismiss(None)`. Without this + alias, Esc would dismiss with `None`, which the caller treats as + a programmatic dismiss (no toast, no reopen) instead of an + explicit defer. + """ + self.action_later() + + +class MCPReconnectPromptScreen(_ReconnectPromptScreen): + """Modal asking whether to restart the server after an MCP login. + + Dismisses with `"reconnect"` when the user accepts the restart and + `"later"` when the user defers. Esc is treated as "later" so the + user is never forced into a reconnect they did not explicitly choose. + """ + + def __init__(self, server_name: str) -> None: + """Initialize the prompt. + + Args: + server_name: Server whose login just succeeded. + """ + super().__init__( + title=Content.from_markup( + "$check Connected to [bold]$name[/bold]", + check=get_glyphs().checkmark, + name=server_name, + ), + body="Reconnect to load new tools.", + ) + + +class MCPDisableReconnectPromptScreen(_ReconnectPromptScreen): + """Modal asking whether to reconnect after `/mcp` disable/enable toggles. + + Shown when the user closes the `/mcp` viewer with pending `F2` + disable-state changes but without pressing `Ctrl+R`, so the toggles + do not silently sit unapplied. Dismisses with `"reconnect"` when the + user accepts the restart and `"later"` when the user defers; Esc is + treated as "later". + """ + + def __init__( + self, + server_names: Sequence[str], + *, + on_choice: Callable[[ReconnectChoice], None] | None = None, + ) -> None: + """Initialize the prompt. + + Args: + server_names: Servers whose disabled state changed and are + waiting on a reconnect. Must be non-empty — the caller + only opens this modal when at least one toggle is + pending, and the body would otherwise name no server. + on_choice: Optional callback invoked for an explicit reconnect + or defer choice before the screen dismisses. This supports + an atomic `switch_screen` transition from the MCP viewer, + whose original result callback is removed by the switch. + """ + super().__init__( + title="Apply MCP server changes?", + body=Content.from_markup( + "Reconnect to apply the changes to $names.", + names=", ".join(server_names), + ), + ) + self._on_choice = on_choice + + def action_reconnect(self) -> None: + """Report and dismiss with `"reconnect"`.""" + if self._on_choice is not None: + self._on_choice("reconnect") + super().action_reconnect() + + def action_later(self) -> None: + """Report and dismiss with `"later"`.""" + if self._on_choice is not None: + self._on_choice("later") + super().action_later() + + +class MCPReconnectForceConfirmScreen(ModalScreen[bool]): + """Confirmation overlay for `/mcp reconnect --force` with no pending login. + + Guards a fat-fingered force-restart when nothing is actually queued. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "confirm", "Confirm", show=False, priority=True), + Binding("escape", "cancel", "Cancel", show=False, priority=True), + ] + + CSS = """ + MCPReconnectForceConfirmScreen { + align: center middle; + } + + MCPReconnectForceConfirmScreen > Vertical { + width: 64; + max-width: 90%; + height: auto; + background: $surface; + border: solid $warning; + padding: 1 2; + } + + MCPReconnectForceConfirmScreen .mcp-reconnect-title { + text-style: bold; + color: $warning; + text-align: center; + margin-bottom: 1; + } + + MCPReconnectForceConfirmScreen .mcp-reconnect-body { + height: auto; + color: $text; + margin-bottom: 1; + } + + MCPReconnectForceConfirmScreen .mcp-reconnect-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual requires an instance method + """Compose the force-reconnect confirmation dialog. + + Yields: + Title, body, and help-row widgets parented inside a `Vertical`. + """ + with Vertical(): + yield Static( + "Force reconnect?", + classes="mcp-reconnect-title", + markup=False, + ) + yield Static( + "No MCP login is queued. Restart will drop the current " + "session and reload all servers.", + classes="mcp-reconnect-body", + markup=False, + ) + yield Static( + "Enter to restart, Esc to cancel", + classes="mcp-reconnect-help", + markup=False, + ) + + def action_confirm(self) -> None: + """Dismiss with `True`.""" + self.dismiss(True) + + def action_cancel(self) -> None: + """Dismiss with `False`.""" + self.dismiss(False) diff --git a/libs/code/deepagents_code/tui/widgets/mcp_viewer.py b/libs/code/deepagents_code/tui/widgets/mcp_viewer.py new file mode 100644 index 0000000000..f7c9462179 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/mcp_viewer.py @@ -0,0 +1,1642 @@ +"""Read-only MCP server and tool viewer modal.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, ClassVar, assert_never + +from textual.binding import Binding, BindingType +from textual.containers import Vertical, VerticalScroll +from textual.content import Content +from textual.events import ( + Click, # noqa: TC002 - needed at runtime for Textual event dispatch +) +from textual.screen import ModalScreen +from textual.widgets import Input, Static + +from deepagents_code.clipboard import copy_text_to_clipboard + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from textual.app import ComposeResult + + from deepagents_code.mcp_tools import MCPServerInfo, MCPServerStatus, MCPToolInfo + +from deepagents_code import theme +from deepagents_code.config import Glyphs, get_glyphs, is_ascii_mode +from deepagents_code.unicode_security import sanitize_control_chars + +logger = logging.getLogger(__name__) + +MCP_VIEWER_RECONNECT_REQUEST = "\x00__mcp_reconnect__" +"""Sentinel returned by `MCPViewerScreen.dismiss` to request a reconnect. + +The null-byte prefix makes the value un-collidable with any valid MCP +server name returned by `MCPServerInfo.name`, so callers can branch on +this exact string without weakening the existing server-name dispatch. +""" + +MCP_RECONNECT_KEY = "ctrl+r" +"""Textual `Binding` key for the in-viewer reconnect action. + +Kept as a module constant so the footer hint and the help-text rendered +in server headers stay in sync with the bound chord. +""" + +MCP_RECONNECT_KEY_LABEL = "Ctrl+R" +"""Display label for `MCP_RECONNECT_KEY`. + +Shown in the footer hint chip and inline header prompts so the user +sees the same chord text the binding will fire on. +""" + + +def _status_glyph(status: MCPServerStatus, glyphs: Glyphs) -> str: + """Return the glyph character for a server `status`. + + Maps onto the existing `Glyphs` set so ASCII fallback is automatic + (`✓ ⚠ ✗` -> `[OK] [!] [X]`). No new glyph definitions needed. + + Args: + status: One of `ok` / `unauthenticated` / `awaiting_reconnect` / + `error` / `disabled`. + glyphs: Active `Glyphs` table (Unicode or ASCII). + + Returns: + The unicode or ASCII glyph character matching `status`. + """ + if status == "ok": + return glyphs.checkmark + if status == "unauthenticated": + return glyphs.warning + if status == "awaiting_reconnect": + return glyphs.circle_empty + if status == "disabled": + return glyphs.pause + if status == "error": + return glyphs.error + assert_never(status) + + +def _status_color(status: MCPServerStatus, colors: theme.ThemeColors) -> str: + """Map a server `status` onto a semantic theme color. + + `ok` -> success (green); `unauthenticated` -> warning (yellow); + `error` -> error (red); `disabled` -> muted. Returning the theme's + hex string lets callers pass the value to `Content.styled()` or + `Content.assemble()` so a theme switch recolors the indicator + without code changes. + + Args: + status: One of `ok` / `unauthenticated` / `awaiting_reconnect` / + `error` / `disabled`. + colors: Active theme palette (typically from `theme.get_theme_colors`). + + Returns: + Hex color string from the active theme. + """ + if status == "ok": + return colors.success + if status == "unauthenticated": + return colors.warning + if status == "awaiting_reconnect": + return colors.warning + if status == "disabled": + return colors.muted + if status == "error": + return colors.error + assert_never(status) + + +def _styled(inner: str, style: str) -> str: + """Wrap a `Content.from_markup` template fragment in `[style]…[/]` if needed. + + Centralizes the `'[' + style + ']…[/]' if style else …` pattern that the + three formatter methods would otherwise repeat five times. + + Args: + inner: Template fragment (may contain `$var` substitutions). + style: Active style string; empty string means render unstyled. + + Returns: + `inner` wrapped in `[style]…[/]` when `style` is truthy, otherwise + `inner` unchanged. + """ + return f"[{style}]{inner}[/]" if style else inner + + +def _format_prop_type(prop_type: Any) -> str: # noqa: ANN401 - JSON Schema field is intentionally untyped + """Render a JSON Schema `type` field for parameter display. + + JSON Schema allows `type` to be a string (`"string"`) or a list of + strings (`["string", "null"]` for nullable types). Plain `str()` on a + list produces an ugly Python repr; we join with `|` instead. + + Args: + prop_type: The raw value of the schema's `type` field. + + Returns: + Display-friendly type string. `"any"` when `prop_type` is missing + or not coercible to a meaningful string. + """ + if prop_type is None: + return "any" + if isinstance(prop_type, list): + parts = [str(t) for t in prop_type if t] + return "|".join(parts) if parts else "any" + return str(prop_type) or "any" + + +_INLINE_TEXT_LIMIT = 200 +"""Max characters for untrusted text rendered inline in a server header. + +Bounds a hostile or buggy server's error/name so it cannot overflow the +single-line header; full text is available in the error-detail modal. +""" + + +def _sort_servers_for_display( + server_info: list[MCPServerInfo], +) -> list[MCPServerInfo]: + """Return `server_info` with attention-needed servers floated to the top. + + Stable sort so the user's config order is preserved within each group. + Surfacing unauthenticated and awaiting-reconnect servers first makes + the next action visible without scrolling on configs with many `ok` + servers. + """ + priority = {"unauthenticated": 0, "awaiting_reconnect": 1} + return sorted(server_info, key=lambda s: priority.get(s.status, 2)) + + +def _visible_tools_for( + server: MCPServerInfo, tokens: list[str] +) -> tuple[MCPToolInfo, ...] | None: + """Return the tools to render for `server` under the active filter. + + Filter matches tool and server *names* only — descriptions, parameter + names, and the transport are deliberately not in the haystack so long + MCP docstrings don't produce spurious matches. A server with zero tools + that matches by name returns `None` so the caller can skip rendering a + stub header followed by the global "No matching tools" empty-state. + + Args: + server: The server whose tools are candidates for display. + tokens: Lower-cased filter tokens — empty means "no filter". + + Returns: + - `server.tools` when the filter is empty or matches the server name + and the server actually has tools. + - A subset tuple when individual tool names match. + - `None` when nothing matches, including the server-name-match case + on a server with zero tools — caller skips the header entirely. + """ + if not tokens: + return server.tools + + if all(token in server.name.lower() for token in tokens): + return server.tools or None + + matching = tuple( + tool + for tool in server.tools + if all(token in tool.name.lower() for token in tokens) + ) + return matching or None + + +class MCPToolItem(Static): + """A selectable tool item in the MCP viewer.""" + + def __init__( + self, + name: str, + description: str, + index: int, + *, + classes: str = "", + input_schema: dict[str, Any] | None = None, + ) -> None: + """Initialize a tool item. + + Args: + name: Tool name. + description: Full tool description. + index: Flat index of this tool in the list. + classes: CSS classes. + input_schema: Raw MCP `inputSchema` dict; rendered as parameters + when the tool is expanded. `None` is treated as "no schema". + """ + self.tool_name = name + self.tool_description = description + self.index = index + self._input_schema = input_schema + self._expanded = False + self._selected = "mcp-tool-selected" in classes + # Pass a placeholder label — `_format_collapsed` reads `self.size`, + # which is only valid after the widget is attached to a screen. + # `on_mount` re-renders with width-aware truncation. + super().__init__(classes=classes) + + def _desc_style(self) -> str: + """Return the markup style tag for the description span. + + Dim text on the `$primary` selection background is unreadable, so + selected rows drop the dim and use bold for tool names only. + """ + return "" if self._selected else "dim" + + def _format_collapsed(self, name: str, description: str) -> Content: + """Build the collapsed (single-line) label. + + Truncates the description with `(...)` if it would overflow + the widget width. + + Args: + name: Tool name. + description: Tool description. + + Returns: + Styled Content label. + """ + if not description: + return Content.from_markup(" $name", name=name) + prefix_len = 2 + len(name) + 1 + avail = self.size.width - prefix_len - 1 if self.size.width else 0 + ellipsis = " (...)" + if avail > 0 and len(description) > avail: + cut = max(0, avail - len(ellipsis)) + desc_text = description[:cut] + ellipsis + else: + desc_text = description + template = f" $name {_styled('$desc', self._desc_style())}" + return Content.from_markup(template, name=name, desc=desc_text) + + def _format_expanded(self, name: str, description: str) -> Content: + """Build the expanded (multi-line) label. + + When `input_schema` carries a non-empty `properties` dict, append + a `Parameters:` block listing each parameter as `name: type` with + `*` for required. + + Args: + name: Tool name. + description: Tool description. + + Returns: + Styled Content label with description and parameters on + following lines. + """ + if description: + style = self._desc_style() + template = f" [bold]$name[/bold]\n {_styled('$desc', style)}" + base = Content.from_markup(template, name=name, desc=description) + else: + base = Content.from_markup(" [bold]$name[/bold]", name=name) + + params = self._format_parameters() + return base.append(params) if params is not None else base + + def _format_parameters(self) -> Content | None: + """Build the parameter list rendered below the description. + + Returns: + A `Content` block with one line per parameter, or `None` when + there is no `input_schema`, the schema is not an object with + non-empty `properties`, or `properties` is malformed. + """ + schema = self._input_schema + if not schema or not isinstance(schema, dict): + return None + properties = schema.get("properties") + if not isinstance(properties, dict) or not properties: + return None + required = schema.get("required") or [] + if not isinstance(required, list): + required = [] + required_set = {str(item) for item in required} + + # Mirror `_desc_style`: empty when this row is selected, so the + # parameter block stays readable on the `$primary` selection + # background (CSS recolors text via `.mcp-tool-selected`). When + # not selected, render dim so the params sit visually below the + # description. + style = self._desc_style() + result = Content.from_markup("\n " + _styled("Parameters:", style)) + line_template = "\n " + _styled("$name: $ptype$star", style) + for prop_name, prop_schema in properties.items(): + prop_type = _format_prop_type( + prop_schema.get("type") if isinstance(prop_schema, dict) else None + ) + star = " *" if str(prop_name) in required_set else "" + # `Content.from_markup` substitution escapes user-supplied + # text, so a parameter named `[bold]foo[/]` cannot inject + # markup tags into the output. Newlines are stripped to + # protect viewport-row math (smart-scroll relies on + # `widget.region.height` matching the rendered row count). + safe_name = str(prop_name).replace("\n", " ").replace("\r", " ")[:80] + line = Content.from_markup( + line_template, + name=safe_name, + ptype=prop_type, + star=star, + ) + result = result.append(line) + return result + + def _rerender(self) -> None: + """Re-render the label with the current selected/expanded state.""" + if self._expanded: + self.update(self._format_expanded(self.tool_name, self.tool_description)) + else: + self.update(self._format_collapsed(self.tool_name, self.tool_description)) + + def set_selected(self, selected: bool) -> None: + """Apply or remove the selected-row styling and re-render the label.""" + if self._selected == selected: + return + self._selected = selected + if selected: + self.add_class("mcp-tool-selected") + else: + self.remove_class("mcp-tool-selected") + self._rerender() + + def toggle_expand(self) -> None: + """Toggle between collapsed and expanded view.""" + self.set_expanded(not self._expanded) + + def set_expanded(self, expanded: bool) -> None: + """Set expansion state explicitly and re-render. + + Single seam through which expansion changes flow, so the screen-level + `Ctrl+E` toggle-all action and the per-row `toggle_expand` share the + same render path. Always re-applies `styles.height` and re-renders so + callers do not need to know whether the state changed — the redundant + write is cheap and avoids drift if `styles.height` was changed + externally (CSS reload, theme switch, programmatic edit). + + Args: + expanded: `True` for expanded multi-line view, `False` for + collapsed single-line view. + """ + self._expanded = expanded + self.styles.height = "auto" if expanded else 1 + self._rerender() + + def on_mount(self) -> None: + """Re-render with correct truncation once width is known. + + Defers via `call_after_refresh` so the first paint happens AFTER + the layout pass. At `on_mount` time `self.size.width` is still + 0, which short-circuits `_format_collapsed`'s `avail > 0` guard + and emits the full description un-truncated for one frame. The + subsequent resize re-render then snaps an ellipsis in, + producing a visible overflow flicker on every mount (initial + open, filter rebuild, F2 toggle rebuild). + """ + self.call_after_refresh(self._rerender) + + def on_resize(self) -> None: + """Re-truncate when widget width changes.""" + if not self._expanded: + self.update(self._format_collapsed(self.tool_name, self.tool_description)) + + def on_click(self, event: Click) -> None: + """Handle click — select and toggle expand via parent screen. + + Args: + event: The click event. + """ + event.stop() + screen = self.screen + if isinstance(screen, MCPViewerScreen): + screen._move_to(self.index) + self.toggle_expand() + + +def _render_server_header( + server: MCPServerInfo, + indicator_glyph: str, + indicator_color: str, + visible_tools: tuple[MCPToolInfo, ...], + glyphs: Glyphs, + *, + selected: bool = False, +) -> Content: + """Build the styled header line for one server. + + Uses `Content.assemble`'s `(text, style)` tuple form so the per-span + color is applied dynamically from the theme palette — `from_markup` + does NOT substitute into bracket tags (`[$icolor]…[/]` would render + as a literal/unknown tag, not a hex color). The tuple form is also + injection-safe: each span's `text` is rendered verbatim, never + markup-parsed, so a server name like `[bold]foo[/]` shows literally + rather than getting styled. `server.error` additionally goes through + `sanitize_control_chars` because MCP servers can return arbitrary error + text including newlines or terminal escapes. + + Args: + server: The server whose header is being rendered. + indicator_glyph: Status glyph (already chosen from `_status_glyph`). + indicator_color: Status color (already chosen from `_status_color`). + visible_tools: Tools that survived the active filter — used only + for the count label. + glyphs: Active `Glyphs` table for the bullet separator. + selected: When `True`, suppresses `dim` styling on secondary spans + so the text stays readable on the `$primary` selection background. + + Returns: + Styled `Content` ready to mount inside a `Static`. + """ + dim_style = "" if selected else "dim" + tool_count = len(visible_tools) + t_label = "tool" if tool_count == 1 else "tools" + if server.status == "ok": + summary = f" {server.transport} {glyphs.bullet} {tool_count} {t_label}" + return Content.assemble( + (f"{indicator_glyph} ", indicator_color), + (server.name, "bold"), + (summary, dim_style), + ) + if server.status == "unauthenticated": + login_hint = " — Enter to log in" + return Content.assemble( + (f"{indicator_glyph} ", indicator_color), + (server.name, "bold"), + (f" {server.transport}", dim_style), + (f" {glyphs.bullet} {server.status}", indicator_color), + (login_hint, dim_style), + ) + if server.status == "awaiting_reconnect": + return Content.assemble( + (f"{indicator_glyph} ", indicator_color), + (server.name, "bold"), + (f" {server.transport}", dim_style), + (f" {glyphs.bullet} ready to load", indicator_color), + (f" — {MCP_RECONNECT_KEY_LABEL} to load tools", dim_style), + ) + if server.status == "error": + return Content.assemble( + (f"{indicator_glyph} ", indicator_color), + (server.name, "bold"), + (f" {server.transport}", dim_style), + (f" {glyphs.bullet} {server.status}", indicator_color), + (" — Enter for details", dim_style), + ) + if server.status == "disabled": + error_text = sanitize_control_chars( + server.error or "", max_length=_INLINE_TEXT_LIMIT + ) + return Content.assemble( + (f"{indicator_glyph} ", indicator_color), + (server.name, "bold"), + (f" {server.transport}", dim_style), + (f" {glyphs.bullet} {server.status}", indicator_color), + (f" — {error_text}", dim_style) if error_text else "", + ) + assert_never(server.status) + + +class MCPServerErrorScreen(ModalScreen[None]): + """Read-only modal for a failed MCP server's error details.""" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("c", "copy_error", "Copy", show=False, priority=True), + Binding("escape", "cancel", "Close", show=False, priority=True), + ] + + CSS = """ + MCPServerErrorScreen { + align: center middle; + } + + MCPServerErrorScreen > Vertical { + width: 100; + max-width: 90%; + height: 80%; + background: $surface; + border: solid $error; + padding: 1 2; + } + + MCPServerErrorScreen .mcp-error-title { + text-style: bold; + color: $error; + text-align: center; + margin-bottom: 1; + } + + MCPServerErrorScreen .mcp-error-body { + height: 1fr; + background: $background; + scrollbar-gutter: stable; + padding: 0 1; + } + + MCPServerErrorScreen .mcp-error-text { + color: $text; + } + + MCPServerErrorScreen .mcp-error-help { + height: 1; + color: $text-muted; + text-style: italic; + margin-top: 1; + text-align: center; + } + """ + + def __init__(self, server: MCPServerInfo) -> None: + """Initialize the error-detail modal. + + Args: + server: Failed MCP server whose error text should be displayed. + """ + super().__init__() + self._server = server + self._error = sanitize_control_chars( + server.error or "No error details were reported.", + keep_newlines=True, + collapse_whitespace=False, + ) + + def compose(self) -> ComposeResult: + """Compose the modal layout. + + Yields: + Modal shell with title, scrollable error text, and help footer. + """ + glyphs = get_glyphs() + yield Vertical( + Static( + Content.from_markup( + "MCP Server Error: $server", + server=sanitize_control_chars( + self._server.name, max_length=_INLINE_TEXT_LIMIT + ), + ), + classes="mcp-error-title", + ), + VerticalScroll( + Static( + Content.from_markup("$error", error=self._error), + classes="mcp-error-text", + ), + classes="mcp-error-body", + ), + Static( + f"c copy error {glyphs.bullet} Esc close", + classes="mcp-error-help", + ), + ) + + def action_copy_error(self) -> None: + """Copy the server error details to the clipboard.""" + success, error = copy_text_to_clipboard(self.app, self._error) + if success: + self.app.notify( + "MCP error copied", + severity="information", + timeout=2, + markup=False, + ) + return + suffix = f": {error}" if error else "" + self.app.notify( + f"Failed to copy MCP error{suffix}", + severity="warning", + timeout=3, + markup=False, + ) + + def action_cancel(self) -> None: + """Close the error details modal.""" + self.dismiss(None) + + +class MCPServerHeaderItem(Static): + """A selectable server-header row in the MCP viewer. + + Cursor-selectable so users can navigate to every server — even those + in `unauthenticated` or `error` states which have no tool rows by the + `MCPServerInfo` invariant — and read the full status / error text on + the line. Not expandable: `Enter` and `Ctrl+E` are no-ops here. + """ + + def __init__( + self, + server: MCPServerInfo, + indicator_glyph: str, + indicator_color: str, + visible_tools: tuple[MCPToolInfo, ...], + glyphs: Glyphs, + index: int, + *, + classes: str = "", + ) -> None: + """Initialize a server-header row. + + Args: + server: Server metadata used to re-render content on selection + state changes. + indicator_glyph: Pre-computed status glyph character. + indicator_color: Pre-computed status hex color string. + visible_tools: Filtered tool tuple — used for the count label. + glyphs: Active glyph table. + index: Flat row index inside `MCPViewerScreen._row_widgets`. + classes: CSS classes — should include `mcp-server-header`, and + optionally `mcp-header-selected` for the initial selection. + """ + self._server = server + self._indicator_glyph = indicator_glyph + self._indicator_color = indicator_color + self._visible_tools = visible_tools + self._glyphs = glyphs + self.index = index + self._selected = "mcp-header-selected" in classes + content = _render_server_header( + server, + indicator_glyph, + indicator_color, + visible_tools, + glyphs, + selected=self._selected, + ) + super().__init__(content, classes=classes) + + @property + def server(self) -> MCPServerInfo: + """Server metadata this header row is rendering.""" + return self._server + + def set_selected(self, selected: bool) -> None: + """Apply or remove the selected-row styling and re-render the label. + + Re-renders content on selection change so `dim` secondary spans + are suppressed on the `$primary` selection background — the same + approach `MCPToolItem` uses via `_desc_style`. + """ + if self._selected == selected: + return + self._selected = selected + if selected: + self.add_class("mcp-header-selected") + else: + self.remove_class("mcp-header-selected") + self.update( + _render_server_header( + self._server, + self._indicator_glyph, + self._indicator_color, + self._visible_tools, + self._glyphs, + selected=selected, + ) + ) + + def refresh_from_server( + self, + server: MCPServerInfo, + indicator_glyph: str, + indicator_color: str, + visible_tools: tuple[MCPToolInfo, ...], + glyphs: Glyphs, + ) -> None: + """Replace the underlying server data and re-render in place. + + Used by `apply_server_disable_toggle` so an F2 toggle updates this + header without tearing down and re-mounting the widget. Preserves + the row's selected state so the cursor stays put visually. + + Args: + server: Updated server metadata. + indicator_glyph: New status glyph character. + indicator_color: New status hex color. + visible_tools: New filtered tool tuple (drives the count label). + glyphs: Active glyph table. + """ + self._server = server + self._indicator_glyph = indicator_glyph + self._indicator_color = indicator_color + self._visible_tools = visible_tools + self._glyphs = glyphs + self.update( + _render_server_header( + server, + indicator_glyph, + indicator_color, + visible_tools, + glyphs, + selected=self._selected, + ) + ) + + def on_click(self, event: Click) -> None: + """Handle click — select the header, or start login on unauth re-click. + + Headers are not expandable. Clicking once moves the cursor; + clicking the already-selected header either starts login for + an `unauthenticated` server or opens details for an `error` + server. + + Args: + event: The click event. + """ + event.stop() + screen = self.screen + if not isinstance(screen, MCPViewerScreen): + return + if self._selected and self._server.needs_attention(): + screen.dismiss(self._server.name) + return + if self._selected and self._server.status == "error": + screen.show_server_error(self._server) + return + screen._move_to(self.index) + + +class MCPViewerScreen(ModalScreen[str | None]): + """Modal viewer for active MCP servers and their tools. + + Displays servers grouped by name with transport type and tool count. + Navigate with arrow keys, Enter to expand/collapse tool descriptions, + start in-app OAuth login for an unauthenticated server, or inspect a + failed server. Ctrl+R requests a reconnect, F2 on a server header + toggles its disabled state, and Escape closes the modal. + + Dismisses with `None` when closed without action, the server name to + drive an in-TUI OAuth login when the user activates an + `unauthenticated` server header, or `MCP_VIEWER_RECONNECT_REQUEST` + for a reconnect. The disable/enable toggle (`F2`) is handled in-place + via the `on_toggle_disable` callback so the screen never tears down + — see the constructor. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", "Up", show=False, priority=True), + Binding("down", "move_down", "Down", show=False, priority=True), + Binding("shift+tab", "jump_up", "Up", show=False, priority=True), + Binding("tab", "jump_down", "Down", show=False, priority=True), + Binding("enter", "toggle_expand", "Expand", show=False, priority=True), + # Use a non-letter chord so it does not steal text input from the + # filter Input. PR #2949 originally proposed `a` for the same + # action; we rebound to `ctrl+e` for that reason. + Binding("ctrl+e", "toggle_all", "Toggle all", show=False, priority=True), + Binding("pageup", "page_up", "Page up", show=False, priority=True), + Binding("pagedown", "page_down", "Page down", show=False, priority=True), + Binding(MCP_RECONNECT_KEY, "reconnect", "Reconnect", show=False, priority=True), + Binding("f2", "toggle_disable", "Toggle disable", show=False, priority=True), + Binding("escape", "cancel", "Close", show=False, priority=True), + ] + """Key bindings for navigation, expansion, and cancel. + + All bindings use `priority=True` so they take precedence over the + embedded filter `Input`. Vim-style `j`/`k` bindings are deliberately + omitted because they would prevent typing those letters into the + always-focused filter input — same rationale as `model_selector.py`. + """ + + CSS = """ + MCPViewerScreen { + align: center middle; + } + + MCPViewerScreen > Vertical { + width: 80; + max-width: 90%; + height: 80%; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + MCPViewerScreen .mcp-viewer-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + MCPViewerScreen #mcp-filter { + margin-bottom: 1; + border: solid $primary-lighten-2; + } + + MCPViewerScreen #mcp-filter:focus { + border: solid $primary; + } + + MCPViewerScreen .mcp-list { + height: 1fr; + min-height: 5; + scrollbar-gutter: stable; + background: $background; + } + + MCPViewerScreen .mcp-server-header { + color: $primary; + margin-top: 1; + } + + MCPViewerScreen .mcp-server-header:hover { + background: $surface-lighten-1; + } + + MCPViewerScreen .mcp-list > .mcp-server-header:first-child { + margin-top: 0; + } + + MCPViewerScreen .mcp-header-selected { + background: $primary; + color: $text; + text-style: bold; + } + + MCPViewerScreen .mcp-header-selected:hover { + background: $primary-lighten-1; + color: $text; + } + + MCPViewerScreen .mcp-tool-item { + height: 1; + padding: 0 1; + } + + MCPViewerScreen .mcp-tool-item:hover { + background: $surface-lighten-1; + } + + MCPViewerScreen .mcp-tool-selected { + background: $primary; + color: $text; + text-style: bold; + } + + MCPViewerScreen .mcp-tool-selected:hover { + background: $primary-lighten-1; + color: $text; + } + + MCPViewerScreen .mcp-empty { + color: $text-muted; + text-style: italic; + text-align: center; + margin-top: 2; + } + + MCPViewerScreen .mcp-viewer-help { + height: 1; + color: $text-muted; + text-style: italic; + margin-top: 1; + text-align: center; + } + """ + + def __init__( + self, + server_info: list[MCPServerInfo], + *, + connecting: bool = False, + pending_reconnect: bool = False, + on_toggle_disable: Callable[[str], Awaitable[None]] | None = None, + on_close: Callable[[], bool] | None = None, + ) -> None: + """Initialize the MCP viewer screen. + + Args: + server_info: List of MCP server metadata to display. + connecting: When `True` and `server_info` is empty, show a + "connecting..." placeholder instead of the "no servers" + message; the screen refreshes when `refresh_server_info` + is called after the server startup completes. + pending_reconnect: `True` when a deferred MCP login is queued + and a restart will pick it up. Surfaces the `Ctrl+R` + reconnect hint in the footer; the keybind itself is a + no-op when this is `False`. + on_toggle_disable: Async callback invoked with the selected + server's name when the user presses `F2` on a header row. + The callback persists the new disabled state and is + expected to call `refresh_server_info` on this screen so + the user sees the updated status without a screen swap. + When `None`, `F2` is a no-op. + on_close: Callback invoked before Escape dismisses the viewer. + Return `True` when the callback replaced the viewer with a + follow-up screen and dismissal should be skipped. `None` + keeps the normal close behavior. + """ + super().__init__() + self._server_info = server_info + self._connecting = connecting + self._pending_reconnect = pending_reconnect + self._on_toggle_disable = on_toggle_disable + self._on_close = on_close + # All cursor-navigable rows in render order: server headers + tool + # items intermixed. `_selected_index` indexes into this list. + self._row_widgets: list[MCPToolItem | MCPServerHeaderItem] = [] + self._selected_index = 0 + self._query: str = "" + + @property + def _tool_widgets(self) -> list[MCPToolItem]: + """Tool rows only — excludes server headers. + + Convenience view used by `Ctrl+E` toggle-all and by tests that + only care about tool-level state. The authoritative storage is + `_row_widgets`. + """ + return [w for w in self._row_widgets if isinstance(w, MCPToolItem)] + + async def refresh_server_info( + self, + server_info: list[MCPServerInfo], + *, + pending_reconnect: bool | None = None, + select_server: str | None = None, + ) -> None: + """Replace the displayed server list; typically after server startup. + + Rebuilds the modal body in place so a user who opened `/mcp` before + tools finished loading sees them appear without closing/reopening. + Also used by the in-place disable toggle so the cursor lands back + on the same server header after F2. + + The active filter is cleared on refresh: the connecting placeholder + suppresses the filter input, so `_query` cannot be non-empty when + this is called. Resetting it also prevents the programmatic + `Input(value=...)` mount in `_mount_body` from triggering a + redundant `Input.Changed` repopulation. + + This is async because `body.remove_children()` must complete before + `_mount_body` re-inserts an `Input(id="mcp-filter")` — Textual + defers child removal, so a non-awaited remove leaves the old + widget attached and the mount raises `DuplicateIds`. + + Args: + server_info: Refreshed server metadata. + pending_reconnect: When provided, updates the footer's + reconnect hint. `None` preserves the existing value. + select_server: When provided, after the rebuild, move the + cursor to the header row whose `server.name` matches. + Unmatched names are silently ignored (the rebuild keeps + the default index-0 selection). + """ + self._server_info = server_info + self._connecting = False + self._query = "" + if pending_reconnect is not None: + self._pending_reconnect = pending_reconnect + body = self.query_one(Vertical) + await body.remove_children() + self._row_widgets = [] + self._selected_index = 0 + self._mount_body(body) + if select_server is not None: + for idx, widget in enumerate(self._row_widgets): + if ( + isinstance(widget, MCPServerHeaderItem) + and widget.server.name == select_server + ): + self._move_to(idx) + self._reveal_selection(widget, direction=1) + break + self._focus_filter_input() + + async def apply_server_disable_toggle( + self, + server_info: list[MCPServerInfo], + *, + toggled_server: str, + pending_reconnect: bool | None = None, + ) -> None: + """Patch a single server's row in place after an F2 toggle. + + Surgically updates only the affected server's header and tool + rows so unchanged widgets keep their identity — no full + `body.remove_children()` + remount, which would re-create every + `MCPToolItem` and reintroduce the `on_mount` truncation flicker + across the entire list. + + Falls back to `refresh_server_info` when the toggled server + cannot be patched in place: server missing from the new info + list, no existing header widget (e.g., the active filter + currently hides it), or the new state would filter the server + out entirely. + + Args: + server_info: Refreshed server metadata (full list). + toggled_server: Name of the server whose disabled state just + changed; identifies which row to patch. + pending_reconnect: When provided, updates the footer's + reconnect hint. `None` preserves the existing value. + """ + self._server_info = server_info + if pending_reconnect is not None: + self._pending_reconnect = pending_reconnect + + new_server = next((s for s in server_info if s.name == toggled_server), None) + header_idx = next( + ( + i + for i, w in enumerate(self._row_widgets) + if isinstance(w, MCPServerHeaderItem) + and w.server.name == toggled_server + ), + None, + ) + tokens = [tok for tok in self._query.lower().split() if tok] + visible_tools = ( + _visible_tools_for(new_server, tokens) if new_server is not None else None + ) + + if new_server is None or header_idx is None or visible_tools is None: + logger.debug( + "apply_server_disable_toggle fallback for %r: " + "new_server=%s header_idx=%s visible_tools=%s", + toggled_server, + new_server is not None, + header_idx, + visible_tools is not None, + ) + await self.refresh_server_info( + server_info, + pending_reconnect=pending_reconnect, + select_server=toggled_server, + ) + return + + header = self._row_widgets[header_idx] + if not isinstance(header, MCPServerHeaderItem): + # The lookup above filters by isinstance, so this branch + # should be unreachable. Log loudly rather than silently + # returning so a future invariant break is visible. + logger.warning( + "apply_server_disable_toggle: expected header at index %d, got %r", + header_idx, + type(header).__name__, + ) + return + next_header_idx = next( + ( + i + for i in range(header_idx + 1, len(self._row_widgets)) + if isinstance(self._row_widgets[i], MCPServerHeaderItem) + ), + len(self._row_widgets), + ) + + # `remove_children(to_remove)` removes the listed widgets + # atomically in one refresh; awaiting `widget.remove()` per + # row would yield to Textual between each, animating the + # tool list shrinking one entry at a time. + scroll = self.query_one(".mcp-list", VerticalScroll) + to_remove = self._row_widgets[header_idx + 1 : next_header_idx] + if to_remove: + await scroll.remove_children(to_remove) + del self._row_widgets[header_idx + 1 : next_header_idx] + + colors = theme.get_theme_colors(self) + glyphs = get_glyphs() + header.refresh_from_server( + new_server, + _status_glyph(new_server.status, glyphs), + _status_color(new_server.status, colors), + visible_tools, + glyphs, + ) + + if visible_tools: + new_widgets: list[MCPToolItem] = [ + MCPToolItem( + name=tool.name, + description=tool.description, + index=0, # renumbered below + classes="mcp-tool-item", + input_schema=tool.input_schema, + ) + for tool in visible_tools + ] + await scroll.mount(*new_widgets, after=header) + self._row_widgets[header_idx + 1 : header_idx + 1] = new_widgets + + # `MCPToolItem.on_click` calls `screen._move_to(self.index)`, + # so every row's stored index must match its position after + # the splice — otherwise clicks land on the wrong row. + for idx, widget in enumerate(self._row_widgets): + widget.index = idx + if self._selected_index >= len(self._row_widgets): + self._selected_index = max(0, len(self._row_widgets) - 1) + + # `_build_help_text` is cheap and reads `_pending_reconnect`, + # so re-render the footer whenever the caller supplied a new + # value — saves comparing against the prior state. + if pending_reconnect is not None: + help_static = self.query_one(".mcp-viewer-help", Static) + help_static.update(self._build_help_text(glyphs)) + + def on_input_changed(self, event: Input.Changed) -> None: + """Rebuild the visible tool list whenever the filter input changes. + + Only the scroll's children are torn down — the title, filter Input, + and help footer stay mounted so focus is preserved across keystrokes. + """ + if event.input.id != "mcp-filter": + return + self._query = event.value + scroll = self.query_one(".mcp-list", VerticalScroll) + scroll.remove_children() + self._row_widgets = [] + self._selected_index = 0 + self._populate_scroll(scroll, self._query) + self._selected_index = min( + self._selected_index, max(0, len(self._row_widgets) - 1) + ) + + def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual requires an instance method + """Compose the screen layout. + + Yields: + Empty `Vertical` — `_mount_body` fills it on mount so the same + builder can also refresh the screen in place after server-ready. + """ + yield Vertical() + + def on_mount(self) -> None: + """Build the body once the screen is mounted.""" + if is_ascii_mode(): + container = self.query_one(Vertical) + colors = theme.get_theme_colors(self) + container.styles.border = ("ascii", colors.success) + self._mount_body(self.query_one(Vertical)) + + def _mount_body(self, container: Vertical) -> None: + """Populate `container` with the title, filter input, list, and help footer. + + The filter Input and scroll container are mounted once. Subsequent + filter rebuilds replace only the scroll's children via + `_populate_scroll`, keeping the Input focused across keystrokes. + """ + glyphs = get_glyphs() + total_servers = len(self._server_info) + total_tools = sum(len(s.tools) for s in self._server_info) + + if total_servers: + server_label = "server" if total_servers == 1 else "servers" + tool_label = "tool" if total_tools == 1 else "tools" + title = ( + f"MCP Servers ({total_servers} {server_label}," + f" {total_tools} {tool_label})" + ) + else: + title = "MCP Servers" + container.mount(Static(title, classes="mcp-viewer-title")) + + # Suppress the filter Input while the connecting placeholder is + # showing — there's nothing to filter yet. + if self._server_info: + container.mount( + Input( + id="mcp-filter", + placeholder="Filter tools...", + value=self._query, + ) + ) + + scroll = VerticalScroll(classes="mcp-list") + container.mount(scroll) + self._populate_scroll(scroll, self._query) + + container.mount( + Static(self._build_help_text(glyphs), classes="mcp-viewer-help") + ) + + def _focus_filter_input(self) -> None: + """Refocus the filter `Input` after an in-place body rebuild. + + `refresh_server_info` clears the body via `remove_children`, which + blurs the screen (Textual resets focus to `None` when the focused + widget is pruned). The newly mounted filter `Input` is not + auto-focused on a re-mount — Textual auto-focuses only on the first + mount — so a viewer opened while the server is still connecting + would leave the rebuilt input unfocused once tools load, and + keystrokes would never reach it. Restore focus explicitly here, + deferred via `call_after_refresh` because `_mount_body` mounts + without awaiting. + + The `Input` exists only when there are servers to filter (see + `_mount_body`); when the list is empty there is nothing to focus, so + return early. Gating on `_server_info` rather than swallowing a + missing-widget error keeps a genuinely-absent input (id drift, a + failed mount) visible instead of silently re-introducing the + keystroke-swallow this method exists to prevent. + """ + + def _focus() -> None: + if not self._server_info: + return + self.query_one("#mcp-filter", Input).focus() + + self.call_after_refresh(_focus) + + def _build_help_text(self, glyphs: Glyphs) -> str: + """Compose the help-footer string from the current `_pending_reconnect`. + + Single source of truth so `_mount_body` (initial) and + `apply_server_disable_toggle` (incremental) stay in sync — F2 + flips the reconnect-pending state, and the footer must update + without a full re-mount. + + Returns: + The rendered help line for the modal footer. + """ + help_parts = [ + f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate", + "Enter expand/login/details", + "F2 disable/enable", + "Ctrl+E expand all", + ] + if self._pending_reconnect: + help_parts.append(f"{MCP_RECONNECT_KEY_LABEL} reconnect") + help_parts.extend(["type to filter", "Esc close"]) + return f" {glyphs.bullet} ".join(help_parts) + + def _populate_scroll(self, scroll: VerticalScroll, query: str) -> None: + """Mount filtered server headers + tool items into `scroll`. + + Empty `query` shows everything; otherwise multi-token AND matching + on server names and tool names only — descriptions, parameter + names, and transport are not in the haystack (see + `_visible_tools_for`). + """ + glyphs = get_glyphs() + + if not self._server_info: + placeholder = ( + "Loading MCP tools..." + if self._connecting + else ("No MCP servers configured.\nUse `--mcp-config` to load servers.") + ) + scroll.mount(Static(placeholder, classes="mcp-empty")) + return + + tokens = [tok for tok in query.lower().split() if tok] + colors = theme.get_theme_colors(self) + flat_index = 0 + + for server in _sort_servers_for_display(self._server_info): + visible_tools = _visible_tools_for(server, tokens) + if visible_tools is None: + # Server filtered out entirely. + continue + + indicator_color = _status_color(server.status, colors) + indicator_glyph = _status_glyph(server.status, glyphs) + header_classes = "mcp-server-header" + if flat_index == 0: + header_classes += " mcp-header-selected" + header = MCPServerHeaderItem( + server=server, + indicator_glyph=indicator_glyph, + indicator_color=indicator_color, + visible_tools=visible_tools, + glyphs=glyphs, + index=flat_index, + classes=header_classes, + ) + self._row_widgets.append(header) + scroll.mount(header) + flat_index += 1 + + for tool in visible_tools: + classes = "mcp-tool-item" + widget = MCPToolItem( + name=tool.name, + description=tool.description, + index=flat_index, + classes=classes, + input_schema=tool.input_schema, + ) + self._row_widgets.append(widget) + scroll.mount(widget) + flat_index += 1 + + if not self._row_widgets: + msg = "No matching tools." if tokens else "No tools available." + scroll.mount(Static(msg, classes="mcp-empty")) + + def _move_to(self, index: int) -> None: + """Move selection to the given row index. + + Args: + index: Target row index inside `_row_widgets` (header or tool). + """ + count = len(self._row_widgets) + if not count: + return + if not (0 <= index < count): + # Stale index from a widget that survived a filter rebuild. + return + old = self._selected_index + if not (0 <= old < count): + old = 0 + self._selected_index = index + + if old != index: + self._row_widgets[old].set_selected(False) + self._row_widgets[index].set_selected(True) + # Caller (action) is responsible for any viewport pin — different + # navigation directions want different anchors (top for down, + # bottom for up). + + def _move_selection(self, delta: int) -> None: + """Move selection by delta row positions within the list bounds. + + Walks every row (headers + tools). Navigation actions handle wrapping + before calling this helper at a list boundary. + + Args: + delta: Number of row positions to move. + """ + if not self._row_widgets: + return + target = self._selected_index + delta + if 0 <= target < len(self._row_widgets): + self._move_to(target) + + def _next_server_header(self, start: int, step: int) -> int | None: + """Return the next server-header index in the requested direction. + + Args: + start: Index to start searching from (exclusive). + step: `+1` (forward) or `-1` (backward). + + Returns: + The index of the nearest `MCPServerHeaderItem` in that direction, + or `None` when no server header exists there. + """ + index = start + step + while 0 <= index < len(self._row_widgets): + if isinstance(self._row_widgets[index], MCPServerHeaderItem): + return index + index += step + return None + + def _scroll_widget_bottom_to_view( + self, widget: MCPToolItem | MCPServerHeaderItem + ) -> None: + """Scroll so `widget.region.bottom` aligns with the viewport bottom. + + Used when jumping upward into a row taller than the viewport: lands + the user at the bottom of that row so the next `Up` press immediately + line-scrolls upward through its content rather than jumping again. + """ + scroll = self.query_one(".mcp-list", VerticalScroll) + delta = (widget.region.y + widget.region.height) - ( + scroll.region.y + scroll.region.height + ) + if delta: + scroll.scroll_relative(y=delta, animate=False) + + def _reveal_selection( + self, + widget: MCPToolItem | MCPServerHeaderItem, + *, + direction: int, + ) -> None: + """Bring `widget` into view after a selection change. + + Only force-anchors rows taller than the viewport — these need a + deliberate edge alignment so subsequent arrow presses can line-scroll + through the row's body. For normal rows, defers to `scroll_visible`, + which is a no-op when the row is already fully visible. Matches + `/model` switcher behavior where short, in-view rows don't tug the + viewport on every keypress. + + Args: + widget: The newly selected row. + direction: `+1` when moving down (anchor top for tall rows), + `-1` when moving up (anchor bottom for tall rows). + """ + scroll = self.query_one(".mcp-list", VerticalScroll) + if widget.region.height > scroll.region.height: + if direction > 0: + widget.scroll_visible(top=True) + else: + self._scroll_widget_bottom_to_view(widget) + else: + widget.scroll_visible() + + def action_move_up(self) -> None: + """Smart up: scroll one row inside a tall expanded row, else jump. + + If the selected row's top edge is already inside the viewport, jump + to the previous row (header or tool), wrapping to the final row from + the first. For rows taller than the viewport, pin the new selection's + **bottom** to the viewport so the next `Up` resumes line-stepping + through that row; otherwise just ensure the row is visible. `Tab` / + `Shift+Tab` jump between server headers (see `action_jump_up`). + """ + if not self._row_widgets: + return + scroll = self.query_one(".mcp-list", VerticalScroll) + selected = self._row_widgets[self._selected_index] + if selected.region.y >= scroll.region.y: + old = self._selected_index + if old == 0: + self._move_to(len(self._row_widgets) - 1) + else: + self._move_selection(-1) + if self._selected_index != old: + self._reveal_selection( + self._row_widgets[self._selected_index], direction=-1 + ) + else: + scroll.scroll_relative(y=-1, animate=False) + + def action_move_down(self) -> None: + """Smart down: scroll one row inside a tall expanded row, else jump. + + If the selected row's bottom edge is already inside the viewport, + jump to the next row (header or tool), wrapping to the first row from + the final one. For rows taller than the viewport, pin the new + selection's top to the viewport; otherwise just ensure the row is + visible. `Tab` / `Shift+Tab` jump between server headers (see + `action_jump_down`). + """ + if not self._row_widgets: + return + scroll = self.query_one(".mcp-list", VerticalScroll) + selected = self._row_widgets[self._selected_index] + selected_bottom = selected.region.y + selected.region.height + viewport_bottom = scroll.region.y + scroll.region.height + if selected_bottom <= viewport_bottom: + old = self._selected_index + if old == len(self._row_widgets) - 1: + self._move_to(0) + else: + self._move_selection(1) + if self._selected_index != old: + self._reveal_selection( + self._row_widgets[self._selected_index], direction=1 + ) + else: + scroll.scroll_relative(y=1, animate=False) + + def action_jump_up(self) -> None: + """Jump backward to the nearest server header (Shift+Tab), wrapping. + + From a tool row this lands on the current server's own header; from a + header it moves to the previous server. Wraps to the final header from + the top. + """ + target = self._next_server_header(self._selected_index, -1) + if target is None: + target = self._next_server_header(len(self._row_widgets), -1) + if target is None or target == self._selected_index: + return + self._move_to(target) + self._reveal_selection(self._row_widgets[target], direction=-1) + + def action_jump_down(self) -> None: + """Jump to the next server (Tab), wrapping at the end.""" + target = self._next_server_header(self._selected_index, +1) + if target is None: + target = self._next_server_header(-1, +1) + if target is None or target == self._selected_index: + return + self._move_to(target) + self._reveal_selection(self._row_widgets[target], direction=1) + + def show_server_error(self, server: MCPServerInfo) -> None: + """Open the read-only error detail modal for `server`. + + Args: + server: Failed MCP server to inspect. + """ + self.app.push_screen(MCPServerErrorScreen(server)) + + def action_toggle_expand(self) -> None: + """Toggle expand on a tool row, log in, or show error details. + + Tool rows expand/collapse as before; activating a header row for + a server in `unauthenticated` state dismisses the viewer with the + server name so the app can drive in-TUI OAuth login. Activating an + `error` header opens a read-only detail modal. Headers for other + states (ok, awaiting reconnect, disabled) remain no-ops. + """ + if not self._row_widgets: + return + row = self._row_widgets[self._selected_index] + if isinstance(row, MCPToolItem): + row.toggle_expand() + # The new height isn't reflected until after the next layout + # pass, so defer the visibility scroll. Without this, expanding + # a row near the viewport bottom leaves its new body off-screen. + self.call_after_refresh(row.scroll_visible) + return + server = row.server + if server.needs_attention(): + self.dismiss(server.name) + return + if server.status == "error": + self.show_server_error(server) + + def action_toggle_all(self) -> None: + """Expand or collapse every visible tool at once. + + If any visible tool is collapsed, expand all; otherwise collapse all. + Operates on tool rows only — server headers are not expandable. + Hidden tools (filtered out) keep their state. + """ + tools = self._tool_widgets + if not tools: + return + any_collapsed = any(not w._expanded for w in tools) + for widget in tools: + widget.set_expanded(any_collapsed) + + def action_page_up(self) -> None: + """Scroll up by one page and snap selection to the topmost visible row. + + Without the selection snap, `_selected_index` would still point at + the now-offscreen row, and a subsequent `Up`/`Down` press would + yank the viewport back to it (see `action_move_up` / `_move_down`, + which scroll the offscreen selection back into view). + """ + if not self._row_widgets: + return + scroll = self.query_one(".mcp-list", VerticalScroll) + scroll.scroll_page_up() + self.call_after_refresh(self._snap_selection_to_topmost_visible) + + def action_page_down(self) -> None: + """Scroll down by one page and snap selection to the bottommost visible row. + + Mirror of `action_page_up`: prevents a subsequent arrow key from + scrolling the viewport back to a now-offscreen selection. + """ + if not self._row_widgets: + return + scroll = self.query_one(".mcp-list", VerticalScroll) + scroll.scroll_page_down() + self.call_after_refresh(self._snap_selection_to_bottommost_visible) + + def _snap_selection_to_topmost_visible(self) -> None: + """Move selection to the first row whose top is at or below the viewport top.""" + if not self._row_widgets: + return + scroll = self.query_one(".mcp-list", VerticalScroll) + top = scroll.region.y + for idx, widget in enumerate(self._row_widgets): + if widget.region.y >= top: + self._move_to(idx) + return + + def _snap_selection_to_bottommost_visible(self) -> None: + """Move selection to the last row whose bottom fits inside the viewport.""" + if not self._row_widgets: + return + scroll = self.query_one(".mcp-list", VerticalScroll) + bottom = scroll.region.y + scroll.region.height + target: int | None = None + for idx, widget in enumerate(self._row_widgets): + if widget.region.y + widget.region.height <= bottom: + target = idx + if target is not None: + self._move_to(target) + + def action_cancel(self) -> None: + """Close the viewer without selecting a server to log into.""" + if self._on_close is not None and self._on_close(): + return + self.dismiss(None) + + def action_reconnect(self) -> None: + """Dismiss with the reconnect sentinel when a login is pending. + + Bindings are static, so the keybind is always bound; this guard + is what makes it a no-op when nothing is queued. + """ + if not self._pending_reconnect: + return + self.dismiss(MCP_VIEWER_RECONNECT_REQUEST) + + def action_toggle_disable(self) -> None: + """Hand off a toggle-disable request to the app without dismissing. + + Only fires when a server header is selected — pressing F2 on a + tool row is a no-op. The app's callback persists the new state + and is expected to call `refresh_server_info(..., select_server=)` + on this screen, so the user sees the new status without the + screen tearing down (which would flicker and reset selection). + """ + if not self._row_widgets: + return + row = self._row_widgets[self._selected_index] + if isinstance(row, MCPToolItem): + return + if self._on_toggle_disable is None: + return + self.app.call_later(self._on_toggle_disable, row.server.name) diff --git a/libs/code/deepagents_code/tui/widgets/message_store.py b/libs/code/deepagents_code/tui/widgets/message_store.py new file mode 100644 index 0000000000..b77610c5c2 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/message_store.py @@ -0,0 +1,1128 @@ +"""Message store for virtualized chat history. + +This module provides data structures and management for message virtualization, +allowing the TUI to handle large message histories efficiently by keeping only +a sliding window of widgets in the DOM while storing all message data as +lightweight dataclasses. + +The approach is inspired by Textual's `Log` widget, which only keeps `N` lines +in the DOM and recreates older ones on demand. +""" + +from __future__ import annotations + +import logging +import uuid +from dataclasses import dataclass, field +from enum import StrEnum +from time import time +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from textual.widget import Widget + + from deepagents_code.diff_utils import DiffStats + from deepagents_code.file_ops import DiffOutcome + +logger = logging.getLogger(__name__) + +DEFAULT_HEIGHT_HINT = 5 +"""Estimated terminal rows for a message whose rendered height is unknown.""" + +MIN_HEIGHT_HINT = 1 +"""Smallest useful row estimate for spacer and range-height math.""" + +_ACTIVE_REASON = "active" +"""Protection reason for the currently-streaming message.""" + +_LIVE_REASON = "live" +"""Protection reason for a pending/running tool row (default for protect_message).""" + + +_UPDATABLE_FIELDS: frozenset[str] = frozenset( + { + "content", + "tool_status", + "tool_output", + "tool_duration", + "tool_expanded", + "tool_reject_reason", + "tool_diff_superseded", + "tool_display_caveat", + "skill_expanded", + "rubric_expanded", + "user_expanded", + "is_streaming", + } +) +"""Fields on `MessageData` that callers are allowed to update via `update_message`. + +Prevents accidental overwriting of identity fields like `id`, `type`, or +`timestamp`. +""" + + +class MessageType(StrEnum): + """Types of messages in the chat.""" + + USER = "user" + """Input authored by the human, rendered above the agent's response.""" + + ASSISTANT = "assistant" + """Streamed agent response rendered with markdown.""" + + TOOL = "tool" + """Record of a tool invocation, including its args, status, and output.""" + + SKILL = "skill" + """Record of a skill invocation, carrying its SKILL.md body and metadata.""" + + ERROR = "error" + """Error surfaced to the user (e.g., a failed tool call or SDK exception).""" + + APP = "app" + """App-status note from the app itself (version info, command feedback).""" + + RUBRIC = "rubric" + """Rubric grader result with a compact summary and expandable details.""" + + SUMMARIZATION = "summarization" + """Notification that the prior conversation was summarized/offloaded.""" + + DIFF = "diff" + """Unified diff preview attached to a file-modifying tool call.""" + + +class ToolStatus(StrEnum): + """Status of a tool call.""" + + PENDING = "pending" + """Queued for execution, typically awaiting human approval.""" + + RUNNING = "running" + """Currently executing.""" + + SUCCESS = "success" + """Completed without error.""" + + ERROR = "error" + """Raised an exception or returned a non-zero exit status.""" + + REJECTED = "rejected" + """Human explicitly denied the call at the approval prompt.""" + + SKIPPED = "skipped" + """Bypassed without executing (e.g., the agent canceled the call).""" + + +@dataclass +class MessageData: + """In-memory message data for virtualization. + + This dataclass holds all information needed to recreate a message widget. + It is designed to be lightweight so that thousands of messages can be + stored without meaningful memory overhead. + """ + + type: MessageType + """The kind of message (user, assistant, tool, etc.).""" + + content: str + """Primary text content of the message. + + For most message types this is the display text. For TOOL messages it is + typically empty because the tool's identity comes from `tool_name` / + `tool_args` instead. + """ + + id: str = field(default_factory=lambda: f"msg-{uuid.uuid4().hex}") + """Unique identifier used to match the dataclass to its DOM widget. + + Uses the full 128-bit `uuid4` hex (not a truncated prefix) so IDs stay + unique across large histories and long sessions; a widget-ID collision + raises `DuplicateIds` when the widget is mounted. + """ + + timestamp: float = field(default_factory=time) + """Unix epoch timestamp of when the message was created.""" + + # TOOL message fields - only populated for TOOL messages + tool_name: str | None = None + """Name of the tool that was called.""" + + tool_args: dict[str, Any] | None = None + """Arguments passed to the tool call.""" + + tool_status: ToolStatus | None = None + """Current execution status of the tool call.""" + + tool_output: str | None = None + """Output returned by the tool after execution.""" + + tool_duration: float | None = None + """Elapsed run time in seconds for a completed timed tool call.""" + + tool_expanded: bool = False + """Whether the tool output section is expanded in the UI.""" + + tool_reject_reason: str | None = None + """User-supplied reason attached to a HITL reject decision (if any).""" + + tool_diff_superseded: bool = False + """Whether a mounted diff replaces this successful tool row.""" + + tool_display_caveat: bool = False + """Whether `tool_output` opens with a caveat that must not be folded away. + + Persisted rather than re-derived from the output text: matching prose would + tie rehydration to the caveat's exact wording, and the cost of getting it + wrong is a transcript that folds the change's only account into a summary + line. See `ToolCallMessage.has_display_caveat`. + """ + + # --- + + diff_file_path: str | None = None + """File path associated with the diff (DIFF messages only).""" + + diff_tool_name: str | None = None + """Name of the file tool that produced the diff (DIFF messages only).""" + + diff_before_content: str | None = None + """Content prefix before the change, used to highlight DIFF messages. + + Bounded by `diff.MAX_HIGHLIGHT_CHARS` as trimmed by + `highlight_source_prefixes` on the way to the widget; `from_widget` reads the + already-trimmed value. + + `__post_init__` enforces the same *length* limit, but not the same rule: it + slices characters, so a value it truncates can end mid-line, which + `highlight_source_prefixes` — keyed on the diff's line numbers — never + produces. Such a prefix lexes into a partial final line and trips the drift + check in `_highlighted_rows`. It is a backstop against a direct constructor + call parking an unbounded copy of a file in a store whose whole point is + that thousands of messages cost little, not a second way to build a prefix. + """ + + diff_after_content: str | None = None + """Content prefix after the change, same bound and same caveat.""" + + diff_stats: DiffStats | None = None + """True change counts, which survive a truncated DIFF body.""" + + diff_outcome: DiffOutcome = "shown" + """What the operation could honestly say about what it changed. + + One field rather than a `stats`-plus-"counts unknown" pair, so a rehydrated + diff cannot come back holding counts it also declares fictional. + """ + + diff_show_numbers: bool = True + """Whether file-relative line numbers are shown in this DIFF message.""" + + diff_show_caveat: bool = True + """Whether the DIFF renders its outcome's caveat, or leaves it to its row. + + Persisted rather than recomputed because the decision depends on what else + was mounted at the time, which rehydration cannot see. Without it a diff + whose row carries the caveat comes back printing the same sentence twice. + """ + + # SKILL message fields - only populated for SKILL messages + skill_name: str | None = None + """Name of the skill that was invoked.""" + + skill_description: str | None = None + """Short description of the skill.""" + + skill_source: str | None = None + """Origin of the skill (e.g., `'built-in'`, `'user'`, `'project'`).""" + + skill_args: str | None = None + """User-provided arguments to the skill invocation.""" + + skill_body: str | None = None + """Full SKILL.md content sent to the agent.""" + + skill_expanded: bool = False + """Whether the skill body is expanded in the UI.""" + + rubric_details: str | None = None + """Complete grader details for RUBRIC messages.""" + + rubric_expanded: bool = False + """Whether the grader details are expanded in the UI.""" + + # USER message fields - only populated for USER messages + user_expanded: bool = False + """Whether a collapsed long user message is expanded in the UI.""" + + user_detect_mode: bool = True + """Whether the message renders a leading `/`/`!` trigger as a mode glyph. + + Submitted prompts are constructed with mode detection off (a leading slash + is literal text there), so this has to survive virtualization or a rehydrated + message would strip a prefix it should render — changing both its glyph and + its collapse threshold. + """ + + is_streaming: bool = False + """Whether the message is still being streamed. + + While `True`, the corresponding widget is actively receiving content + chunks and should not be pruned or re-hydrated. + """ + + is_markdown: bool = False + """For APP messages, whether `content` is a markdown source string. + + When `True`, rehydration renders the content via Rich markdown instead of + the plain dim-italic `AppMessage` styling. + """ + + height_hint: int | None = None + """Cached rendered widget height in terminal rows, or None if unmeasured. + + Measured after layout by `_measure_message_height` in `app.py` and stored + via `set_height_hint`. Consumed by `estimate_height`/`range_height` to size + the transcript spacers and to keep the scroll anchor stable across + hydrate-above/below. When None (not yet measured), `estimate_height` falls + back to `DEFAULT_HEIGHT_HINT`. Always `>= MIN_HEIGHT_HINT` once set. + """ + + def __post_init__(self) -> None: + """Validate type-field coherence after construction. + + Raises: + ValueError: If a TOOL message is missing `tool_name`, a SKILL + message is missing `skill_name`, or a RUBRIC message is missing + `rubric_details`. + """ + if self.type == MessageType.TOOL and not self.tool_name: + msg = "TOOL messages must have a tool_name" + raise ValueError(msg) + if self.type == MessageType.SKILL and not self.skill_name: + msg = "SKILL messages must have a skill_name" + raise ValueError(msg) + # A summary-only grader result stays an AppMessage; a RUBRIC message + # exists precisely to carry expandable details, so require them. + if self.type == MessageType.RUBRIC and not self.rubric_details: + msg = "RUBRIC messages must have rubric_details" + raise ValueError(msg) + # Enforce the bound the highlight fields document. `from_widget` already + # supplies trimmed values, so this only catches direct construction — + # which is exactly the path that could otherwise park an unbounded copy + # of a file in a store whose whole point is that thousands of messages + # cost little. Imported here rather than at module scope to keep the + # widget module off the startup import path (AGENTS.md). + from deepagents_code.tui.widgets.diff import MAX_HIGHLIGHT_CHARS + + if self.diff_before_content is not None: + self.diff_before_content = self.diff_before_content[:MAX_HIGHLIGHT_CHARS] + if self.diff_after_content is not None: + self.diff_after_content = self.diff_after_content[:MAX_HIGHLIGHT_CHARS] + + def to_widget(self) -> Widget: + """Recreate a widget from this message data. + + Returns: + The appropriate message widget for this data. + """ + # Import here to avoid circular imports + from deepagents_code.tui.widgets.messages import ( + AppMessage, + AssistantMessage, + DiffMessage, + ErrorMessage, + RubricResultMessage, + SkillMessage, + SummarizationMessage, + ToolCallMessage, + UserMessage, + ) + + match self.type: + case MessageType.USER: + widget = UserMessage( + self.content, + id=self.id, + detect_mode=self.user_detect_mode, + ) + widget._deferred_expanded = self.user_expanded + return widget + + case MessageType.ASSISTANT: + return AssistantMessage(self.content, id=self.id) + + case MessageType.TOOL: + widget = ToolCallMessage( + self.tool_name or "unknown", + self.tool_args, + id=self.id, + ) + # Deferred state is restored automatically during on_mount + # via _restore_deferred_state + widget._deferred_status = self.tool_status + widget._deferred_output = self.tool_output + widget._deferred_duration = self.tool_duration + widget._deferred_expanded = self.tool_expanded + widget._deferred_reject_reason = self.tool_reject_reason + if self.tool_display_caveat: + widget._mark_display_caveat() + if self.tool_diff_superseded: + # Go through the widget's own setter so a rehydrated row + # passes the same tool-name guard as the live path; writing + # the flag directly could hide a row no diff can replace. + widget.mark_superseded_by_diff() + return widget + + case MessageType.SKILL: + widget = SkillMessage( + skill_name=self.skill_name or "unknown", + description=self.skill_description or "", + source=self.skill_source or "", + body=self.skill_body or "", + args=self.skill_args or "", + id=self.id, + ) + widget._deferred_expanded = self.skill_expanded + return widget + + case MessageType.ERROR: + return ErrorMessage(self.content, id=self.id) + + case MessageType.APP: + return AppMessage(self.content, markdown=self.is_markdown, id=self.id) + + case MessageType.RUBRIC: + widget = RubricResultMessage( + self.content, + self.rubric_details or "", + id=self.id, + ) + widget._deferred_expanded = self.rubric_expanded + return widget + + case MessageType.SUMMARIZATION: + return SummarizationMessage(self.content, id=self.id) + + case MessageType.DIFF: + return DiffMessage( + self.content, + file_path=self.diff_file_path or "", + tool_name=self.diff_tool_name, + before=self.diff_before_content or "", + after=self.diff_after_content or "", + stats=self.diff_stats, + outcome=self.diff_outcome, + show_caveat=self.diff_show_caveat, + show_numbers=self.diff_show_numbers, + id=self.id, + ) + + case _: + logger.warning( + "Unknown MessageType %r for message %s, falling back to AppMessage", + self.type, + self.id, + ) + return AppMessage(self.content, id=self.id) + + @classmethod + def from_widget(cls, widget: Widget) -> MessageData: + """Create MessageData from an existing widget. + + Args: + widget: The message widget to serialize. + + Returns: + MessageData containing all the widget's state. + """ + # Deferred: prevents import-order issue — both modules live in the + # widgets package, and messages is re-exported from widgets/__init__. + from deepagents_code.tui.widgets.messages import ( + AppMessage, + AssistantMessage, + DiffMessage, + ErrorMessage, + RubricResultMessage, + SkillMessage, + SummarizationMessage, + ToolCallMessage, + UserMessage, + ) + + widget_id = widget.id or f"msg-{uuid.uuid4().hex}" + + if isinstance(widget, SkillMessage): + return cls( + type=MessageType.SKILL, + content="", + id=widget_id, + skill_name=widget._skill_name, + skill_description=widget._description, + skill_source=widget._source, + skill_body=widget._body, + skill_args=widget._args, + skill_expanded=widget._expanded, + ) + + if isinstance(widget, UserMessage): + return cls( + type=MessageType.USER, + content=widget._content, + id=widget_id, + user_expanded=widget._expanded, + user_detect_mode=widget._detect_mode, + ) + + if isinstance(widget, AssistantMessage): + return cls( + type=MessageType.ASSISTANT, + content=widget._content, + id=widget_id, + is_streaming=widget._stream is not None, + ) + + if isinstance(widget, ToolCallMessage): + tool_status: ToolStatus | None = None + if widget._status: + try: + tool_status = ToolStatus(widget._status) + except ValueError: + logger.warning( + "Unknown tool status %r for widget %s", + widget._status, + widget_id, + ) + + return cls( + type=MessageType.TOOL, + content="", # Tool messages don't have simple content + id=widget_id, + tool_name=widget._tool_name, + tool_args=widget._args, + tool_status=tool_status, + tool_output=widget._output, + tool_duration=widget._duration, + tool_expanded=widget._expanded, + tool_reject_reason=widget._reject_reason, + # The raw flag, deliberately, not the `_superseded_by_diff` + # property: that property conjoins `is_success`, so persisting + # it would bake the current status into the stored value and a + # later error-to-success flip would lose the supersession. Do + # not "fix" this to use the public property. + tool_diff_superseded=widget._diff_superseded, + tool_display_caveat=widget.has_display_caveat, + ) + + if isinstance(widget, ErrorMessage): + return cls( + type=MessageType.ERROR, + # `_content` may be `Content` (link spans drop on resume). + content=str(widget._content), + id=widget_id, + ) + + # Check specialized subclasses before AppMessage so we keep their type + # when serializing and can restore their specific styling later. + if isinstance(widget, DiffMessage): + return cls( + type=MessageType.DIFF, + content=widget._diff_content, + id=widget_id, + diff_file_path=widget._file_path, + diff_tool_name=widget._tool_name, + diff_before_content=widget._before, + diff_after_content=widget._after, + diff_stats=widget._stats, + diff_outcome=widget._outcome, + diff_show_caveat=widget._show_caveat, + diff_show_numbers=widget._show_numbers, + ) + + if isinstance(widget, SummarizationMessage): + return cls( + type=MessageType.SUMMARIZATION, + content=str(widget._content), + id=widget_id, + ) + + if isinstance(widget, RubricResultMessage): + return cls( + type=MessageType.RUBRIC, + content=widget._summary, + id=widget_id, + rubric_details=widget._details, + rubric_expanded=widget._expanded, + ) + + if isinstance(widget, AppMessage): + return cls( + type=MessageType.APP, + content=str(widget._content), + id=widget_id, + is_markdown=widget._is_markdown, + ) + + logger.warning( + "Unknown widget type %s (id=%s), storing as APP message", + type(widget).__name__, + widget_id, + ) + return cls( + type=MessageType.APP, + content=f"[Unknown widget: {type(widget).__name__}]", + id=widget_id, + ) + + +class MessageStore: + """Manages message data and widget window for virtualization. + + This class stores all messages as data and manages a sliding window + of widgets that are actually mounted in the DOM. + + Attributes: + WINDOW_SIZE: Maximum number of messages to keep mounted in the DOM. + + Trades DOM cost against scroll smoothness. Note each message may + also mount a timestamp footer, so the live widget count is up to + ~2x this value. Spacer rows above/below the window preserve full + scroll geometry, so this only bounds how much is rendered at once, + not what the user can scroll to. + HYDRATE_BUFFER: Number of messages to hydrate when scrolling near edge. + + Provides enough buffer to avoid visible loading pauses. + """ + + WINDOW_SIZE: int = 200 + HYDRATE_BUFFER: int = 15 + + def __init__(self) -> None: + """Initialize the message store.""" + self._messages: list[MessageData] = [] + self._index: dict[str, MessageData] = {} + """ID -> MessageData lookup. + + Must contain exactly one entry per element of `_messages`. Any method + that adds to or removes from `_messages` must update `_index` + in lockstep. + """ + self._visible_start: int = 0 + self._visible_end: int = 0 + + self._protection_reasons: dict[str, set[str]] = {} + """Message ID -> set of reasons it must stay mounted while live. + + A message is protected from virtualization iff it has at least one + reason. Reasons are independent (`_ACTIVE_REASON` for the streaming + message, `_LIVE_REASON` for a pending/running tool), so releasing one + source never revokes another's protection. + """ + + self._active_message_id: str | None = None + """The single currently-streaming message, mirrored into + `_protection_reasons` under `_ACTIVE_REASON`. Retained so the + `is_active`/`set_active_message` API keeps working.""" + + @property + def total_count(self) -> int: + """Total number of messages stored.""" + return len(self._messages) + + @property + def visible_count(self) -> int: + """Number of messages currently visible (as widgets).""" + return self._visible_end - self._visible_start + + @property + def has_messages_above(self) -> bool: + """Check if there are archived messages above the visible window.""" + return self._visible_start > 0 + + @property + def has_messages_below(self) -> bool: + """Check if there are archived messages below the visible window.""" + return self._visible_end < len(self._messages) + + def append(self, message: MessageData) -> None: + """Add a new message to the store. + + Args: + message: The message data to add. + """ + was_at_tail = self._visible_end == len(self._messages) + if message.id in self._index: + logger.warning( + "Duplicate message ID %r appended; previous entry will be " + "unreachable via get_message()", + message.id, + ) + self._messages.append(message) + self._index[message.id] = message + if was_at_tail: + self._visible_end = len(self._messages) + + def bulk_load( + self, messages: list[MessageData] + ) -> tuple[list[MessageData], list[MessageData]]: + """Load many messages at once, keeping only the tail visible. + + This is optimized for thread resumption: all messages are stored as + lightweight data, but only the last `WINDOW_SIZE` entries are marked + visible (i.e. will need DOM widgets). + + Args: + messages: Ordered list of message data to load. + + Returns: + Tuple of (archived, visible) message lists. + """ + self._messages.extend(messages) + for msg in messages: + if msg.id in self._index: + logger.warning( + "Duplicate message ID %r in bulk_load; previous entry " + "will be unreachable via get_message()", + msg.id, + ) + self._index[msg.id] = msg + total = len(self._messages) + + if total <= self.WINDOW_SIZE: + self._visible_start = 0 + else: + self._visible_start = total - self.WINDOW_SIZE + + self._visible_end = total + + archived = self._messages[: self._visible_start] + visible = self._messages[self._visible_start : self._visible_end] + return archived, visible + + def get_message(self, message_id: str) -> MessageData | None: + """Get a message by its ID. + + Args: + message_id: The ID of the message to find. + + Returns: + The message data, or None if not found. + """ + return self._index.get(message_id) + + def update_message(self, message_id: str, **updates: Any) -> bool: + """Update a message's data. + + Only fields in `_UPDATABLE_FIELDS` may be updated. Unknown field + names raise `ValueError` to catch typos early. + + Args: + message_id: The ID of the message to update. + **updates: Fields to update. + + Returns: + True if the message was found and updated. + + Raises: + ValueError: If any key in `updates` is not in the updatable + allowlist. + """ + unknown = set(updates) - _UPDATABLE_FIELDS + if unknown: + msg = f"Cannot update unknown or protected fields: {unknown}" + raise ValueError(msg) + + msg_data = self._index.get(message_id) + if msg_data is None: + logger.warning( + "update_message called for unknown ID %r; update discarded", + message_id, + ) + return False + for key, value in updates.items(): + setattr(msg_data, key, value) + return True + + def set_active_message(self, message_id: str | None) -> None: + """Set the currently active (streaming) message. + + Active messages are never archived. Only the previous active message's + `_ACTIVE_REASON` is released, so a message also protected for another + reason (e.g. a live tool) stays protected. + + Args: + message_id: The ID of the active message, or None to clear. + """ + if self._active_message_id is not None: + self.unprotect_message(self._active_message_id, reason=_ACTIVE_REASON) + self._active_message_id = message_id + if message_id is not None: + self.protect_message(message_id, reason=_ACTIVE_REASON) + + def is_active(self, message_id: str) -> bool: + """Check if a message is the active streaming message. + + Args: + message_id: The message ID to check. + + Returns: + True if this is the active message. + """ + return message_id == self._active_message_id + + def protect_message(self, message_id: str, *, reason: str = _LIVE_REASON) -> None: + """Keep a live message mounted during window updates. + + Reasons accumulate independently; a message stays protected until every + reason is released. Idempotent per reason. + + Args: + message_id: Message ID to protect. + reason: Why the message is protected. Defaults to a live tool row. + """ + self._protection_reasons.setdefault(message_id, set()).add(reason) + + def unprotect_message(self, message_id: str, *, reason: str = _LIVE_REASON) -> None: + """Release one protection reason from a message. + + The message becomes virtualizable only once it has no remaining + reasons. Releasing a reason the message does not hold is a no-op. + + Args: + message_id: Message ID to stop protecting. + reason: Which reason to release. Defaults to a live tool row. + """ + reasons = self._protection_reasons.get(message_id) + if reasons is None: + return + reasons.discard(reason) + if not reasons: + del self._protection_reasons[message_id] + + def is_protected(self, message_id: str) -> bool: + """Check whether a message is protected from virtualization. + + Returns: + Whether the message is protected for at least one reason. + """ + return message_id in self._protection_reasons + + def window_exceeded(self) -> bool: + """Check if the visible window exceeds the maximum size. + + Returns: + True if we should prune some widgets. + """ + return self.visible_count > self.WINDOW_SIZE + + def get_messages_to_prune(self, count: int | None = None) -> list[MessageData]: + """Get the oldest visible messages that should be pruned. + + Returns a contiguous run of messages from the START of the visible + window. Stops at the first protected message (the active stream or a + live tool run) to avoid creating gaps in the visible window (which + would desync store state from the DOM). + + Args: + count: Number of messages to prune, or None to prune + enough to get back to WINDOW_SIZE. + + Returns: + List of messages to prune (remove widgets for). + """ + if count is None: + count = max(0, self.visible_count - self.WINDOW_SIZE) + + if count <= 0: + return [] + + to_prune: list[MessageData] = [] + idx = self._visible_start + + while len(to_prune) < count and idx < self._visible_end: + msg = self._messages[idx] + # Stop at the first protected message to keep the window contiguous + if self.is_protected(msg.id): + break + to_prune.append(msg) + idx += 1 + + return to_prune + + def get_messages_to_prune_below( + self, count: int | None = None + ) -> list[MessageData]: + """Get newest visible messages that should be pruned below the viewport. + + Args: + count: Number of messages to prune, or enough to return to + `WINDOW_SIZE` when omitted. + + Returns: + Messages to remove from the bottom of the visible window. + """ + if count is None: + count = max(0, self.visible_count - self.WINDOW_SIZE) + if count <= 0: + return [] + + to_prune: list[MessageData] = [] + idx = self._visible_end - 1 + while len(to_prune) < count and idx >= self._visible_start: + msg = self._messages[idx] + if self.is_protected(msg.id): + break + to_prune.append(msg) + idx -= 1 + to_prune.reverse() + return to_prune + + def mark_pruned(self, message_ids: list[str]) -> None: + """Mark messages as pruned (widgets removed). + + Advances `_visible_start` past consecutive pruned messages at the front + of the window. + + Args: + message_ids: IDs of messages that were pruned. + """ + pruned_set = set(message_ids) + while ( + self._visible_start < self._visible_end + and self._messages[self._visible_start].id in pruned_set + ): + self._visible_start += 1 + + def mark_pruned_below(self, message_ids: list[str]) -> None: + """Mark bottom-window messages as pruned. + + Args: + message_ids: IDs removed from the bottom of the mounted window. + """ + pruned_set = set(message_ids) + while ( + self._visible_end > self._visible_start + and self._messages[self._visible_end - 1].id in pruned_set + ): + self._visible_end -= 1 + + def get_messages_to_hydrate(self, count: int | None = None) -> list[MessageData]: + """Get messages above the visible window to hydrate. + + Args: + count: Number of messages to hydrate, or None for `HYDRATE_BUFFER`. + + Returns: + List of messages to hydrate (create widgets for), in order. + """ + if count is None: + count = self.HYDRATE_BUFFER + + if self._visible_start <= 0: + return [] + + hydrate_start = max(0, self._visible_start - count) + return self._messages[hydrate_start : self._visible_start] + + def mark_hydrated(self, count: int) -> None: + """Mark that messages above were hydrated. + + Args: + count: Number of messages that were hydrated. + """ + self._visible_start = max(0, self._visible_start - count) + + def get_messages_to_hydrate_below( + self, count: int | None = None + ) -> list[MessageData]: + """Get messages below the visible window to hydrate. + + Args: + count: Number of messages to hydrate; defaults to `HYDRATE_BUFFER` + when omitted. + + Returns: + Messages below the mounted window, in order. + """ + if count is None: + count = self.HYDRATE_BUFFER + if self._visible_end >= len(self._messages): + return [] + hydrate_end = min(len(self._messages), self._visible_end + count) + return self._messages[self._visible_end : hydrate_end] + + def mark_hydrated_below(self, count: int) -> None: + """Mark that messages below were hydrated. + + Args: + count: Number of messages that were hydrated below the window. + """ + self._visible_end = min(len(self._messages), self._visible_end + count) + + def should_hydrate_above( + self, scroll_position: float, viewport_height: int + ) -> bool: + """Check if we should hydrate messages above the current view. + + Args: + scroll_position: Current scroll Y position. + viewport_height: Height of the viewport. + + Returns: + True if user is scrolling near the top and we have archived messages. + """ + if not self.has_messages_above: + return False + + # Hydrate when within 2x viewport height of the top + threshold = viewport_height * 2 + return scroll_position < threshold + + def should_prune_below( + self, scroll_position: float, viewport_height: int, content_height: int + ) -> bool: + """Check if we should prune messages below the current view. + + Note: + Not yet integrated into the scroll handler. Intended for future + pruning of messages below the viewport when the user scrolls far up. + + Args: + scroll_position: Current scroll Y position. + viewport_height: Height of the viewport. + content_height: Total height of all content. + + Returns: + True if we have too many widgets and bottom ones are far from view. + """ + if self.visible_count <= self.WINDOW_SIZE: + return False + + # Only prune if user is far from the bottom + distance_from_bottom = content_height - scroll_position - viewport_height + threshold = viewport_height * 3 + return distance_from_bottom > threshold + + def should_hydrate_below( + self, + scroll_position: float, + viewport_height: int, + bottom_spacer_top: int, + *, + max_scroll: float | None = None, + ) -> bool: + """Check if we should hydrate messages below the current view. + + Args: + scroll_position: Current scroll Y position. + viewport_height: Height of the viewport. + bottom_spacer_top: Estimated row where the bottom spacer begins. + max_scroll: Maximum scroll offset of the viewport, when known. When + the view is scrolled to this edge but history is still archived + below, hydration must run regardless of the spacer-distance + heuristic: the user cannot scroll any further, and estimated + spacer heights can drift from the real DOM layout enough to + leave the distance check just short of its threshold, stranding + the tail. Mirrors how scrolling to the top (offset 0) always + hydrates above. + + Returns: + True if the viewport is near (or at) the bottom spacer. + """ + if not self.has_messages_below: + return False + if max_scroll is not None and scroll_position >= max_scroll: + return True + viewport_bottom = scroll_position + viewport_height + distance_from_bottom_spacer = bottom_spacer_top - viewport_bottom + threshold = viewport_height * 2 + return distance_from_bottom_spacer < threshold + + def clear(self) -> None: + """Clear all messages.""" + self._messages.clear() + self._index.clear() + self._visible_start = 0 + self._visible_end = 0 + self._protection_reasons.clear() + self._active_message_id = None + + def get_visible_range(self) -> tuple[int, int]: + """Get the range of visible message indices. + + Returns: + Tuple of (start_index, end_index). + """ + return (self._visible_start, self._visible_end) + + def get_all_messages(self) -> list[MessageData]: + """Get all stored messages. + + Returns: + List of all message data (shallow copy). + """ + return list(self._messages) + + def get_visible_messages(self) -> list[MessageData]: + """Get messages in the visible window. + + Returns: + List of visible message data. + """ + return self._messages[self._visible_start : self._visible_end] + + def set_height_hint(self, message_id: str, rows: int) -> bool: + """Update a measured message height, clamped to `MIN_HEIGHT_HINT`. + + The single write path for `height_hint`; `height_hint` is intentionally + excluded from `update_message`'s allowlist so every write clamps here. + + Args: + message_id: Message ID to update. + rows: Rendered height in terminal rows. + + Returns: + Whether the message existed and was updated. + """ + msg_data = self._index.get(message_id) + if msg_data is None: + return False + msg_data.height_hint = max(MIN_HEIGHT_HINT, rows) + return True + + def invalidate_height_hints(self, *, scale: float | None = None) -> None: + """Invalidate or scale cached height hints after terminal reflow. + + Args: + scale: Optional multiplier used when terminal width changes. When + omitted, all cached hints are cleared. + """ + for msg in self._messages: + if msg.height_hint is None: + continue + if scale is None: + msg.height_hint = None + else: + msg.height_hint = max(MIN_HEIGHT_HINT, round(msg.height_hint * scale)) + + @staticmethod + def estimate_height(message: MessageData) -> int: + """Return the best available row estimate for a message.""" + if message.height_hint is None: + return DEFAULT_HEIGHT_HINT + return max(MIN_HEIGHT_HINT, message.height_hint) + + def range_height(self, start: int, end: int) -> int: + """Estimate rows in `[start:end]`. + + Returns: + Estimated row count in the range. + """ + bounded_start = max(0, min(start, len(self._messages))) + bounded_end = max(bounded_start, min(end, len(self._messages))) + return sum( + self.estimate_height(msg) + for msg in self._messages[bounded_start:bounded_end] + ) diff --git a/libs/code/deepagents_code/tui/widgets/messages.py b/libs/code/deepagents_code/tui/widgets/messages.py new file mode 100644 index 0000000000..73f6410307 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/messages.py @@ -0,0 +1,5452 @@ +"""Message widgets.""" + +from __future__ import annotations + +import ast +import json +import logging +import re +import textwrap +from dataclasses import dataclass +from pathlib import Path +from time import time +from typing import TYPE_CHECKING, Any, ClassVar, Literal, NamedTuple, TypeAlias + +from textual import on +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.content import Content +from textual.css.query import NoMatches +from textual.events import Click +from textual.geometry import Offset +from textual.message import Message +from textual.message_pump import NoActiveAppError +from textual.reactive import var +from textual.selection import Selection +from textual.style import Style as TStyle +from textual.widgets import Static + +from deepagents_code import theme +from deepagents_code._ask_user_types import ( + ASK_USER_ANSWERED_SUMMARY, + ASK_USER_FAILED_SUMMARY, + AskUserRowSummary, +) +from deepagents_code.config import ( + MODE_DISPLAY_GLYPHS, + detect_mode_prefix, + get_glyphs, + is_ascii_mode, +) +from deepagents_code.diff_utils import ( + DiffStats, + count_diff_change_lines, + is_truncation_marker, + split_diff_lines, +) +from deepagents_code.file_ops import ( + DiffOutcome, + display_caveat, + is_sensitive_file_path, +) +from deepagents_code.formatting import format_duration +from deepagents_code.input import EMAIL_PREFIX_PATTERN, INPUT_HIGHLIGHT_PATTERN +from deepagents_code.tool_display import ( + EXECUTE_HEADER_MAX_LENGTH, + JS_EVAL_HEADER_MAX_LENGTH, + format_tool_display, +) +from deepagents_code.tui.widgets._js_eval_display import ( + JsEvalBlock, + JsEvalError, + JsEvalResult, + JsEvalStdout, + parse_js_eval_blocks, +) +from deepagents_code.tui.widgets._links import ( + event_targets_link, + open_checked_url_async, + open_style_link, +) +from deepagents_code.tui.widgets.diff import ( + compose_diff_lines, + format_diff_stats, + highlight_source_prefixes, +) +from deepagents_code.unicode_security import render_with_unicode_markers + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, Sequence + + from rich.console import ( + Console as RichConsole, + ConsoleOptions, + RenderResult, + ) + from textual.app import ComposeResult + from textual.events import MouseMove + from textual.timer import Timer + from textual.widget import Widget + from textual.widgets import Markdown + from textual.widgets._markdown import MarkdownStream + + from deepagents_code.input import MediaTracker + from deepagents_code.theme import ThemeColors + + _SummaryCall: TypeAlias = tuple[str, Mapping[str, Any]] + """One tool call as the summary code sees it: `(raw tool name, parsed args)`.""" + + _SummaryCacheKey: TypeAlias = tuple[tuple[str, str | None], ...] + """Opaque identity of a summary line's inputs — compare only for equality.""" + + _LiveSummaryKey: TypeAlias = tuple[_SummaryCacheKey, _SummaryCacheKey] + """The `(completed, pending)` key pair behind a cached live summary line.""" + +logger = logging.getLogger(__name__) + + +def _mode_color(mode: str | None, widget_or_app: object | None = None) -> str: + """Return the hex color string for a mode, falling back to primary. + + Args: + mode: Mode name (e.g. `'shell'`, `'command'`) or `None`. + widget_or_app: Textual widget or `App` for theme-aware lookup. + + Returns: + Color string from the active theme's `ThemeColors`. + """ + colors = theme.get_theme_colors(widget_or_app) + if not mode: + return colors.primary + if mode == "shell_incognito": + return colors.mode_incognito + if mode == "shell": + return colors.mode_bash + if mode == "command": + return colors.mode_command + logger.warning("Missing color for mode '%s'; falling back to primary.", mode) + return colors.primary + + +@dataclass(frozen=True, slots=True) +class FormattedOutput: + """Result of formatting tool output for display.""" + + content: Content + """Styled `Content` for the formatted output.""" + + truncation: str | None = None + """Description of truncated content (e.g., "10 more lines"), or None if no + truncation occurred.""" + + +# Maximum number of tool arguments to display inline +_MAX_INLINE_ARGS = 3 + +# Truncation limits for display +_MAX_TODO_CONTENT_LEN = 70 +_DEFAULT_TODO_WRAP_WIDTH = 80 +_TODO_WRAP_GUARD_COLUMNS = 4 +_MAX_WEB_CONTENT_LEN = 100 + +# User message display truncation — when content exceeds this many characters, +# only the head and tail are rendered with an elision marker in between. +# This keeps very large pastes from flooding the conversation scrollback. +_USER_MSG_MAX_DISPLAY_CHARS = 10_000 +_USER_MSG_TRUNCATE_HEAD_CHARS = 2_500 +_USER_MSG_TRUNCATE_TAIL_CHARS = 2_500 + +# Tools that have their key info already in the header (no need for args line) +_TOOLS_WITH_HEADER_INFO: set[str] = { + # Filesystem tools + "ls", + "read_file", + "write_file", + "edit_file", + "delete", + "glob", + "grep", + "execute", # sandbox shell + "js_eval", # JS interpreter + # Web tools + "web_search", + "fetch_url", + "ask_user", + # Agent tools + "task", + "write_todos", +} + + +# Tools whose key info (file path / search pattern) is already in the header, so +# their output body is collapsed entirely by default — an expand affordance +# replaces the inline preview. `read_file` echoes the file; grep/glob echo the +# matches for a pattern the header already names. +_COLLAPSE_OUTPUT_BY_DEFAULT: set[str] = { + "read_file", + "grep", + "glob", +} + + +_TOOL_SUPERSEDED_BY_DIFF = "edit_file" +"""The one tool whose successful row is replaced by the `DiffMessage` after it. + +The row self-hides via `mark_superseded_by_diff`, and only ever behind a diff +that can actually stand in for it — the adapter requires a non-empty body and a +`shown` outcome. `DiffOutcome` explains why the other outcomes cannot. + +Ask `ToolCallMessage.can_be_superseded` rather than comparing against this +constant — the adapter checks a tool name from a different source, and the two +must not drift. +""" + + +# Tools whose collapsed body is always the formatter's compact preview, no +# matter how short the raw output is, and whose expandability is therefore +# decided by the formatter rather than by the raw size thresholds. `write_todos` +# renders a per-item summary; `ask_user` renders a one-line summary so a +# two-line transcript still keeps its answers behind an expand click. +_ALWAYS_PREVIEW_TOOLS: frozenset[str] = frozenset({"write_todos", "ask_user"}) + + +# An `ask_user` row whose recorded output is exactly one of these holds only a +# fallback summary — no `ToolMessage` transcript ever arrived — so there is +# nothing for an expand click to reveal. Recognized by value rather than by the +# `_deferred_success_settled` flag so the suppression also holds for a row rebuilt +# from the message store, where that flag is not persisted (a rehydrated row is +# always already terminal). A real transcript always begins `Q: `, so it can never +# collide with these. +_ASK_USER_ROW_SUMMARIES: frozenset[str] = frozenset( + {ASK_USER_ANSWERED_SUMMARY, ASK_USER_FAILED_SUMMARY} +) + + +# Long-running tools whose completed status row reports how long they ran +# ("Took ") when a run was timed, instead of being hidden. `execute` +# shells and `task` subagent dispatches can both run for a while, so the elapsed +# time is useful. +_TIMED_SUCCESS_TOOLS: set[str] = { + "execute", + "task", +} + + +# CSS classes applied to a `ToolCallMessage` to tint the whole row by terminal +# outcome (see its `DEFAULT_CSS`). Running/pending states carry none of these. +_STATUS_CLASSES: frozenset[str] = frozenset( + {"-status-success", "-status-error", "-status-rejected", "-status-skipped"} +) + + +_SUCCESS_EXIT_RE = re.compile(r"\n?\[Command succeeded with exit code 0\]\s*$") +"""Strip the SDK's `[Command succeeded with exit code 0]` trailer from tool output.""" + + +_READ_FILE_GUTTER_RE = re.compile(r"^ *(\d+(?:\.\d+)?)(?: |\t)(.*)$") +"""Match a `read_file` gutter row into (marker, source). + +The marker is a bare `N` or `N.M` (the latter a wrapped-line continuation) — +both sides of the dot required, so a stray `.5` head is not a gutter. The +separator is exactly two spaces (current format) or a single tab (legacy +`cat -n`). Only the separator is consumed and leading padding is spaces-only, so +source indentation — including leading tabs — after the gutter stays put. Kept in +sync with the separator emitted by deepagents' `format_content_with_line_numbers` +(the authoritative producer). See `ToolCallMessage._compact_line_gutter`. +""" + + +def _strip_success_exit_line(text: str) -> str: + """Remove the `[Command succeeded with exit code 0]` trailer. + + Non-zero exit codes are left intact (they come through `set_error`). + + Args: + text: Raw tool output string. + + Returns: + Text with the success exit-code trailer removed, if present. + """ + return _SUCCESS_EXIT_RE.sub("", text) + + +# Visual width of the prompt prefix (glyph + trailing space, e.g. "> ", "$ "). +# Glyphs are single characters, so the prefix is always two columns wide. +_PROMPT_PREFIX_WIDTH = 2 + + +def _strip_prompt_prefix( + result: tuple[str, str] | None, + selection: Selection, +) -> tuple[str, str] | None: + """Drop the leading prompt prefix glyph from a selected range. + + The prefix is only rendered on the first row, so it is stripped only when + the selection begins there. This keeps triple-click / select-all copies to + the message body instead of the decorative `"> "` (or mode glyph) prefix. + + Args: + result: The `(text, ending)` tuple returned by `Static.get_selection`. + selection: The active selection geometry. + + Returns: + The selection with the prefix removed from row 0, or `result` unchanged. + """ + if result is None: + return None + text, ending = result + start = selection.start + if start is not None and start.y != 0: + return result + start_x = 0 if start is None else start.x + prefix_chars = max(0, _PROMPT_PREFIX_WIDTH - start_x) + return text[prefix_chars:], ending + + +def _select_prompt_body(widget: Static) -> None: + """Select the user message body without its decorative prompt glyph. + + Args: + widget: User message widget whose body should be selected. + """ + widget.screen.selections = { # ty: ignore[invalid-assignment] # Textual reactive descriptor assignment updates selection watchers; `set_reactive` would skip them. + widget: Selection(Offset(_PROMPT_PREFIX_WIDTH, 0), None), + } + + +@dataclass(frozen=True, slots=True) +class _UserMessageFull: + """A user message short enough to render verbatim.""" + + text: str + """The original body, unmodified.""" + + +@dataclass(frozen=True, slots=True) +class _UserMessageCollapsed: + """A user message elided to head + tail for transcript display. + + Each variant names only the fields that mean something for it, so states + like "not collapsed but 5 hidden lines" are unrepresentable and consumers + dispatch by `isinstance` rather than reading an overloaded flag. + """ + + head: str + """Leading slice kept verbatim, rendered above the elision marker.""" + + tail: str + """Trailing slice kept verbatim, rendered below the elision marker.""" + + hidden_lines: int + """Newlines in the elided middle.""" + + hidden_chars: int + """Characters in the elided middle. + + Reported in place of `hidden_lines` for single-line bodies (base64 blobs, + minified JSON), where "+0 lines" would imply nothing was hidden. + """ + + @property + def text(self) -> str: + """Single-string collapsed form, for callers that cannot emit spans. + + Returns: + Head and tail joined by an elision marker. + """ + ellipsis = get_glyphs().ellipsis + return ( + f"{self.head}\n{ellipsis} +{self.hidden_lines} lines {ellipsis}\n" + f"{self.tail}" + ) + + +_UserMessageDisplay = _UserMessageFull | _UserMessageCollapsed +"""Either form a user-message body can take in the transcript.""" + + +def _will_collapse(text: str) -> bool: + """Return whether `text` exceeds the transcript display threshold. + + Single source of truth for the threshold, shared by `_collapse_user_message` + and `UserMessage.will_truncate` so the render decision and the expand + affordance can never disagree. + + Args: + text: Candidate body text. + + Returns: + `True` when the body is long enough to collapse. + """ + return len(text) > _USER_MSG_MAX_DISPLAY_CHARS + + +def _collapse_user_message(text: str) -> _UserMessageDisplay: + """Collapse a very long user message for transcript display. + + Keeps the first and last portions and elides the middle. This mirrors + Claude Code's `UserPromptMessage` head+tail truncation for rendering + performance. + + Args: + text: Full message content. + + Returns: + `_UserMessageCollapsed` when the body exceeds the display threshold, + otherwise `_UserMessageFull` carrying the original text. + """ + if not _will_collapse(text): + return _UserMessageFull(text=text) + hidden_start = _USER_MSG_TRUNCATE_HEAD_CHARS + hidden_end = len(text) - _USER_MSG_TRUNCATE_TAIL_CHARS + return _UserMessageCollapsed( + head=text[:_USER_MSG_TRUNCATE_HEAD_CHARS], + tail=text[-_USER_MSG_TRUNCATE_TAIL_CHARS:], + # Counted over a range rather than a slice so a multi-megabyte paste + # does not allocate a copy of its own middle on every render. + hidden_lines=text.count("\n", hidden_start, hidden_end), + hidden_chars=hidden_end - hidden_start, + ) + + +def _truncate_for_display(text: str) -> str: + """Truncate very long user message text for display in the conversation. + + Thin string-returning wrapper around `_collapse_user_message` for + `QueuedUserMessage.render` and tests, which only need the joined text. + `UserMessage.render` consumes the head/tail fields directly so it can + interleave the clickable affordance between them. + + Args: + text: Full message content. + + Returns: + Truncated text with an elision marker, or the original text when + it does not exceed the display threshold. + """ + return _collapse_user_message(text).text + + +class UserMessage(Static): + """Widget displaying a user message. + + Very long messages are collapsed in the transcript by default (head+tail + elision) to protect scrollback performance. The full text remains on the + widget for copy/select, and the collapsed form is reversible via click or + Ctrl+O. + """ + + class ExpansionChanged(Message): + """Posted when the collapsed-body expansion state changes.""" + + def __init__(self, widget: UserMessage, expanded: bool) -> None: + """Initialize an expansion-state message. + + Args: + widget: The user message whose expansion state changed. + expanded: Whether the full body is now shown. + """ + super().__init__() + self.widget = widget + self.expanded = expanded + + DEFAULT_CSS = """ + UserMessage { + height: auto; + padding: 0 1; + margin: 0 0 1 0; + background: transparent; + border-left: wide $primary; + pointer: text; + /* The expand affordance carries `@click` meta, which Textual styles as + a link (underline, and bold on an accent block when hovered). + Neutralize both so the hint renders as plain inherited-colour dim + italic, matching every other "click or Ctrl+O" hint in this module. + Bold in particular has to go: it cancels dim in most terminals. */ + link-color: $text; + link-style: not underline; + link-color-hover: $text; + link-background-hover: transparent; + link-style-hover: not bold not underline; + } + + UserMessage.-cancelled { + opacity: 0.6; + } + """ + """`-cancelled` dims a prompt whose turn was interrupted by the user.""" + + _expanded: var[bool] = var(False) + + def __init__( + self, + content: str, + *, + media_snapshot: MediaTracker | None = None, + detect_mode: bool = True, + **kwargs: Any, + ) -> None: + """Initialize a user message. + + Args: + content: The message content + media_snapshot: Optional media tracker state captured at submission. + detect_mode: When `True` (default), a leading mode trigger (`/`, + `!`, `!!`) is rendered with its shell/command glyph, border, and + highlight. Set to `False` for text submitted as literal agent + input (e.g. via `-m`/`--message`), which never triggers a + shell/command mode, so a leading slash (like a file path) must + render as a plain user message rather than a slash command. + **kwargs: Additional arguments passed to parent + """ + super().__init__(**kwargs) + self._content = content + self._media_snapshot = media_snapshot + self._detect_mode = detect_mode + self._deferred_expanded = False + # Last expansion value published to the message store. Deduping against + # it keeps the reactive's initialization watcher and the deferred + # restore from re-emitting a value the store already holds. + self._published_expanded = False + + @staticmethod + def will_truncate(content: str) -> bool: + """Return whether `content` would collapse in the transcript. + + Prefer the `has_expandable_body` property when a widget is in hand: it + applies the same mode-prefix stripping as `render()`, so it cannot + disagree with what is actually on screen. This static form is for + callers that only have the raw string and know no prefix applies. + + Args: + content: Candidate user-message body (mode prefix already stripped + when applicable — matching what `render()` collapses). + + Returns: + `True` when the body exceeds the display character threshold. + """ + return _will_collapse(content) + + @property + def raw_text(self) -> str: + """The original, untruncated message text as the user submitted it. + + Named `raw_text` rather than `content` to avoid shadowing Textual's + read/write `Static.content` property (backed by a mangled attribute); + overriding it getter-only would make `self.content = ...` raise. + """ + return self._content + + @property + def media_snapshot(self) -> MediaTracker | None: + """Media tracker state captured when the message was submitted.""" + return self._media_snapshot + + def _body_for_display(self) -> str: + """Return the message body after stripping a detected mode trigger. + + Unlike `_prefix_and_body`, this does not look up theme colors, so it is + safe to call before mount (e.g. `has_expandable_body` / Ctrl+O routing). + + Returns: + Body text used for collapse decisions (`has_expandable_body`). + """ + content = self._content + mode_match = detect_mode_prefix(content) if self._detect_mode else None + if mode_match: + prefix_text, _mode = mode_match + return content[len(prefix_text) :] + return content + + @property + def has_expandable_body(self) -> bool: + """Whether this message is long enough to collapse/expand in display.""" + return self.will_truncate(self._body_for_display()) + + def set_cancelled(self) -> None: + """Dim the message to mark its turn as interrupted by the user.""" + self.add_class("-cancelled") + + def toggle_expanded(self) -> None: + """Toggle between collapsed and full-body transcript display.""" + if not self.has_expandable_body: + return + self._expanded = not self._expanded + + def action_toggle_expand(self) -> None: + """Textual `@click` target for the expand/collapse affordance.""" + self.toggle_expanded() + + def watch__expanded(self, expanded: bool) -> None: + """Relayout and publish user-driven expansion for virtualization.""" + self.refresh(layout=True) + # Publish only genuine changes: dedupe against the store's known value + # to drop the reactive's initialization watcher and the deferred + # restore, and require `is_attached` so `_expanded` set in pre-mount + # test setup does not `post_message` on a detached widget. + if self.is_attached and expanded != self._published_expanded: + self._published_expanded = expanded + self.post_message(self.ExpansionChanged(self, expanded)) + + def get_selection(self, selection: Selection) -> tuple[str, str] | None: + """Return selected text, preferring the full content over the render. + + `render()` truncates long messages, so for a full-message selection + (select-all / select-to-end, where `selection.end` is `None`) the text + is extracted from the untruncated content so copy yields the complete + original. A partial selection is extracted from the base (on-screen) + render so its offsets stay aligned with what the user highlighted. + + Args: + selection: The active selection geometry. + + Returns: + The `(text, ending)` selection with the prefix removed, or `None`. + """ + if selection.end is not None: + return _strip_prompt_prefix(super().get_selection(selection), selection) + text = str(self._build_full_render()) + return _strip_prompt_prefix((selection.extract(text), "\n"), selection) + + def _prefix_and_body(self) -> tuple[tuple[str, str], str]: + """Compute the styled mode prefix and the body with its trigger stripped. + + Returns: + A `(prefix, body)` pair where `prefix` is a `(text, style)` tuple and + `body` is the content with any mode-trigger prefix removed. + """ + colors = theme.get_theme_colors(self) + content = self._content + mode_match = detect_mode_prefix(content) if self._detect_mode else None + if mode_match: + prefix_text, mode = mode_match + glyph = MODE_DISPLAY_GLYPHS.get(mode, prefix_text[0]) + return ( + (f"{glyph} ", f"bold {_mode_color(mode, self)}"), + content[len(prefix_text) :], + ) + return ("> ", f"bold {colors.primary}"), content + + def _build_full_render(self) -> Content: + """Build a Content from the full content without display truncation. + + Omits the expand/collapse hint spans in both the collapsed and expanded + states, so select-all yields only the original message body. Drag + selections do not route here (see `get_selection`) and can still pick up + hint text along with the body. + + Returns: + Content with the mode prefix glyph and the full message body. + """ + prefix, body = self._prefix_and_body() + return Content.assemble(prefix, body) + + def text_select_all(self) -> None: + """Select the message body without the prompt prefix glyph.""" + _select_prompt_body(self) + + def on_mount(self) -> None: + """Add mode/ASCII CSS classes and restore deferred expansion state.""" + mode_match = detect_mode_prefix(self._content) if self._detect_mode else None + if mode_match: + _prefix, mode = mode_match + self.add_class(f"-mode-{mode.replace('_', '-')}") + if is_ascii_mode(): + self.add_class("-ascii") + # The store already holds the restored state, so record it as published + # first; the assignment below then dedupes instead of re-emitting it. + self._published_expanded = self._deferred_expanded + if self._deferred_expanded: + self._expanded = True + self._deferred_expanded = False + + def _append_highlighted_body( + self, + parts: list[str | tuple[str, str] | Content], + content: str, + *, + colors: ThemeColors, + ) -> None: + """Append body text to `parts`, highlighting @mentions and /commands. + + Args: + parts: Accumulator for `Content.assemble`. + content: Body text to highlight and append. + colors: Active theme colors. + """ + last_end = 0 + for match in INPUT_HIGHLIGHT_PATTERN.finditer(content): + start, end = match.span() + token = match.group() + + # Skip @mentions that look like email addresses + if token.startswith("@") and start > 0: + char_before = content[start - 1] + if EMAIL_PREFIX_PATTERN.match(char_before): + continue + + # Add text before the match (unstyled) + if start > last_end: + parts.append(content[last_end:start]) + + # The regex only matches tokens starting with / or @ + if token.startswith("/") and start == 0: + # A leading `/command` is only highlighted when mode detection + # is on; otherwise it is literal text (e.g. a file path passed + # via `-m`) and must render plain so the token is not dropped. + if self._detect_mode: + parts.append((token, f"bold {colors.warning}")) + else: + parts.append(token) + elif token.startswith("@"): + # @file mention + parts.append((token, f"bold {colors.primary}")) + last_end = end + + # Add remaining text after last match + if last_end < len(content): + parts.append(content[last_end:]) + + @staticmethod + def _hint_style() -> TStyle: + """Style for the expand/collapse affordance. + + Returns: + Dim italic style carrying the `@click` hit-target meta. + """ + # `@click` meta is what Textual uses for Markdown links; body text stays + # free of meta so regular clicks select/copy without toggling. The + # link-* rules in `DEFAULT_CSS` keep Textual's automatic link styling + # from overriding the dim italic. + return TStyle(dim=True, italic=True) + TStyle.from_meta( + {"@click": "toggle_expand"} + ) + + @classmethod + def _collapse_hint_content(cls, collapsed: _UserMessageCollapsed) -> Content: + """Build the clickable "show full message" affordance. + + Args: + collapsed: The collapse result describing the elided middle. + + Returns: + Dim hit-target Content wired to `action_toggle_expand`. + """ + ellipsis = get_glyphs().ellipsis + # A single-line paste (base64 blob, minified JSON) hides no newlines, so + # "+0 lines" would read as "nothing is hidden" exactly when the most is. + if collapsed.hidden_lines: + amount = f"+{collapsed.hidden_lines:,} lines" + else: + amount = f"+{collapsed.hidden_chars:,} characters" + return Content.styled( + f"{ellipsis} {amount} · click or Ctrl+O to show full message", + cls._hint_style(), + ) + + @classmethod + def _expand_hint_content(cls) -> Content: + """Build the clickable "collapse" affordance shown when expanded. + + Returns: + Dim hit-target Content wired to `action_toggle_expand`. + """ + return Content.styled("click or Ctrl+O to collapse", cls._hint_style()) + + def render(self) -> Content: + """Render the styled user message. + + Returns: + Styled Content with mode prefix and highlighted mentions. Long + messages are collapsed by default with a clickable expand + affordance; when expanded they show the full body plus a collapse + hint. Select-all still uses `_build_full_render` (no hints). + """ + colors = theme.get_theme_colors(self) + + # Use mode-specific prefix indicator when content starts with a + # mode trigger character (e.g. "!" for shell, "/" for commands). + # The display glyph may differ from the trigger (e.g. "$" for shell). + prefix, body = self._prefix_and_body() + parts: list[str | tuple[str, str] | Content] = [prefix] + collapse = _collapse_user_message(body) + + if isinstance(collapse, _UserMessageFull): + self._append_highlighted_body(parts, body, colors=colors) + return Content.assemble(*parts) + + if self._expanded: + self._append_highlighted_body(parts, body, colors=colors) + parts.extend(("\n", self._expand_hint_content())) + return Content.assemble(*parts) + + # Collapsed: head + clickable elision line + tail. The middle marker is + # the affordance (not a second trailing line) so the collapse stays + # one glanceable region instead of an invisible middle ellipsis. Head + # and tail come from the collapse result rather than being re-sliced + # here, so the reported amount always describes the gap on screen. + self._append_highlighted_body(parts, collapse.head, colors=colors) + parts.extend(("\n", self._collapse_hint_content(collapse), "\n")) + self._append_highlighted_body(parts, collapse.tail, colors=colors) + return Content.assemble(*parts) + + +class QueuedUserMessage(Static): + """Widget displaying a queued (pending) user message in grey. + + This is an ephemeral widget that gets removed when the message is dequeued. + """ + + DEFAULT_CSS = """ + QueuedUserMessage { + height: auto; + padding: 0 1; + margin: 0 0 1 0; + background: transparent; + border-left: wide $panel; + opacity: 0.6; + pointer: text; + } + """ + """Dimmed border + reduced opacity to distinguish queued messages from sent ones.""" + + def __init__( + self, content: str, *, detect_mode: bool = True, **kwargs: Any + ) -> None: + """Initialize a queued user message. + + Args: + content: The message content + detect_mode: When `True` (default), a leading mode trigger (`/`, + `!`, `!!`) is rendered with its shell/command glyph. Set to + `False` for text queued as literal agent input (e.g. via + `-m`/`--message`), so a leading slash (like a file path) renders + as a plain user message rather than a slash command. + **kwargs: Additional arguments passed to parent + """ + super().__init__(**kwargs) + self._content = content + self._detect_mode = detect_mode + + def on_mount(self) -> None: + """Add ASCII border class when in ASCII mode.""" + if is_ascii_mode(): + self.add_class("-ascii") + + def get_selection(self, selection: Selection) -> tuple[str, str] | None: + """Return selected text, preferring the full content over the render. + + See `UserMessage.get_selection`: full-message selections extract from + the untruncated content, partial selections defer to the on-screen + render so offsets stay aligned. + + Args: + selection: The active selection geometry. + + Returns: + The `(text, ending)` selection with the prefix removed, or `None`. + """ + if selection.end is not None: + return _strip_prompt_prefix(super().get_selection(selection), selection) + text = str(self._build_full_render()) + return _strip_prompt_prefix((selection.extract(text), "\n"), selection) + + def _prefix_and_body(self) -> tuple[tuple[str, str], str]: + """Compute the muted mode prefix and body with its trigger stripped. + + Returns: + A `(prefix, body)` pair where `prefix` is a `(text, style)` tuple. + """ + colors = theme.get_theme_colors(self) + content = self._content + mode_match = detect_mode_prefix(content) if self._detect_mode else None + if mode_match: + prefix_text, mode = mode_match + glyph = MODE_DISPLAY_GLYPHS.get(mode, prefix_text[0]) + return (f"{glyph} ", f"bold {colors.muted}"), content[len(prefix_text) :] + return ("> ", f"bold {colors.muted}"), content + + def _build_full_render(self) -> Content: + """Build a Content from the full content without display truncation. + + Returns: + Content with the mode prefix glyph and the full message body. + """ + prefix, body = self._prefix_and_body() + return Content.assemble(prefix, body) + + def text_select_all(self) -> None: + """Select the message body without the prompt prefix glyph.""" + _select_prompt_body(self) + + def render(self) -> Content: + """Render the queued user message (greyed out). + + Returns: + Styled Content with dimmed prefix and body. + """ + colors = theme.get_theme_colors(self) + prefix, content = self._prefix_and_body() + content = _truncate_for_display(content) + return Content.assemble(prefix, (content, colors.muted)) + + +def _strip_frontmatter(text: str) -> str: + """Remove YAML frontmatter delimited by `---` markers. + + Args: + text: Raw `SKILL.md` content. + + Returns: + Body text with frontmatter removed and leading whitespace stripped. + """ + stripped = text.lstrip() + if not stripped.startswith("---"): + return text + # Find closing --- (skip the opening line) + end = stripped.find("\n---", 3) + if end == -1: + return text + # Skip past the closing --- and its trailing newline + after = end + 4 # len("\n---") + return stripped[after:].lstrip("\n") + + +class _SkillToggle(Static): + """Clickable header/hint area for toggling skill body expansion. + + Referenced by name in `SkillMessage._on_toggle_click`'s `@on(Click)` + CSS selector — rename with care. + """ + + +class SkillMessage(Vertical): + """Widget displaying a skill invocation with collapsible body. + + Shows skill name, source badge, description, and user args as a compact + header. The full SKILL.md body (frontmatter stripped) is hidden behind a + preview/expand toggle (click or Ctrl+O). The expanded view renders + markdown via Rich's `Markdown` inside a single `Static` widget. + + Visibility is driven by a CSS class (`-expanded`) toggled via a Textual + reactive `var`. Click handlers are scoped to the header and hint widgets + (`_SkillToggle`) so clicks on the rendered markdown body do not trigger + expansion toggles (preserving text selection, for instance). + """ + + DEFAULT_CSS = """ + SkillMessage { + height: auto; + padding: 0 1; + margin: 0 0 1 0; + background: transparent; + border-left: wide $skill; + } + + SkillMessage .skill-header { + height: auto; + } + + SkillMessage .skill-description { + color: $text-muted; + margin-left: 3; + } + + SkillMessage .skill-args { + margin-left: 3; + margin-top: 0; + } + + SkillMessage #skill-md { + margin-left: 3; + margin-top: 0; + padding: 0; + display: none; + } + + SkillMessage .skill-hint { + margin-left: 3; + color: $text-muted; + } + + SkillMessage.-expanded #skill-md { + display: block; + } + + SkillMessage:hover { + border-left: wide $skill-hover; + } + """ + + _PREVIEW_LINES = 4 + _PREVIEW_CHARS = 300 + + _expanded: var[bool] = var(False, toggle_class="-expanded") + + def __init__( + self, + skill_name: str, + description: str = "", + source: str = "", + body: str = "", + args: str = "", + **kwargs: Any, + ) -> None: + """Initialize a skill message. + + Args: + skill_name: Skill identifier. + description: Short description of the skill. + source: Origin label (e.g., `'built-in'`, `'user'`). + body: Full SKILL.md content (frontmatter included). + args: User-provided arguments. + **kwargs: Additional arguments passed to parent. + """ + super().__init__(**kwargs) + self._skill_name = skill_name + self._description = description + self._source = source + self._body = body + self._stripped_body = _strip_frontmatter(body) + self._args = args + self._md_widget: Static | None = None + self._hint_widget: _SkillToggle | None = None + self._deferred_expanded: bool = False + self._md_rendered: bool = False + + def compose(self) -> ComposeResult: + """Compose the skill message layout. + + Yields: + Widgets for header, description, args, and collapsible body. + """ + colors = theme.get_theme_colors() + source_tag = f" [{self._source}]" if self._source else "" + yield _SkillToggle( + Content.styled( + f"/ skill:{self._skill_name}{source_tag}", + f"bold {colors.skill}", + ), + classes="skill-header", + ) + if self._description: + yield _SkillToggle( + Content.styled(self._description, "dim"), + classes="skill-description", + ) + if self._args: + yield Static( + Content.assemble( + ("User request: ", "bold"), + self._args, + ), + classes="skill-args", + ) + yield Static("", id="skill-md") + yield _SkillToggle("", classes="skill-hint", id="skill-hint") + + def on_mount(self) -> None: + """Cache widget references, render initial state. + + Ordering matters: widget refs must be cached before `_prepare_body` + or `_deferred_expanded` assignment, because either may set + `_expanded` which fires `watch__expanded` synchronously. + """ + if is_ascii_mode(): + colors = theme.get_theme_colors(self) + self.styles.border_left = ("ascii", colors.skill) + + self._md_widget = self.query_one("#skill-md", Static) + self._hint_widget = self.query_one("#skill-hint", _SkillToggle) + + body = self._stripped_body.strip() + if body: + self._prepare_body(body) + + if self._deferred_expanded: + self._expanded = self._deferred_expanded + self._deferred_expanded = False + + def _prepare_body(self, body: str) -> None: + """Set initial hint text. Full body render is deferred to first expand. + + Args: + body: Stripped markdown body text. + """ + lines = body.split("\n") + total_lines = len(lines) + needs_truncation = ( + total_lines > self._PREVIEW_LINES or len(body) > self._PREVIEW_CHARS + ) + + if needs_truncation: + remaining = total_lines - self._PREVIEW_LINES + ellipsis = get_glyphs().ellipsis + if self._hint_widget: + self._hint_widget.update( + Content.styled( + f"{ellipsis} {remaining} more lines" + " — click or Ctrl+O to expand", + "dim italic", + ) + ) + else: + # Short body — show fully rendered, no preview needed. + self._ensure_md_rendered(body) + self._expanded = True + + def _ensure_md_rendered(self, body: str) -> None: + """Render markdown into the Static widget on first call, then no-op. + + Args: + body: Stripped markdown body text. + """ + if self._md_rendered or not self._md_widget: + return + try: + from rich.markdown import Markdown as RichMarkdown + + self._md_widget.update(RichMarkdown(body)) + except Exception: + logger.warning( + "Failed to render skill body as markdown; falling back to plain text", + exc_info=True, + ) + self._md_widget.update(body) + self._md_rendered = True + + def toggle_body(self) -> None: + """Toggle between preview and full body display.""" + if not self._stripped_body.strip(): + return + self._expanded = not self._expanded + + def watch__expanded(self, expanded: bool) -> None: + """Lazy-render markdown on first expand; update hint text.""" + body = self._stripped_body.strip() + if not body: + return + + if expanded: + self._ensure_md_rendered(body) + + if not self._hint_widget: + return + + lines = body.split("\n") + total_lines = len(lines) + needs_truncation = ( + total_lines > self._PREVIEW_LINES or len(body) > self._PREVIEW_CHARS + ) + + if not needs_truncation: + # Short body — always fully visible, no hint needed. + self._hint_widget.display = False + return + + if expanded: + self._hint_widget.update( + Content.styled("click or Ctrl+O to collapse", "dim italic") + ) + else: + remaining = total_lines - self._PREVIEW_LINES + ellipsis = get_glyphs().ellipsis + self._hint_widget.update( + Content.styled( + f"{ellipsis} {remaining} more lines — click or Ctrl+O to expand", + "dim italic", + ) + ) + + @on(Click, "_SkillToggle") + def _on_toggle_click(self, event: Click) -> None: + """Toggle expansion when header or hint is clicked.""" + event.stop() + if self._stripped_body.strip(): + self.toggle_body() + + +class AssistantMessage(Vertical): + """Widget displaying an assistant message with markdown support. + + Uses MarkdownStream for smoother streaming instead of re-rendering + the full content on each update. Once a stream finishes, the message + is re-rendered from the complete source via `Markdown.update()` to + work around Textualize/textual#6518: `MarkdownFence._update_from_block` + refreshes the visible `Label` but leaves `_highlighted_code` pinned to + the first chunk, so any later recompose (click, focus change, theme + update) re-yields the stale value and wrapped fenced-code bodies vanish. + A full re-parse rebuilds every fence with correct internal state. + + Streamed tokens are coalesced in `_pending_append` and flushed to the + `MarkdownStream` on a throttled timer (`_STREAM_FLUSH_INTERVAL`). Writing + every token immediately forced a markdown re-parse per chunk on the UI + event loop, which starved keyboard input while the model streamed. + Batching the writes keeps the event loop free so typing stays responsive. + """ + + _STREAM_FLUSH_INTERVAL: ClassVar[float] = 0.1 + """Seconds between coalesced flushes of streamed text to the markdown widget.""" + + DEFAULT_CSS = """ + AssistantMessage { + height: auto; + padding: 0 1; + margin: 0 0 1 0; + } + + AssistantMessage Markdown { + padding: 0; + margin: 0; + pointer: text; + } + + /* Markdown blocks carry a bottom margin for inter-block spacing; drop it + on the final block so the message has no trailing blank row. */ + AssistantMessage Markdown > *:last-child { + margin-bottom: 0; + } + """ + + def __init__(self, content: str = "", **kwargs: Any) -> None: + """Initialize an assistant message. + + Args: + content: Initial markdown content + **kwargs: Additional arguments passed to parent + """ + super().__init__(**kwargs) + self._content_parts: list[str] = [content] if content else [] + self._markdown: Markdown | None = None + self._stream: MarkdownStream | None = None + self._pending_append = "" + self._flush_timer: Timer | None = None + + @property + def _content(self) -> str: + """Full message text, materialized from streamed chunks on access.""" + if len(self._content_parts) > 1: + self._content_parts = ["".join(self._content_parts)] + return self._content_parts[0] if self._content_parts else "" + + @_content.setter + def _content(self, value: str) -> None: + self._content_parts = [value] if value else [] + + def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual widget method convention + """Compose the assistant message layout. + + Yields: + Markdown widget for rendering assistant content. + """ + from textual.widgets import Markdown + + yield Markdown("", id="assistant-content", open_links=False) + + def on_mount(self) -> None: + """Store reference to markdown widget.""" + from textual.widgets import Markdown + + self._markdown = self.query_one("#assistant-content", Markdown) + + def on_mouse_move(self, event: MouseMove) -> None: + """Show a pointer cursor over markdown links, text cursor elsewhere. + + The pointer is set on the inner `Markdown` widget because it carries a + non-default (`text`) pointer in CSS, so the screen resolves its shape + before reaching this container. + """ + if self._markdown is not None: + self._markdown.styles.pointer = ( + "pointer" if event_targets_link(event) else "text" + ) + + def on_leave(self) -> None: + """Reset the markdown pointer shape when the mouse leaves the message.""" + if self._markdown is not None: + self._markdown.styles.pointer = "text" + + async def on_markdown_link_clicked(self, event: Markdown.LinkClicked) -> None: + """Open Markdown links with the same toast feedback as style links.""" + event.stop() + await open_checked_url_async(event.href, app=self.app, notify_on_success=True) + + def _get_markdown(self) -> Markdown: + """Get the markdown widget, querying if not cached. + + Returns: + The Markdown widget for this message. + """ + if self._markdown is None: + from textual.widgets import Markdown + + self._markdown = self.query_one("#assistant-content", Markdown) + return self._markdown + + def _ensure_stream(self) -> MarkdownStream: + """Ensure the markdown stream is initialized. + + Returns: + The MarkdownStream instance for streaming content. + """ + if self._stream is None: + from textual.widgets import Markdown + + self._stream = Markdown.get_stream(self._get_markdown()) + return self._stream + + async def append_content(self, text: str) -> None: + """Append streamed content, coalescing writes onto a throttled timer. + + Tokens are buffered in `_pending_append` and written to the + `MarkdownStream` at most once per `_STREAM_FLUSH_INTERVAL` so the UI + event loop stays free to process keypresses while the model streams. + + Args: + text: Text to append + """ + if not text: + return + self._content_parts.append(text) + self._pending_append += text + if self._flush_timer is None: + self._flush_timer = self.set_interval( + self._STREAM_FLUSH_INTERVAL, self._flush_pending_append + ) + + async def _flush_pending_append(self) -> None: + """Write any buffered streamed text to the markdown stream. + + Runs from a Textual timer callback, where an unhandled exception + escalates to `App._handle_exception` and tears down the whole REPL. + On a transient write failure the buffer is restored (re-prepended + ahead of any text that arrived in the meantime) so the next tick + retries instead of silently dropping the fragment. + """ + if not self._pending_append: + return + pending = self._pending_append + self._pending_append = "" + try: + stream = self._ensure_stream() + await stream.write(pending) + except Exception: # a render hiccup must not crash the app + self._pending_append = pending + self._pending_append + logger.exception("Failed to flush streamed markdown fragment") + + def _stop_flush_timer(self) -> None: + """Cancel the coalescing flush timer if it is running.""" + if self._flush_timer is not None: + self._flush_timer.stop() + self._flush_timer = None + + async def write_initial_content(self) -> None: + """Write initial content if provided at construction time.""" + if self._content: + await self._get_markdown().update(self._content) + + async def stop_stream(self) -> None: + """Stop the streaming and finalize the content.""" + self._stop_flush_timer() + await self._flush_pending_append() + if self._stream is not None: + await self._stream.stop() + self._stream = None + await self._get_markdown().update(self._content) + + async def set_content(self, content: str) -> None: + """Set the full message content. + + Cancels any active stream and renders the new content with a + single `Markdown.update()` (avoiding a redundant intermediate + update of the in-flight content). + + Args: + content: The markdown content to display + """ + self._stop_flush_timer() + self._pending_append = "" + if self._stream is not None: + await self._stream.stop() + self._stream = None + self._content = content + if self._markdown: + await self._markdown.update(content) + + +_ToolStatus = Literal["pending", "running", "success", "error", "rejected", "skipped"] +"""The full set of lifecycle states a tool call can hold. + +Kept as a closed `Literal` so `ty` flags typos at the assignment sites and so +the grouping predicates (`is_success`/`is_failed`/`is_pending`) partition a +known universe. +""" + +_TOOL_AWAITING_APPROVAL_ACCESSORY_CLASS = "-tool-awaiting-approval-accessory" +"""Marker class hiding a tool's accessories while an approval prompt replaces it. + +Deliberately distinct from `_TOOL_GROUP_COLLAPSED_ACCESSORY_CLASS`: a footer can +be hidden for more than one reason at once, and releasing one reason must not +un-hide a footer still hidden by another. Merging reasons into a single class +would make +`ToolGroupSummary._release_collapsible` reveal a footer whose tool is still +hidden behind an approval prompt. + +Applied with `set_class` rather than by assigning `display`. An inline `display` +permanently outranks the CSS cascade, so assigning it here would strand the +footer against the user's `/timestamps` preference forever. Styled in +`app.tcss`, which relies on rule order to win the specificity tie against that +preference's own class. +""" + +_TOOL_SUPERSEDED_ACCESSORY_CLASS = "-tool-superseded-accessory" +"""Hides a row's decorations when its diff has taken the row's place. + +A third, independent hide reason. See `_TOOL_AWAITING_APPROVAL_ACCESSORY_CLASS` +for why each reason gets its own class (they must release independently) and +why it is applied with `set_class` rather than `display`. +""" + + +class ToolCallMessage(Vertical): + """Widget displaying a tool call with collapsible output. + + Tool outputs are shown as a 3-line preview by default. + Press Ctrl+O to expand/collapse the full output. + Shows an animated "Running..." indicator while the tool is executing. + """ + + DEFAULT_CSS = """ + ToolCallMessage { + height: auto; + padding: 0 1; + margin: 0 0 1 0; + background: transparent; + border-left: wide $tool; + } + + ToolCallMessage .tool-header { + height: auto; + color: $tool; + text-style: bold; + } + + ToolCallMessage .tool-task-desc { + color: $text-muted; + margin-left: 3; + text-style: italic; + } + + ToolCallMessage .tool-args { + color: $text-muted; + margin-left: 3; + } + + ToolCallMessage .tool-status { + margin-left: 3; + } + + ToolCallMessage .tool-status.pending { + color: $warning; + } + + ToolCallMessage .tool-status.success { + color: $success; + } + + ToolCallMessage .tool-status.error { + color: $error; + } + + ToolCallMessage .tool-status.rejected { + color: $warning; + } + + ToolCallMessage .tool-reject-reason { + margin-left: 3; + margin-top: 0; + height: auto; + color: $text-muted; + } + + ToolCallMessage .tool-output-row { + layout: horizontal; + height: auto; + width: 1fr; + } + + /* Fixed gutter holds the output glyph so soft-wrapped content lines stay + aligned to a single hanging indent instead of falling under the glyph. */ + ToolCallMessage .tool-output-gutter { + width: 2; + height: 1; + color: $text-muted; + } + + ToolCallMessage .tool-output { + margin-left: 0; + margin-top: 0; + padding: 0; + height: auto; + width: 1fr; + } + + ToolCallMessage .tool-output-preview { + margin-left: 0; + margin-top: 0; + width: 1fr; + } + + ToolCallMessage .tool-output-hint { + margin-left: 0; + color: $text-muted; + } + + /* Terminal outcome tints the row: green success, red error, amber + rejected/skipped. A faint background keeps text readable across + light/dark/ansi themes while the border carries the primary signal. */ + ToolCallMessage.-status-success { + border-left: wide $success; + background: $success 8%; + } + + ToolCallMessage.-status-error { + border-left: wide $error; + background: $error 10%; + } + + ToolCallMessage.-status-rejected, + ToolCallMessage.-status-skipped { + border-left: wide $warning; + background: $warning 8%; + } + + ToolCallMessage:hover { + border-left: wide $tool-hover; + } + """ + """Left border tracks tool lifecycle; hover brightens for interactivity.""" + + _PREVIEW_LINES = 6 + """Maximum number of lines to show in preview mode.""" + + _PREVIEW_CHARS = 400 + """Maximum number of characters to show in preview mode.""" + + _JS_EVAL_INLINE_RESULT_MAX = 80 + """Maximum single-line `js_eval` result length rendered inline. + + Inline rendering uses `result: value` rather than a standalone labeled block. + """ + + _TASK_DESC_MAX_LENGTH = 120 + """Maximum `task` description length shown before it is truncated. + + A longer description collapses to at most this many characters (trailing + whitespace trimmed) with a trailing ellipsis and becomes expandable via + click or Ctrl+O. + """ + + _RUNNING_TIMER_THRESHOLD_SECS = 10 + """Seconds a tool must run before the elapsed-time counter appears. + + Short tool calls finish well under this threshold, so the timer would only + flicker on briefly; suppressing it until the tool is genuinely slow keeps + the "Running..." row quiet for the common case. + """ + + def __init__( + self, + tool_name: str, + args: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Initialize a tool call message. + + Args: + tool_name: Name of the tool being called + args: Tool arguments (optional) + **kwargs: Additional arguments passed to parent + """ + super().__init__(**kwargs) + self._tool_name = tool_name + self._args = args or {} + self._status: _ToolStatus = "pending" # Waiting for approval or auto-approve + self._output: str = "" + self._expanded: bool = False + self._args_expanded: bool = False + self._task_desc_expanded: bool = False + # User-provided reason attached to a HITL reject decision (if any). + self._reject_reason: str | None = None + # Widget references (set in on_mount) + self._status_widget: Static | None = None + self._header_widget: Static | None = None + self._task_desc_widget: Static | None = None + self._task_desc_hint_widget: Static | None = None + self._args_widget: Static | None = None + self._args_hint_widget: Static | None = None + self._preview_widget: Static | None = None + self._preview_row: Horizontal | None = None + self._hint_widget: Static | None = None + self._full_widget: Static | None = None + self._full_row: Horizontal | None = None + self._reject_reason_widget: Static | None = None + # Animation state + self._spinner_position = 0 + self._start_time: float | None = None + self._duration: float | None = None + self._animation_timer: Timer | None = None + # Terminal success this row earned but has not rendered. See + # `defer_success`; `_deferred_success_settled` separates "still awaiting + # the richer result" from "already fell back to the summary". + self._deferred_success_output: str | None = None + self._deferred_success_settled: bool = False + # One-shot guard so `_format_ask_user_output` reports unusable `questions` + # args once per widget rather than on every re-render. + self._ask_user_args_warned: bool = False + # Deferred state for hydration (set by MessageData.to_widget) + self._deferred_status: str | None = None + self._deferred_output: str | None = None + self._deferred_duration: float | None = None + self._deferred_expanded: bool = False + self._deferred_reject_reason: str | None = None + # Whether the widget is currently hidden because an approval prompt + # is rendering the same content (see `set_awaiting_approval`). + self._awaiting_approval: bool = False + # Transcript decorations that must follow approval visibility without + # losing their independent user-controlled visibility state. + self._visibility_accessories: list[Widget] = [] + self._diff_superseded: bool = False + self._self_hidden: bool = False + self._has_display_caveat: bool = False + + def compose(self) -> ComposeResult: + """Compose the tool call message layout. + + Yields: + Widgets for header, arguments, status, and output display. + """ + tool_label = format_tool_display(self._tool_name, self._args) + yield Static(tool_label, markup=False, classes="tool-header", id="tool-header") + # Task: dedicated description line (dim, truncated). A long description + # collapses to a truncated preview that expands on click or Ctrl+O. + if self._tool_name == "task": + if self._task_description(): + yield Static( + self._task_desc_content(), + classes="tool-task-desc", + id="task-desc", + ) + yield Static("", classes="tool-output-hint", id="task-desc-hint") + # Only show args for tools where header doesn't capture the key info + elif self._tool_name not in _TOOLS_WITH_HEADER_INFO: + args = self._filtered_args() + if args: + args_str = ", ".join( + f"{k}={v!r}" for k, v in list(args.items())[:_MAX_INLINE_ARGS] + ) + if len(args) > _MAX_INLINE_ARGS: + args_str += ", ..." + yield Static( + Content.from_markup("[dim]($args)[/dim]", args=args_str), + classes="tool-args", + ) + # Collapsed argument detail for tools whose args are too noisy inline. + # Mounted for every tool but only populated when `has_expandable_args` is True. + yield Static("", classes="tool-args", id="args-full") + yield Static("", classes="tool-output-hint", id="args-hint") + # Status - shows running animation while pending, then final status + yield Static("", classes="tool-status", id="status") + # Optional HITL reject reason (only shown when user rejected with a message) + yield Static("", classes="tool-reject-reason", id="reject-reason") + # Output area - hidden initially, shown when output is set. The glyph + # lives in a fixed-width gutter so wrapped content aligns to a single + # hanging indent rather than wrapping back under the glyph. + output_prefix = get_glyphs().output_prefix + yield Horizontal( + Static(output_prefix, classes="tool-output-gutter"), + Static("", classes="tool-output-preview", id="output-preview"), + classes="tool-output-row", + id="output-preview-row", + ) + yield Horizontal( + Static(output_prefix, classes="tool-output-gutter"), + Static("", classes="tool-output", id="output-full"), + classes="tool-output-row", + id="output-full-row", + ) + yield Static("", classes="tool-output-hint", id="output-hint") + + def on_mount(self) -> None: + """Cache widget references and hide all status/output areas initially.""" + if is_ascii_mode(): + self.add_class("-ascii") + + self._status_widget = self.query_one("#status", Static) + self._header_widget = self.query_one("#tool-header", Static) + try: + self._task_desc_widget = self.query_one("#task-desc", Static) + self._task_desc_hint_widget = self.query_one("#task-desc-hint", Static) + except NoMatches: + # Only mounted for `task` calls that carry a description. + self._task_desc_widget = None + self._task_desc_hint_widget = None + self._args_widget = self.query_one("#args-full", Static) + self._args_hint_widget = self.query_one("#args-hint", Static) + self._preview_widget = self.query_one("#output-preview", Static) + self._preview_row = self.query_one("#output-preview-row", Horizontal) + self._hint_widget = self.query_one("#output-hint", Static) + self._full_widget = self.query_one("#output-full", Static) + self._full_row = self.query_one("#output-full-row", Horizontal) + self._reject_reason_widget = self.query_one("#reject-reason", Static) + # Hide everything initially - status only shown when running or on error/reject + self._status_widget.display = False + self._args_widget.display = False + self._args_hint_widget.display = False + self._preview_row.display = False + self._hint_widget.display = False + self._full_row.display = False + self._reject_reason_widget.display = False + self._update_args_display() + self._update_task_desc_display() + + # Restore deferred state if this widget was hydrated from data + self._restore_deferred_state() + # `to_widget` sets `_diff_superseded` before mount, but not every + # `_restore_deferred_state` branch applies visibility. Applied here so + # hiding does not depend on which branch a tool takes. + self._apply_own_visibility() + + def _restore_deferred_state(self) -> None: + """Restore state from deferred values (used when hydrating from data).""" + if self._deferred_status is None: + return + + status = self._deferred_status + output = self._deferred_output or "" + duration = self._deferred_duration + self._expanded = self._deferred_expanded + if self._deferred_reject_reason: + self._reject_reason = self._deferred_reject_reason + + # Clear deferred values + self._deferred_status = None + self._deferred_output = None + self._deferred_duration = None + self._deferred_expanded = False + self._deferred_reject_reason = None + + # Restore based on status (don't restart animations for running tools) + colors = theme.get_theme_colors(self) + match status: + case "success": + self._status = "success" + self._output = output + self._duration = duration + self._apply_status_class("success") + if self._tool_name in _TIMED_SUCCESS_TOOLS and duration is not None: + self._show_timed_success_status(duration) + else: + self._show_success_status() + self._update_output_display() + case "error": + self._status = "error" + self._output = output + self._apply_status_class("error") + if self._status_widget: + self._status_widget.add_class("error") + error_icon = get_glyphs().error + self._status_widget.update( + Content.styled(f"{error_icon} Error", colors.error) + ) + self._status_widget.display = True + self._update_output_display() + case "rejected": + self._status = "rejected" + self._apply_status_class("rejected") + if self._status_widget: + self._status_widget.add_class("rejected") + error_icon = get_glyphs().error + self._status_widget.update( + Content.styled(f"{error_icon} Rejected", colors.warning) + ) + self._status_widget.display = True + self._update_reject_reason_display() + case "skipped": + self._status = "skipped" + self._apply_status_class("skipped") + if self._status_widget: + self._status_widget.add_class("rejected") + self._status_widget.update(Content.styled("- Skipped", "dim")) + self._status_widget.display = True + case "running": + # For running tools, show static "Running..." without animation + # (animations shouldn't be restored for archived tools) + self._status = "running" + if self._status_widget: + self._status_widget.add_class("pending") + frame = get_glyphs().spinner_frames[0] + self._status_widget.update( + Content.styled(f"{frame} Running...", colors.warning) + ) + self._status_widget.display = True + case _: + # pending or unknown - leave as default + pass + + def set_running(self) -> None: + """Mark the tool as running (approved and executing). + + Call this when approval is granted to start the running animation. + """ + if self._status == "running": + return # Already running + + self._status = "running" + self._duration = None + self._start_time = time() + if self._status_widget: + self._status_widget.add_class("pending") + self._status_widget.display = True + self._update_running_animation() + self._animation_timer = self.set_interval(0.1, self._update_running_animation) + + def _update_running_animation(self) -> None: + """Update the running spinner animation.""" + if self._status != "running" or self._status_widget is None: + return + + spinner_frames = get_glyphs().spinner_frames + frame = spinner_frames[self._spinner_position] + self._spinner_position = (self._spinner_position + 1) % len(spinner_frames) + + elapsed = "" + if self._start_time is not None: + elapsed_secs = int(time() - self._start_time) + if elapsed_secs >= self._RUNNING_TIMER_THRESHOLD_SECS: + elapsed = f" ({format_duration(elapsed_secs)})" + + text = f"{frame} Running...{elapsed}" + self._status_widget.update( + Content.styled(text, theme.get_theme_colors(self).warning) + ) + + def pause_running(self) -> None: + """Pause the running spinner while the tool awaits a user decision. + + Reverts the row to its pending appearance (status hidden) and stops the + animation so a tool blocked on HITL approval or `ask_user` input does + not misleadingly display "Running...". Resume with `set_running`, which + restarts the elapsed timer from the moment execution actually begins. + """ + if self._status != "running": + return + self._stop_animation() + self._status = "pending" + self._start_time = None + if self._status_widget: + self._status_widget.remove_class("pending") + self._status_widget.display = False + + def _stop_animation(self) -> None: + """Stop the running animation.""" + if self._animation_timer is not None: + self._animation_timer.stop() + self._animation_timer = None + + def _apply_status_class(self, status: str) -> None: + """Tint the whole row to match a terminal outcome. + + Swaps the `-status-*` CSS class so the row border and background + reflect success/error/rejected/skipped. Running and pending states keep + the default `$tool` accent, so they clear any prior status class. + + Args: + status: Terminal status name (`success`, `error`, `rejected`, + `skipped`); any other value clears the tint. + """ + for name in _STATUS_CLASSES: + self.remove_class(name) + class_name = f"-status-{status}" + if class_name in _STATUS_CLASSES: + self.add_class(class_name) + + def defer_success(self, output: AskUserRowSummary) -> None: + """Record a terminal success this row earned but has not yet rendered. + + An answered `ask_user` deliberately stays in `_current_tool_messages` so + the streamed `ToolMessage` can settle it with the full Q&A transcript. + That leaves the row non-terminal in the meantime, and every teardown + sweep treats a still-tracked row as a failure — so without this the row + renders as rejected or as an agent error, and its `tool.result` reports + `tool_status="error"`, for a question the user answered normally. + + Args: + output: Summary to settle with if the `ToolMessage` never arrives. + Narrowed to `AskUserRowSummary` because `_format_ask_user_output` + recognizes exactly those values as "no transcript behind this row" + and suppresses the expand affordance for them. Passing the + transcript here would strand it unreadable on the row. + """ + self._deferred_success_output = output + self._deferred_success_settled = False + + @property + def deferred_success_output(self) -> str | None: + """Terminal output for a row that earned a success it did not render. + + Set while the row awaits its richer result and deliberately kept after a + fallback settle, because a settled row can still be tracked in + `_current_tool_messages` and swept again later (`textual_adapter`'s + `finally` backstop). `_dispatch_terminal_tool_result_hooks` reads this as + the "this row already succeeded" flag, so clearing it on settle would make + that later sweep report a fabricated failure. + """ + return self._deferred_success_output + + @property + def is_awaiting_deferred_result(self) -> bool: + """Whether this row still expects a richer result to replace its summary. + + Distinct from `deferred_success_output`, which stays set after a fallback + settle. Callers that must not act on an already-settled row — recovering + an interrupted turn's `tool_calls`, or imposing a terminal failure — ask + this instead. + """ + return self._deferred_success_output is not None and ( + not self._deferred_success_settled + ) + + def clear_deferred_success(self) -> None: + """Drop the deferred outcome once an authoritative result supersedes it. + + Called when the streamed `ToolMessage` settles the row, so its real + status wins — including an error, which `set_error` would otherwise + redirect back to the deferred success. + """ + self._deferred_success_output = None + self._deferred_success_settled = False + + def settle_deferred_success(self) -> bool: + """Settle this row with its deferred success, if it is awaiting one. + + Idempotent: a row that already fell back returns False rather than + re-rendering, so callers need no `is_awaiting_deferred_result` guard of + their own. Records that the fallback fired but keeps the output — see + `deferred_success_output` for why a later sweep still needs to read it. + + Returns: + True if the row was settled. False if it had no deferred outcome, has + already settled, or is rejected/skipped so `set_success` would + ignore it — in each case the caller should record its own terminal + state. + """ + output = self._deferred_success_output + if output is None or self._deferred_success_settled: + # Mirrors `is_awaiting_deferred_result`, spelled out so the type + # checker can narrow `output` to `str`. + return False + if self._status in {"rejected", "skipped"}: + return False + # Before `set_success`, which re-renders synchronously. Nothing in that + # render path reads this flag today (`_format_ask_user_output` derives + # "no transcript" from the output value instead, so the suppression also + # survives rehydration), but ordering the flag first keeps the object + # consistent for anything the render does reach. + self._deferred_success_settled = True + self.set_success(output) + return True + + def set_success(self, result: str = "") -> None: + """Mark the tool call as successful. + + For long-running tools (`execute`, `task`) that actually ran (a start + time was recorded via `set_running`), the elapsed run time is shown via + `_show_timed_success_status`; every other case routes through + `_show_success_status`. + + Args: + result: Tool output/result to display + """ + if self._status in {"rejected", "skipped"}: + # A rejected tool (or one skipped due to a sibling rejection) never + # legitimately becomes successful. A resumed turn can still stream a + # synthetic ToolMessage for such a tool (see the reasoned-reject path + # in `textual_adapter`); ignore it so the row keeps its terminal + # rejected/skipped state instead of flipping. + return + elapsed = time() - self._start_time if self._start_time is not None else None + self._stop_animation() + self._status = "success" + # This call owns `_output`, so any caveat a previous completion put + # there is gone. Clearing here keeps the flag from outliving the + # sentence it describes and leaving the row unfoldable for no visible + # reason; `set_success_with_caveat` re-sets it after delegating here. + self._has_display_caveat = False + self._duration = ( + elapsed + if self._tool_name in _TIMED_SUCCESS_TOOLS and elapsed is not None + else None + ) + # Strip redundant command success trailers — the UI already conveys + # success. `ask_user` output is a user-authored Q&A transcript, though, + # so text that resembles a command trailer must remain verbatim. + self._output = ( + result + if self._tool_name == "ask_user" + else _strip_success_exit_line(result) + ) + self._apply_status_class("success") + if self._duration is not None: + self._show_timed_success_status(self._duration) + else: + self._show_success_status() + self._update_output_display() + + def _show_timed_success_status(self, duration: float) -> None: + """Render the preserved duration for a completed timed tool call. + + Args: + duration: Elapsed tool run time in seconds. + """ + if self._status_widget is None: + return + self._status_widget.remove_class("pending") + self._status_widget.update( + Content.styled(f"Took {format_duration(duration)}", "dim") + ) + self._status_widget.display = True + + def _show_success_status(self) -> None: + """Render the status marker for a completed successful call. + + When the call produces visible output it speaks for itself and the + status stays hidden; otherwise show a "Success!" marker so a completed + call isn't left without any outcome indicator. A row already marked as + replaced by a mounted diff hides entirely. + """ + if self._status_widget is None: + return + self._status_widget.remove_class("pending") + if self._superseded_by_diff: + self._apply_own_visibility() + return + if self._format_output(self._output, is_preview=False).content.plain.strip(): + self._status_widget.remove_class("success") + self._status_widget.display = False + return + glyph = get_glyphs().checkmark + colors = theme.get_theme_colors(self) + self._status_widget.add_class("success") + self._status_widget.update(Content.styled(f"{glyph} Success!", colors.success)) + self._status_widget.display = True + + @staticmethod + def can_be_superseded(tool_name: str | None) -> bool: + """Return whether a diff may stand in for this tool's row. + + The public form of the `_TOOL_SUPERSEDED_BY_DIFF` check, so the adapter + does not reach for a private constant to ask the same question from a + different name source. `mark_superseded_by_diff` still enforces it — this + only lets a caller avoid tripping the warning. + + Args: + tool_name: Raw name of the tool that produced the row. + + Returns: + Whether a mounted `DiffMessage` may hide the row. + """ + return tool_name == _TOOL_SUPERSEDED_BY_DIFF + + def mark_superseded_by_diff(self) -> None: + """Hide a successful file-tool row after its diff has mounted. + + Rejects any tool other than `_TOOL_SUPERSEDED_BY_DIFF`, leaving the row + visible and logging at warning — so this is not safe to call + speculatively. That guard is load-bearing rather than defensive: + `MessageStore.to_widget` routes a stored flag through this method + precisely to inherit it, so rehydration cannot hide a row the live path + would have left visible. + """ + if self._tool_name != _TOOL_SUPERSEDED_BY_DIFF: + # A broken invariant, not a routine skip: the caller decided this row + # was superseded from a *different* name source (the adapter gates on + # `record.tool_name`), so a divergence leaves an empty-bodied diff + # rendering "no changes" beside a row that stayed visible. + logger.warning( + "mark_superseded_by_diff called on %r; only %r may be superseded", + self._tool_name, + _TOOL_SUPERSEDED_BY_DIFF, + ) + return + self._diff_superseded = True + self._apply_own_visibility() + + def set_error(self, error: str) -> None: + """Mark the tool call as failed. + + Args: + error: Error message + """ + if self._status in {"rejected", "skipped"}: + # A rejected/skipped tool never legitimately errors. A resumed turn + # can stream a synthetic error ToolMessage for a reasoned-reject tool + # (see `textual_adapter`); ignore it so the row keeps its rejected + # state rather than flipping to "Error" (which also left the stale + # `rejected` CSS class alongside `error`). + return + if self.settle_deferred_success(): + # A teardown sweep imposing a generic failure on a row that already + # succeeded (an answered `ask_user` awaiting its transcript). The + # authoritative `ToolMessage` calls `clear_deferred_success` first, so + # a *real* tool error still lands below. `settle_deferred_success` is + # idempotent, so the redirect fires once: a row that already fell back + # keeps no immunity against a later genuine error. + # + # INFO, not DEBUG: turning a failure into a success is the single + # highest-stakes decision on this path, and the always-on debug ring + # buffer that backs the in-app console only captures INFO and above. + logger.info( + "Suppressed error on tool row with a deferred success: %s", error + ) + return + self._stop_animation() + self._status = "error" + self._apply_status_class("error") + # Not a no-op: `_superseded_by_diff` is gated on success, so this is what + # reveals a row that was hidden behind a diff before its status flipped. + self._apply_own_visibility() + # For shell commands, prepend the full command so users can see what failed + command = self._args.get("command") if self._tool_name == "execute" else None + if command and isinstance(command, str) and command.strip(): + self._output = f"$ {command}\n\n{error}" + else: + self._output = error + if self._status_widget: + self._status_widget.remove_class("pending") + self._status_widget.add_class("error") + error_icon = get_glyphs().error + colors = theme.get_theme_colors(self) + self._status_widget.update( + Content.styled(f"{error_icon} Error", colors.error) + ) + self._status_widget.display = True + # Always show full error - errors should be visible + self._expanded = True + self._update_output_display() + + def set_rejected(self, *, reason: str | None = None) -> None: + """Mark the tool call as rejected by user. + + Args: + reason: Optional free-text reason supplied via the HITL reject + widget; rendered as a dim line beneath the status. + """ + if self.settle_deferred_success(): + # A turn-cancel sweep rejecting every tracked row; an answered + # `ask_user` among them still succeeded, so it keeps its own outcome. + # (Interrupt rejections leave these rows tracked instead — see + # `_pop_rows_not_awaiting_deferred_result`.) INFO for the same reason + # as the redirect in `set_error`. + logger.info( + "Suppressed rejection on tool row with a deferred success: %s", reason + ) + return + self._stop_animation() + self._status = "rejected" + self._apply_status_class("rejected") + if reason and reason.strip(): + self._reject_reason = reason.strip() + if self._status_widget: + self._status_widget.remove_class("pending") + self._status_widget.add_class("rejected") + error_icon = get_glyphs().error + text = f"{error_icon} Rejected" + colors = theme.get_theme_colors(self) + self._status_widget.update(Content.styled(text, colors.warning)) + self._status_widget.display = True + self._update_reject_reason_display() + + def _update_reject_reason_display(self) -> None: + """Render the rejection reason line if a reason is set.""" + if self._reject_reason_widget is None: + return + if self._reject_reason: + self._reject_reason_widget.update( + Content.from_markup( + "[dim italic]Reason: $reason[/dim italic]", + reason=self._reject_reason, + ) + ) + self._reject_reason_widget.display = True + else: + self._reject_reason_widget.display = False + + def set_skipped(self) -> None: + """Mark the tool call as skipped (due to another rejection).""" + self._stop_animation() + self._status = "skipped" + self._apply_status_class("skipped") + if self._status_widget: + self._status_widget.remove_class("pending") + self._status_widget.add_class("rejected") # Use same styling as rejected + self._status_widget.update(Content.styled("- Skipped", "dim")) + self._status_widget.display = True + + def set_awaiting_approval(self) -> None: + """Hide the tool call while an approval prompt mirrors its content. + + Used to avoid showing the same shell command in both the streamed tool + call header and the HITL approval dialog at the same time. The widget + is restored via `clear_awaiting_approval` once the user decides. + """ + self._awaiting_approval = True + self._apply_own_visibility() + + def clear_awaiting_approval(self) -> None: + """Restore the tool call after `set_awaiting_approval`. + + No-op if `set_awaiting_approval` was not previously called, so the + method is safe to call unconditionally from a `finally` block. + """ + if not self._awaiting_approval: + return + self._awaiting_approval = False + self._apply_own_visibility() + + def _register_visibility_accessories(self, *accessories: Widget) -> None: + """Link transcript decorations whose visibility follows this tool. + + Idempotent: `Widget` uses identity equality, so re-registering the same + accessory (a regroup folding an already-folded tool) cannot double-add. + """ + for accessory in accessories: + if accessory not in self._visibility_accessories: + self._visibility_accessories.append(accessory) + self._sync_own_hide_accessories() + + @property + def has_own_hide_reason(self) -> bool: + """Whether this row hides itself, regardless of any group collapse. + + Group code must consult this before revealing a row: the two mechanisms + are independent, so an unconditional `display = True` would reveal a row + that is hiding for its own reasons. + """ + return self._awaiting_approval or self._superseded_by_diff + + def _apply_own_visibility(self) -> None: + """Apply self-hide reasons without disturbing group visibility. + + Only touches `display` when a self-hide reason applies or is being + released — a row with no self-hide history is left exactly as the group + set it. Releasing is the narrower guarantee: it restores `display` to + `True` unconditionally, so a row that was *both* group-collapsed and + self-hidden would reveal itself into a collapsed group. Not reachable + today (the one supersedable tool is group-excluded, and rows awaiting + approval are evicted rather than folded), but a new hide reason that can + coexist with a group must consult the group's state here. + + The other direction is `has_own_hide_reason`, which group code checks + before revealing — and which this reads, so the set of hide reasons is + defined in exactly one place and a new one cannot be honoured by the + group checks while being ignored here. + """ + if self.has_own_hide_reason: + self.display = False + self._self_hidden = True + elif self._self_hidden: + self.display = True + self._self_hidden = False + self._sync_own_hide_accessories() + + def _sync_own_hide_accessories(self) -> None: + """Mirror this row's hide reasons onto linked decorations. + + Tests each reason individually rather than reading `has_own_hide_reason`: + each carries its own class so the reasons release independently, which the + aggregate cannot express. A new hide reason needs a class and an entry + here as well as a term in `has_own_hide_reason`. + """ + for accessory in self._visibility_accessories: + accessory.set_class( + self._awaiting_approval, + _TOOL_AWAITING_APPROVAL_ACCESSORY_CLASS, + ) + accessory.set_class( + self._superseded_by_diff, + _TOOL_SUPERSEDED_ACCESSORY_CLASS, + ) + + def toggle_output(self) -> None: + """Toggle expansion of the tool's preview/full output.""" + if not self._output: + return + # No-op in both directions when nothing is hidden: the collapsed and + # expanded forms are identical, so toggling only flickers the hint. + # This also covers force-expanded errors (see `set_error`). + if not self._has_expandable_output(): + return + self._expanded = not self._expanded + self._update_output_display() + + def toggle_args(self) -> None: + """Toggle display of collapsed tool arguments.""" + if not self.has_expandable_args: + return + self._args_expanded = not self._args_expanded + self._update_args_display() + + def toggle_task_desc(self) -> None: + """Toggle between the truncated and full `task` description.""" + if not self.has_expandable_task_desc: + return + self._task_desc_expanded = not self._task_desc_expanded + self._update_task_desc_display() + + def on_click(self, event: Click) -> None: + """Toggle output/argument/description expansion. + + A click on the header/args region (the truncated command or code line + and its hint) toggles the collapsible args/code block directly, so an + `execute` command or `js_eval` program can be expanded even when the + output below it is *also* expandable. A `task` row routes clicks on its + description region to the description toggle for the same reason. + Otherwise prefer toggling output, falling through to the args/code block + only when the output can't expand — `js_eval` commonly has a short, + unexpandable result sitting below a multi-line, collapsible code block, + and the old "output wins whenever it exists" rule left that code block + stuck. + """ + event.stop() # Prevent click from bubbling up and scrolling + if self.has_expandable_task_desc and self._click_targets_task_desc_region( + event.widget + ): + self.toggle_task_desc() + elif self.has_expandable_args and self._click_targets_args_region(event.widget): + self.toggle_args() + elif self._output and self.has_expandable_output: + self.toggle_output() + elif self.has_expandable_args: + self.toggle_args() + elif self.has_expandable_task_desc: + self.toggle_task_desc() + + def _click_targets_args_region(self, widget: object) -> bool: + """Whether a click landed on the header/args block (not the output). + + Walks up from the clicked widget to `self`, matching the cached + header, collapsed-args, and args-hint widgets. The walk is bounded so a + mock or detached node (which never reaches `self` via `.parent`) returns + `False` instead of looping, preserving the generic "prefer output" + routing for those cases. + + Returns: + `True` if the click landed on the header/args region. + """ + targets = tuple( + target + for target in ( + self._header_widget, + self._args_widget, + self._args_hint_widget, + ) + if target is not None + ) + if not targets: + # A click can only arrive post-mount, where these refs are always + # cached, so an empty tuple means a regression nulled them out. Log + # it rather than silently routing every click to output (mirrors + # `_update_args_display`). + logger.debug("_click_targets_args_region: header/args refs not cached") + return False + # The header/args/hint widgets are direct children of `self` (a click on + # rendered text reports a descendant, so the real match depth is 0-1). + # 8 is generous headroom that also bounds the walk for a detached or mock + # node, whose `.parent` chain never reaches `self`. + node = widget + for _ in range(8): + if node is None or node is self: + return False + if any(node is target for target in targets): + return True + node = getattr(node, "parent", None) + return False + + def _click_targets_task_desc_region(self, widget: object) -> bool: + """Whether a click landed on the `task` header/description block. + + Mirrors `_click_targets_args_region` but matches the cached header, + description, and description-hint widgets so a `task` row expands its + description when clicked, even when its output below is also expandable. + + Returns: + `True` if the click landed on the header/description region. + """ + targets = tuple( + target + for target in ( + self._header_widget, + self._task_desc_widget, + self._task_desc_hint_widget, + ) + if target is not None + ) + if not targets: + logger.debug("_click_targets_task_desc_region: header/desc refs not cached") + return False + node = widget + for _ in range(8): + if node is None or node is self: + return False + if any(node is target for target in targets): + return True + node = getattr(node, "parent", None) + return False + + def _format_output( + self, output: str, *, is_preview: bool = False + ) -> FormattedOutput: + """Format tool output based on tool type for nicer display. + + Args: + output: Raw output string + is_preview: Whether this is for preview (truncated) display + + Returns: + FormattedOutput with content and optional truncation info. + """ + # Trim surrounding blank lines and trailing whitespace, but preserve the + # command's own leading indentation on the first content line. A bare + # `strip()` would lstrip the first line only — continuation lines keep + # their indent — so output that indents every row (e.g. `git branch -r`, + # which prefixes each branch with two spaces) renders with line 0 flush + # and the rest indented beside the fixed glyph gutter. + output = output.rstrip().lstrip("\n") + if not output: + return FormattedOutput(content=Content("")) + + # Tool-specific formatting using dispatch table + formatters = { + "write_todos": self._format_todos_output, + "ls": self._format_ls_output, + "read_file": self._format_file_output, + "write_file": self._format_file_output, + "edit_file": self._format_edit_file_output, + "grep": self._format_search_output, + "glob": self._format_search_output, + "execute": self._format_shell_output, + "js_eval": self._format_js_eval_output, + "web_search": self._format_web_output, + "fetch_url": self._format_web_output, + "task": self._format_task_output, + "ask_user": self._format_ask_user_output, + } + + formatter = formatters.get(self._tool_name) + if formatter: + return formatter(output, is_preview=is_preview) + + return self._format_generic_output(output, is_preview=is_preview) + + def _format_generic_output( + self, output: str, *, is_preview: bool = False + ) -> FormattedOutput: + """Format output using generic size-based truncation. + + Used for tools with no dedicated formatter, and by a dedicated formatter + that cannot parse its input and so must still cap an arbitrarily long + body rather than dumping it into the collapsed row. + + Args: + output: Tool output. `_format_output` has stripped trailing whitespace + and leading newlines, but deliberately preserves the first line's + leading indentation — do not assume it is fully trimmed. + is_preview: Whether to truncate for the collapsed row. + + Returns: + FormattedOutput, carrying truncation info only when `is_preview` and + the body exceeds the line or character threshold. + """ + if is_preview: + lines = output.split("\n") + if len(lines) > self._PREVIEW_LINES: + return self._format_lines_output(lines, is_preview=True) + if len(output) > self._PREVIEW_CHARS: + truncated = output[: self._PREVIEW_CHARS] + truncation = f"{len(output) - self._PREVIEW_CHARS} more chars" + return FormattedOutput( + content=Content(truncated), truncation=truncation + ) + + # Default: plain text (Content treats input as literal) + return FormattedOutput(content=Content(output)) + + @property + def has_expandable_output(self) -> bool: + """Whether collapsed output has hidden content worth a toggle. + + Public wrapper around `_has_expandable_output` so toggle routing (click + and Ctrl+O) can tell "has output" apart from "has output that can + actually expand/collapse". `js_eval` results are frequently short and + unexpandable while the code block above them *is* collapsible, so the + routing must fall through to args when output cannot toggle. + """ + return self._has_expandable_output() + + def _is_search_no_result_output(self, output: str) -> bool: + """Return whether search output is a terminal no-result message. + + These sentinels must match the empty-result strings the SDK emits + (`format_grep_matches` in `deepagents.backends.utils` and + `_format_file_paths` in `deepagents.middleware.filesystem`). If those + change, this silently stops matching and empty searches revert to + collapsing behind an expand affordance rather than rendering inline. + """ + if self._tool_name == "grep": + return output.strip() == "No matches found" + if self._tool_name == "glob": + return output.strip() == "No files found" + return False + + def _has_expandable_output(self) -> bool: + """Return whether collapsed output has hidden content to expand.""" + output = self._output.strip() + if not output or self._is_search_no_result_output(output): + return False + + # Tools in `_COLLAPSE_OUTPUT_BY_DEFAULT` (read_file, grep, glob) collapse + # their body entirely by default (the header already carries the file + # path / search pattern), so any result with something to show is + # expandable regardless of size. The exception is a search that finds + # nothing: grep/glob return the terminal "No matches found" / "No files + # found" message, caught by `_is_search_no_result_output` above so it + # renders inline (see `_update_output_display`) instead of hiding a + # "nothing found" result behind an expand click. Beyond that, confirm the + # formatted output is non-empty rather than trusting the raw string — + # output that formats to blank (all whitespace, or a serialized empty + # collection like `[]`) has nothing to reveal. Successful `edit_file` + # similarly hides its redundant success line in the collapsed view while + # keeping the raw output expandable. This mirrors the empty-output guard + # in `_update_output_display`, which suppresses any body that would + # render blank before the collapse branch is reached — the two must move + # together if that assumption changes. Errors are excluded because + # `set_error` force-expands every error; treating a short error as + # always-expandable would offer a collapse that hides it entirely. + if self._tool_name in _COLLAPSE_OUTPUT_BY_DEFAULT and self._status != "error": + formatted = self._format_output(output, is_preview=False) + return bool(formatted.content.plain.strip()) + if self._tool_name == "edit_file" and self._status == "success": + return True + + # See `_ALWAYS_PREVIEW_TOOLS`: the formatter decides whether these have + # anything left to reveal, rather than the raw size thresholds below. + # (A formatter that cannot parse its input may delegate back to them.) + if self._tool_name in _ALWAYS_PREVIEW_TOOLS: + return self._format_output(output, is_preview=True).truncation is not None + + lines = output.split("\n") + if len(lines) > self._PREVIEW_LINES or len(output) > self._PREVIEW_CHARS: + # The outer size threshold is necessary but not sufficient: only + # treat output as expandable if the formatter actually hides + # content. Some formatters cap by line count alone (task and the + # web fallback, via `_format_task_output` / `_format_lines_output`), + # so a long single line crosses the char threshold yet renders in + # full with nothing hidden. + return self._format_output(output, is_preview=True).truncation is not None + + return False + + def _format_todos_output( + self, output: str, *, is_preview: bool = False + ) -> FormattedOutput: + """Format write_todos output as a checklist. + + Returns: + FormattedOutput with checklist content and optional truncation info. + """ + items = self._parse_todo_items(output) + if items is None: + return FormattedOutput(content=Content(output)) + + if not items: + return FormattedOutput(content=Content.styled("No todos", "dim")) + + lines: list[Content] = [] + max_items = 4 if is_preview else len(items) + + # Build stats header + stats = self._build_todo_stats(items) + if stats: + lines.extend([stats, Content("")]) + + # Format each item + lines.extend( + self._format_single_todo(item, is_preview=is_preview) + for item in items[:max_items] + ) + + truncation = None + if is_preview: + hidden_items = len(items) - max_items + if hidden_items > 0: + truncation = f"{hidden_items} more" + elif any( + len(self._todo_text(item)) > _MAX_TODO_CONTENT_LEN + for item in items[:max_items] + ): + truncation = "full todo text" + + return FormattedOutput(content=Content("\n").join(lines), truncation=truncation) + + @staticmethod + def _todo_text(item: dict | str) -> str: + """Return display text for a todo item. + + Args: + item: Todo item dictionary or plain string. + + Returns: + Todo content text. + """ + if isinstance(item, dict): + return str(item.get("content", str(item))) + return str(item) + + def _parse_todo_items(self, output: str) -> list | None: # noqa: PLR6301 # Grouped as method for widget cohesion + """Parse todo items from output. + + Returns: + List of todo items, or None if parsing fails. + """ + list_match = re.search(r"\[(\{.*\})\]", output.replace("\n", " "), re.DOTALL) + if list_match: + try: + return ast.literal_eval("[" + list_match.group(1) + "]") + except (ValueError, SyntaxError): + return None + try: + items = ast.literal_eval(output) + return items if isinstance(items, list) else None + except (ValueError, SyntaxError): + return None + + def _build_todo_stats(self, items: list) -> Content: + """Build stats content for todo list. + + Returns: + Styled `Content` showing active, pending, and completed counts. + """ + colors = theme.get_theme_colors(self) + completed = sum( + 1 for i in items if isinstance(i, dict) and i.get("status") == "completed" + ) + active = sum( + 1 for i in items if isinstance(i, dict) and i.get("status") == "in_progress" + ) + pending = len(items) - completed - active + + parts: list[Content] = [] + if active: + parts.append(Content.styled(f"{active} active", colors.warning)) + if pending: + parts.append(Content.styled(f"{pending} pending", "dim")) + if completed: + parts.append(Content.styled(f"{completed} done", colors.success)) + return Content.styled(" | ", "dim").join(parts) if parts else Content("") + + def _todo_content_width(self, indent_width: int) -> int: + """Return the todo content wrap width for the current widget size. + + Args: + indent_width: Display width before todo content starts. + + Returns: + Width available for todo content wrapping. + """ + display_width = 0 + for widget in (self._full_widget, self._preview_widget, self): + if widget and widget.is_mounted and widget.size.width > 0: + display_width = widget.size.width + break + + if not display_width: + try: + display_width = self.app.size.width + except NoActiveAppError: + display_width = _DEFAULT_TODO_WRAP_WIDTH + + # The content widgets measured above live inside the gutter row, so + # their width already excludes the output glyph column; the guard + # columns absorb the gutter offset for the self/app fallback width. + available = display_width - indent_width - _TODO_WRAP_GUARD_COLUMNS + return max(20, available) + + def _format_todo_line( + self, + prefix: Content, + text: str, + *, + is_preview: bool, + text_style: str | None = None, + ) -> Content: + """Format a todo row, wrapping expanded content under the text column. + + Args: + prefix: Styled status prefix before todo content. + text: Todo text to render. + is_preview: Whether the compact preview is being rendered. + text_style: Optional style for todo content. + + Returns: + Styled `Content` for one todo row. + """ + if is_preview and len(text) > _MAX_TODO_CONTENT_LEN: + text = text[: _MAX_TODO_CONTENT_LEN - 3] + "..." + + if is_preview: + content = Content.styled(text, text_style) if text_style else Content(text) + return Content.assemble(prefix, content) + + indent = " " * len(prefix.plain) + wrapped = textwrap.wrap( + text, + width=self._todo_content_width(len(prefix.plain)), + break_long_words=True, + break_on_hyphens=False, + ) or [""] + parts: list[Content] = [prefix] + for index, line in enumerate(wrapped): + if index: + parts.append(Content("\n" + indent)) + content = Content.styled(line, text_style) if text_style else Content(line) + parts.append(content) + return Content.assemble(*parts) + + def _format_single_todo(self, item: dict | str, *, is_preview: bool) -> Content: + """Format a single todo item. + + Args: + item: Todo item dictionary or plain string. + is_preview: Whether the compact preview is being rendered. + + Returns: + Styled `Content` with checkbox and status styling. + """ + colors = theme.get_theme_colors(self) + if isinstance(item, dict): + text = self._todo_text(item) + status = item.get("status", "pending") + else: + text = self._todo_text(item) + status = "pending" + + glyphs = get_glyphs() + if status == "completed": + return self._format_todo_line( + Content.styled(f"{glyphs.checkmark} done ", colors.success), + text, + is_preview=is_preview, + text_style="dim", + ) + if status == "in_progress": + return self._format_todo_line( + Content.styled(f"{glyphs.circle_filled} active ", colors.warning), + text, + is_preview=is_preview, + ) + return self._format_todo_line( + Content.styled(f"{glyphs.circle_empty} todo ", "dim"), + text, + is_preview=is_preview, + ) + + def _format_ls_output( # noqa: PLR6301 # Grouped as method for widget cohesion + self, output: str, *, is_preview: bool = False + ) -> FormattedOutput: + """Format ls output as a clean directory listing. + + Returns: + FormattedOutput with directory listing and optional truncation info. + """ + # Try to parse as a Python list (common format) + try: + items = ast.literal_eval(output) + if isinstance(items, list): + lines: list[Content] = [] + max_items = 5 if is_preview else len(items) + for item in items[:max_items]: + path = Path(str(item)) + name = path.name + if path.suffix in {".py", ".pyx"}: + lines.append(Content.styled(name, theme.FILE_PYTHON)) + elif path.suffix in {".json", ".yaml", ".yml", ".toml"}: + lines.append(Content.styled(name, theme.FILE_CONFIG)) + elif not path.suffix: + lines.append(Content.styled(f"{name}/", theme.FILE_DIR)) + else: + lines.append(Content(name)) + + truncation = None + if is_preview and len(items) > max_items: + truncation = f"{len(items) - max_items} more" + + return FormattedOutput( + content=Content("\n").join(lines), truncation=truncation + ) + except (ValueError, SyntaxError): + pass + + # Fallback: plain text + return FormattedOutput(content=Content(output)) + + @staticmethod + def _compact_line_gutter(output: str) -> str: + r"""Tighten `read_file`'s line-number gutter for display. + + `read_file` prefixes each row with a right-justified line marker — `N`, + or `N.M` for a wrapped-line continuation — then two spaces, then the + original source content. (Output from deepagents versions predating the + gutter disambiguation in #4561 used the older `cat -n` gutter — a wide + right-justified number and a tab — which may still surface from cached or + persisted transcripts.) The model needs the raw gutter for edits, but the + TUI re-justifies markers to the widest marker actually present, then two + spaces, mirroring how grep/glob results sit flush left. Source + indentation after the gutter is preserved untouched. + + The gutter shape is `_READ_FILE_GUTTER_RE`. Lines that don't match a + gutter shape (e.g. test fixtures or non-numbered output) are passed + through unchanged. + + Returns: + The output with compacted gutters, or the original string if no + line-numbered content was found. + """ + lines = output.split("\n") + parsed: list[tuple[str, str] | None] = [] + width = 0 + for line in lines: + match = _READ_FILE_GUTTER_RE.match(line) + if match: + marker, source = match.groups() + parsed.append((marker, source)) + width = max(width, len(marker)) + else: + parsed.append(None) + + if width == 0: + return output + + compacted: list[str] = [] + for line, row in zip(lines, parsed, strict=True): + if row is None: + compacted.append(line) + else: + marker, source = row + compacted.append(f"{marker:>{width}} {source}") + return "\n".join(compacted) + + def _format_edit_file_output( + self, output: str, *, is_preview: bool = False + ) -> FormattedOutput: + """Render edit_file output, hiding success only in the preview. + + On success the collapsed status glyph and the diff already convey the + outcome, so the "Successfully replaced ..." line is hidden by default. + The full rendering still shows the raw tool output so clicking the row + can recover the original message. Errors still render in both modes. + + Returns: + Empty preview on success, otherwise the file formatter. + """ + if self._status == "success" and is_preview: + return FormattedOutput(content=Content("")) + return self._format_file_output(output, is_preview=is_preview) + + def _format_file_output( + self, output: str, *, is_preview: bool = False + ) -> FormattedOutput: + """Format file read/write output. + + Preview mode caps both line count and total characters so that files + with very long lines (minified HTML/JS/CSS) don't wrap and overflow + the widget. + + Returns: + FormattedOutput with file content and optional truncation info. + """ + output = self._compact_line_gutter(output) + lines = output.split("\n") + # Files conventionally end in "\n"; the trailing empty element isn't a + # real line and would inflate truncation counts. + had_trailing_newline = bool(lines) and not lines[-1] + if had_trailing_newline: + lines = lines[:-1] + max_lines = 4 if is_preview else len(lines) + char_budget = self._PREVIEW_CHARS if is_preview else None + + shown, chars_used, char_truncated = self._truncate_to_budget( + lines, max_lines=max_lines, char_budget=char_budget + ) + parts = [Content(line) for line in shown] + content = Content("\n").join(parts) if parts else Content("") + + truncation = self._build_truncation_hint( + output=output, + lines=lines, + parts_count=len(parts), + chars_used=chars_used, + char_truncated=char_truncated, + had_trailing_newline=had_trailing_newline, + is_preview=is_preview, + ) + + return FormattedOutput(content=content, truncation=truncation) + + @staticmethod + def _truncate_to_budget( + lines: list[str], *, max_lines: int, char_budget: int | None + ) -> tuple[list[str], int, bool]: + """Apply line- and character-count caps to a list of display lines. + + Shared by the file, shell, and search formatters so preview truncation + stays identical across tool outputs. When `char_budget` is `None` (the + expanded, non-preview view) only the line cap applies. + + Args: + lines: Candidate display lines, already cleaned by the caller. + max_lines: Maximum number of lines to emit. + char_budget: Maximum characters to emit across all lines, counting + the newline separators between them, or `None` for no cap. + + Returns: + The lines to show, the characters consumed (including separators), + and whether the character budget forced truncation. + """ + shown: list[str] = [] + chars_used = 0 + char_truncated = False + for line in lines[:max_lines]: + display_line = line + if char_budget is not None: + separator_cost = 1 if shown else 0 + remaining = char_budget - chars_used - separator_cost + if remaining <= 0: + char_truncated = True + break + if len(line) > remaining: + display_line = line[:remaining] + char_truncated = True + chars_used += separator_cost + len(display_line) + shown.append(display_line) + if char_truncated: + break + return shown, chars_used, char_truncated + + @staticmethod + def _build_truncation_hint( + *, + output: str, + lines: list[str], + parts_count: int, + chars_used: int, + char_truncated: bool, + had_trailing_newline: bool, + is_preview: bool, + line_unit: Literal["files", "lines"] = "lines", + ) -> str | None: + """Compose the truncation hint, preferring line counts over char counts. + + When both the line cap and the char cap were hit, hidden-line count is + the more useful signal for the user — char counts dominate the hint + for big files where what they really want to know is "how many more + lines am I missing?". `line_unit` names the hidden-row noun ("lines" + for text output, "files" for glob path lists). + + Returns: + Hint string for the UI, or `None` if nothing was truncated. + """ + if not is_preview: + return None + hidden_lines = len(lines) - parts_count + if hidden_lines > 0: + return f"{hidden_lines} more {line_unit}" + if char_truncated: + effective_output_len = len(output) - (1 if had_trailing_newline else 0) + hidden_chars = effective_output_len - chars_used + return f"{hidden_chars} more chars" + return None + + def _format_search_output( + self, output: str, *, is_preview: bool = False + ) -> FormattedOutput: + """Format grep/glob search output. + + Returns: + FormattedOutput with search results and optional truncation info. + """ + # Try to parse as a Python list (glob returns list of paths). The + # except is scoped to detection only — formatting runs outside it so a + # bug in `_format_search_lines` can't silently reroute to the fallback. + try: + items = ast.literal_eval(output.strip()) + except (ValueError, SyntaxError): + items = None + + if isinstance(items, list): + paths: list[str] = [] + for item in items: + path = Path(str(item)) + try: + display = str(path.relative_to(Path.cwd())) + except ValueError: + display = path.name + paths.append(display) + return self._format_search_lines( + paths, is_preview=is_preview, line_unit="files" + ) + + # Fallback: line-based output (grep results) + lines = [ + raw_line.strip() for raw_line in output.split("\n") if raw_line.strip() + ] + return self._format_search_lines( + lines, is_preview=is_preview, line_unit="lines" + ) + + def _format_search_lines( + self, + lines: list[str], + *, + is_preview: bool, + line_unit: Literal["files", "lines"], + ) -> FormattedOutput: + """Format search result rows with line and character preview caps. + + `line_unit` names the hidden-row noun for the hint — "files" for glob + path lists, "lines" for grep matches. + + Returns: + FormattedOutput with search rows and optional truncation info. + """ + # Search rows are denser than file/shell output, so the preview shows + # one extra row (5) before truncating. + max_lines = 5 if is_preview else len(lines) + char_budget = self._PREVIEW_CHARS if is_preview else None + + shown, chars_used, char_truncated = self._truncate_to_budget( + lines, max_lines=max_lines, char_budget=char_budget + ) + parts = [Content(line) for line in shown] + content = Content("\n").join(parts) if parts else Content("") + + # The cleaned `lines` carry no trailing-newline element, so the joined + # length is the full preview-able content length. + truncation = self._build_truncation_hint( + output="\n".join(lines), + lines=lines, + parts_count=len(parts), + chars_used=chars_used, + char_truncated=char_truncated, + had_trailing_newline=False, + is_preview=is_preview, + line_unit=line_unit, + ) + + return FormattedOutput(content=content, truncation=truncation) + + def _format_shell_output( + self, output: str, *, is_preview: bool = False + ) -> FormattedOutput: + """Format shell command output. + + Returns: + FormattedOutput with shell output and optional truncation info. + """ + lines = output.split("\n") + had_trailing_newline = bool(lines) and not lines[-1] + if had_trailing_newline: + lines = lines[:-1] + max_lines = 4 if is_preview else len(lines) + char_budget = self._PREVIEW_CHARS if is_preview else None + + shown, chars_used, char_truncated = self._truncate_to_budget( + lines, max_lines=max_lines, char_budget=char_budget + ) + # Dim the leading `$ command` echo; only the first row can carry it. + parts = [ + Content.styled(line, "dim") + if index == 0 and line.startswith("$ ") + else Content(line) + for index, line in enumerate(shown) + ] + content = Content("\n").join(parts) if parts else Content("") + + truncation = self._build_truncation_hint( + output=output, + lines=lines, + parts_count=len(parts), + chars_used=chars_used, + char_truncated=char_truncated, + had_trailing_newline=had_trailing_newline, + is_preview=is_preview, + ) + + return FormattedOutput(content=content, truncation=truncation) + + def _format_js_eval_output( + self, output: str, *, is_preview: bool = False + ) -> FormattedOutput: + """Format `js_eval` (JS interpreter) output. + + Unwraps the REPL's `` / `` / `` envelope into + labeled, styled sections instead of dumping the raw XML-escaped blob. + + Returns: + FormattedOutput with the formatted REPL output and optional + truncation info. + """ + blocks = parse_js_eval_blocks(output) + if blocks is None: + # Unexpected shape — fall back to plain line rendering. + return self._format_lines_output(output.split("\n"), is_preview=is_preview) + + colors = theme.get_theme_colors(self) + + # Common case: a single short scalar result with no stdout. Rendering a + # standalone "result" header above a one-word value reads as a + # misplaced badge, so collapse it to an inline `result: value` line. + if len(blocks) == 1: + block = blocks[0] + if ( + isinstance(block, JsEvalResult) + and not block.kind + and "\n" not in block.body + and len(block.body) <= self._JS_EVAL_INLINE_RESULT_MAX + ): + content = Content.assemble( + Content.styled("result: ", colors.success), + Content(block.body), + ) + return FormattedOutput(content=content) + lines: list[Content] = [] + total_lines = 0 + max_lines = self._PREVIEW_LINES if is_preview else None + # Char budget mirrors the other formatters so a single very long body + # line (e.g. a 10k-char result) is clipped instead of flooding the + # collapsed preview. `None` outside preview means no char cap. + remaining_chars = self._PREVIEW_CHARS if is_preview else None + # Chars hidden when a single over-budget body line is clipped. Only + # meaningful for the hint when no whole lines were dropped (line counts + # take precedence below, matching `_build_truncation_hint`). + clipped_chars = 0 + + def add_section(label: Content, body: str) -> None: + nonlocal total_lines, remaining_chars, clipped_chars + if max_lines is not None and total_lines >= max_lines: + return + if remaining_chars is not None and remaining_chars <= 0: + return + lines.append(label) + total_lines += 1 + body_lines = body.split("\n") if body else [""] + for body_line in body_lines: + if max_lines is not None and total_lines >= max_lines: + break + if remaining_chars is not None: + if remaining_chars <= 0: + break + if len(body_line) > remaining_chars: + # Clip the over-budget line and stop adding more. + lines.append(Content(f" {body_line[:remaining_chars]}")) + total_lines += 1 + clipped_chars = len(body_line) - remaining_chars + remaining_chars = 0 + break + remaining_chars -= len(body_line) + lines.append(Content(f" {body_line}")) + total_lines += 1 + + for block in blocks: + if isinstance(block, JsEvalStdout): + add_section(Content.styled("stdout", "dim"), block.body) + elif isinstance(block, JsEvalError): + header = f"error ({block.error_type})" if block.error_type else "error" + add_section(Content.styled(header, colors.error), block.body) + else: # JsEvalResult + label = "result (handle)" if block.kind else "result" + add_section(Content.styled(label, colors.success), block.body) + + content = Content("\n").join(lines) if lines else Content("") + truncation = self._build_js_eval_truncation_hint( + blocks=blocks, + shown_lines=total_lines, + clipped_chars=clipped_chars, + is_preview=is_preview, + ) + return FormattedOutput(content=content, truncation=truncation) + + @staticmethod + def _build_js_eval_truncation_hint( + *, + blocks: list[JsEvalBlock], + shown_lines: int, + clipped_chars: int, + is_preview: bool, + ) -> str | None: + """Quantify how much `js_eval` preview content was hidden. + + Prefers a hidden-line count over a hidden-char count (mirroring + `_build_truncation_hint`): when whole sections were dropped, "N more + lines" is the more useful signal; a lone clipped body line reports the + chars it lost. + + Args: + blocks: The parsed blocks, used to compute the full (untruncated) + display-line count. + shown_lines: Display lines actually emitted into the preview. + clipped_chars: Chars dropped from a single clipped body line, if any. + is_preview: Whether this is preview rendering; full renders never + truncate. + + Returns: + A hint string for the UI, or `None` when nothing was hidden. + """ + if not is_preview: + return None + # Each block renders as one label line plus its body lines; an empty + # body still occupies one (blank) line. + full_lines = sum( + 1 + (len(block.body.split("\n")) if block.body else 1) for block in blocks + ) + hidden_lines = full_lines - shown_lines + if hidden_lines > 0: + return f"{hidden_lines} more lines" + if clipped_chars > 0: + return f"{clipped_chars} more chars" + return None + + def _format_web_output( + self, output: str, *, is_preview: bool = False + ) -> FormattedOutput: + """Format web_search/fetch_url output. + + Returns: + FormattedOutput with web response and optional truncation info. + """ + data = self._try_parse_web_data(output) + if isinstance(data, dict): + return self._format_web_dict(data, is_preview=is_preview) + + # Fallback: plain text + return self._format_lines_output(output.split("\n"), is_preview=is_preview) + + @staticmethod + def _try_parse_web_data(output: str) -> dict | None: + """Try to parse web output as JSON or dict. + + Returns: + Parsed dict if successful, None otherwise. + """ + try: + if output.strip().startswith("{"): + return json.loads(output) + return ast.literal_eval(output) + except (ValueError, SyntaxError, json.JSONDecodeError): + return None + + def _format_web_dict(self, data: dict, *, is_preview: bool) -> FormattedOutput: + """Format a parsed web response dict. + + Returns: + FormattedOutput with web response content and optional truncation info. + """ + # Handle web_search results + if "results" in data: + return self._format_web_search_results( + data.get("results", []), is_preview=is_preview + ) + + # Handle fetch_url response + if "markdown_content" in data: + lines = data["markdown_content"].split("\n") + return self._format_lines_output(lines, is_preview=is_preview) + + # Generic dict - show key fields + parts: list[Content] = [] + max_keys = 3 if is_preview else len(data) + for k, v in list(data.items())[:max_keys]: + v_str = str(v) + if is_preview and len(v_str) > _MAX_WEB_CONTENT_LEN: + v_str = v_str[:_MAX_WEB_CONTENT_LEN] + "..." + parts.append(Content(f" {k}: {v_str}")) + truncation = None + if is_preview and len(data) > max_keys: + truncation = f"{len(data) - max_keys} more" + return FormattedOutput( + content=Content("\n").join(parts) if parts else Content(""), + truncation=truncation, + ) + + def _format_web_search_results( # noqa: PLR6301 # Grouped as method for widget cohesion + self, results: list, *, is_preview: bool + ) -> FormattedOutput: + """Format web search results. + + Returns: + FormattedOutput with search results and optional truncation info. + """ + if not results: + return FormattedOutput(content=Content.styled("No results", "dim")) + parts: list[Content] = [] + max_results = 3 if is_preview else len(results) + for r in results[:max_results]: + title = r.get("title", "") + url = r.get("url", "") + parts.extend( + [ + Content.styled(f" {title}", "bold"), + Content.styled(f" {url}", "dim"), + ] + ) + truncation = None + if is_preview and len(results) > max_results: + truncation = f"{len(results) - max_results} more results" + return FormattedOutput(content=Content("\n").join(parts), truncation=truncation) + + def _format_lines_output( # noqa: PLR6301 # Grouped as method for widget cohesion + self, lines: list[str], *, is_preview: bool + ) -> FormattedOutput: + """Format a list of lines with optional preview truncation. + + Returns: + FormattedOutput with lines content and optional truncation info. + """ + max_lines = 4 if is_preview else len(lines) + parts = [Content(line) for line in lines[:max_lines]] + content = Content("\n").join(parts) if parts else Content("") + truncation = None + if is_preview and len(lines) > max_lines: + truncation = f"{len(lines) - max_lines} more lines" + return FormattedOutput(content=content, truncation=truncation) + + def _format_task_output( # noqa: PLR6301 # Grouped as method for widget cohesion + self, output: str, *, is_preview: bool = False + ) -> FormattedOutput: + """Format task (subagent) output. + + Returns: + FormattedOutput with task output and optional truncation info. + """ + lines = output.split("\n") + max_lines = 4 if is_preview else len(lines) + + parts = [Content(line) for line in lines[:max_lines]] + content = Content("\n").join(parts) if parts else Content("") + + truncation = None + if is_preview and len(lines) > max_lines: + truncation = f"{len(lines) - max_lines} more lines" + + return FormattedOutput(content=content, truncation=truncation) + + def _ask_user_question_count(self) -> int: + """Return the number of valid question objects in this tool call. + + The count comes from the structured tool arguments rather than parsing + the free-form transcript. This keeps arbitrary answer text opaque while + still supporting the collapsed `N answers` affordance. + + Returns: + The question count, or zero unless `questions` is a non-empty list of + dicts each carrying non-blank `question` text. Deliberately looser + than `ask_user._validate_questions` — it accepts payloads that + rejects, such as an unknown `type` or a `choices`/`type` mismatch — + because it only needs to guard the fields the count reads. Of the + three paths that populate `_args`, only the `ask_user` interrupt + (validated in `textual_adapter` via `ask_user_adapter`) is checked; + the streamed tool call and the persisted store + (`message_store.to_widget`) are not, so malformed shapes do reach + here and must degrade rather than raise. + """ + questions = self._args.get("questions") + if not isinstance(questions, list) or not questions: + return 0 + if not all( + isinstance(question, dict) + and isinstance(question.get("question"), str) + and bool(question["question"].strip()) + for question in questions + ): + return 0 + return len(questions) + + def _format_ask_user_output( + self, output: str, *, is_preview: bool = False + ) -> FormattedOutput: + """Format an `ask_user` result for the collapsed or expanded row. + + The inline question widget is unmounted once answered, so this row is the + only place the answers stay visible in the live session — the thread's + own `ToolMessage` is what a reload re-renders from. Collapsed, the row + keeps a one-line summary; expanded, it shows what was sent back. + + The summary is derived from the recorded status, never from the answer + text (the question count only labels the expand affordance). The + placeholders are in-band, so a user who types `(cancelled)` or + `(error: ...)` must not have their answer read as control state. The cost + is that a cancelled prompt resumed by a non-TUI client — which `ask_user` + records as `status="success"` with `(cancelled)` placeholders — reads as + answered until expanded. + + Returns: + FormattedOutput with the status-derived summary when `is_preview`, or + the output rendered literally when expanded. A row holding only a + fallback summary advertises no expansion. Falls back to generic + formatting when the structured question args are unavailable. + """ + question_count = self._ask_user_question_count() + if question_count == 0: + # Route through the generic path rather than returning the body bare: + # `ask_user` is in `_ALWAYS_PREVIEW_TOOLS`, so the size thresholds in + # `_has_expandable_output`/`_update_output_display` no longer gate it + # and an arbitrarily long body would otherwise fill the collapsed row + # with no expand affordance. `_format_generic_output` reapplies them. + if not self._ask_user_args_warned: + # Once per widget: this runs on every re-render, and the + # condition cannot change without a new `_args`. + self._ask_user_args_warned = True + logger.warning( + "ask_user row has no usable `questions` args (got %r); the " + "collapsed row will show the transcript instead of a summary", + self._args.get("questions"), + ) + return self._format_generic_output(output, is_preview=is_preview) + + if output in _ASK_USER_ROW_SUMMARIES: + # No authoritative ToolMessage arrived, so this row holds only the + # fallback summary. There is no transcript for expansion to reveal; + # advertising the question count would create a dead affordance. + return FormattedOutput(content=Content.styled(output, "dim")) + + if not is_preview: + return FormattedOutput(content=Content(output)) + + if self._status == "error": + # The transcript holds `(error: ...)` placeholders, not answers, so + # count the questions instead of promising answers. + summary = ASK_USER_FAILED_SUMMARY + noun = "question" if question_count == 1 else "questions" + else: + summary = ASK_USER_ANSWERED_SUMMARY + noun = "answer" if question_count == 1 else "answers" + return FormattedOutput( + content=Content.styled(summary, "dim"), + truncation=f"{question_count} {noun}", + ) + + def _update_output_display(self) -> None: + """Update the output display based on expanded state.""" + # Guard: all widgets must be initialized before updating display state + if ( + not self._output + or not self._preview_widget + or not self._preview_row + or not self._full_widget + or not self._full_row + or not self._hint_widget + ): + return + + output_stripped = self._output.strip() + lines = output_stripped.split("\n") + total_lines = len(lines) + total_chars = len(output_stripped) + + # Truncate if too many lines OR too many characters + needs_truncation = ( + total_lines > self._PREVIEW_LINES or total_chars > self._PREVIEW_CHARS + ) + + # Some output is a non-empty raw string that the formatter renders as no + # visible content — all whitespace, or a serialized empty collection like + # `[]`. The raw `_output` is truthy, so the early-return guard at the top + # of this method doesn't catch it, but rendering it would show an empty + # box with a misleading expand affordance. Treat it like empty output and + # render nothing. (A search that found nothing is not this case: grep/glob + # return a human-readable "No matches found" / "No files found" that + # formats non-empty and renders inline; see the collapse branch below.) + # This also subsumes the all-whitespace case, so the collapsed branch + # below no longer needs its own empty guard. + # + # This fires for errors too, but never hides one: a real error body is + # human-readable text that formats non-empty (and execute errors keep + # the `$ command` echo), so it only triggers on a body that has nothing + # to render anyway. The "error" status badge stays visible regardless. + full = self._format_output(self._output, is_preview=False) + if not full.content.plain.strip(): + self._preview_row.display = False + self._full_row.display = False + self._hint_widget.display = False + return + + if self._expanded: + # Show full output with formatting + self._preview_row.display = False + self._full_widget.update(full.content) + self._full_row.display = True + # Only offer a collapse affordance when collapsing would actually + # hide something. Errors are force-expanded (see `set_error`), so a + # short single-line error has no smaller collapsed form — showing + # "click to collapse" there is misleading. + if self._has_expandable_output(): + self._hint_widget.update( + Content.styled( + f"{self._output_hint_keys()} to collapse", "dim italic" + ) + ) + self._hint_widget.display = True + else: + self._hint_widget.display = False + else: + # Show collapsed preview + self._full_row.display = False + # `read_file` echoes the file the agent read, grep/glob echo the + # matches for a pattern the header already names, and `edit_file` + # success output repeats the status/diff — so the body is noise by + # default. Collapse it entirely (no preview) while keeping the + # original output expandable for when the user does want to see it. + # A grep/glob that found nothing is excluded: its terminal "No + # matches/files found" message is the whole result, so it renders + # inline rather than hiding behind an expand click. + if not self._is_search_no_result_output(self._output) and ( + self._tool_name in _COLLAPSE_OUTPUT_BY_DEFAULT + or (self._tool_name == "edit_file" and self._status == "success") + ): + self._preview_row.display = False + ellipsis = get_glyphs().ellipsis + self._hint_widget.update( + Content.styled( + f"{ellipsis} {self._output_hint_keys()} to expand", "dim italic" + ) + ) + self._hint_widget.display = True + return + # Truncate the preview only when the output is large enough to + # warrant it; `_ALWAYS_PREVIEW_TOOLS` use their compact preview + # regardless of size. + is_preview = needs_truncation or self._tool_name in _ALWAYS_PREVIEW_TOOLS + # Pass the raw output, not `output_stripped`: `_format_output` + # normalizes whitespace while preserving the first line's leading + # indentation. Pre-stripping here flattens that indent on line 0 only, + # misaligning uniformly indented output (e.g. `git branch -r`). The + # expanded branch above already passes raw `self._output`. + result = self._format_output(self._output, is_preview=is_preview) + self._preview_widget.update(result.content) + self._preview_row.display = True + + # Offer expansion only when the formatter actually hid content. + # The raw size threshold can trip without anything being hidden, and + # promising an expansion that reveals nothing is misleading. + if result.truncation: + ellipsis = get_glyphs().ellipsis + self._hint_widget.update( + Content.styled( + f"{ellipsis} {result.truncation} — " + f"{self._output_hint_keys()} to expand", + "dim italic", + ) + ) + self._hint_widget.display = True + else: + self._hint_widget.display = False + + def _output_hint_keys(self) -> str: + """Affordances to advertise in the output expand/collapse hint. + + Ctrl+O routes to the collapsible command/code block whenever this row + has one (see `action_toggle_tool_output`), and to a truncated `task` + description when the row is a `task` call, so the output hint only + advertises Ctrl+O when Ctrl+O would actually toggle the *output*. When a + command/code block or expandable `task` description owns Ctrl+O the + output is reachable by clicking its own region instead. + + Returns: + `"click"` when an expandable command/code block or `task` + description owns Ctrl+O, otherwise `"click or Ctrl+O"`. + """ + if self.has_expandable_args or self.has_expandable_task_desc: + return "click" + return "click or Ctrl+O" + + @property + def has_output(self) -> bool: + """Check if this tool message has output to display. + + Returns: + True if there is output content, False otherwise. + """ + return bool(self._output) + + @property + def tool_name(self) -> str: + """Public read-only accessor for the underlying tool name.""" + return self._tool_name + + @property + def args(self) -> dict[str, Any]: + """Public read-only accessor for the parsed tool-call arguments. + + Returns a shallow copy so a consumer (e.g. a hook payload built from + `args`) cannot rebind the widget's top-level keys by reference. Nested + mutable values are shared, not deep-copied, so callers must treat them as + read-only and must not deep-mutate a returned nested value. + """ + return dict(self._args) + + @property + def summary_call(self) -> _SummaryCall: + """This row as `(tool name, args)` for the group summary line. + + Unlike `args` this does not copy: the group rebuilds its cache key from + every member on each spinner tick, so a copy per member per tick buys + nothing the caller uses. Safe because the `Mapping` return type is + read-only and this widget never mutates `_args` after construction — the + dict is the caller's, though, so do not widen this to a caller that + mutates. + """ + return (self._tool_name, self._args) + + @property + def is_success(self) -> bool: + """Whether the tool completed successfully.""" + return self._status == "success" + + @property + def _superseded_by_diff(self) -> bool: + """Whether this row hides because its `DiffMessage` says it all. + + Gated on success so a row whose status later flips to error is revealed + again: `_diff_superseded` stays set, this goes `False`, and `set_error` + re-applies visibility for exactly that reason. Without the conjunct a + failure would be hidden behind a diff of the change it did not make. + """ + return self.is_success and self._diff_superseded + + @property + def has_display_caveat(self) -> bool: + """Whether this row's output leads with a caveat that must stay visible. + + A caveat is the user's only account of a change the transcript cannot + render, and it is carried inside this row's output. A groupable tool + (`write_file`, `delete`) is folded at mount, and the collapsed summary + line is built from tool names and arguments, never from tool output — so + without this the caveat is folded away and destroying a 5,000-line file + whose contents could not be read renders as `▸ Deleted 1 file`, exactly + like destroying an empty one. + + Read by the group code alongside `is_failed`: a caveated row is not a + failure, but it has the same claim on staying on screen. + """ + return self._has_display_caveat + + def set_success_with_caveat(self, caveat: str, output: str) -> bool: + """Complete this row successfully, leading with a caveat if there is one. + + The live path's single entry point, because the flag and the prose it + describes must not be settable apart: a flag without the sentence leaves + a row unfoldable for no visible reason, and — the costly direction — the + sentence without the flag lets the change's only account be folded into + a group summary. + + Not routed through `set_error`: a display problem is not a tool failure. + That would stamp the row "Error", overwrite the tool's success message, + and make a completed operation count toward every failure surface, so a + user would retry an edit that already applied. Prepended rather than + appended so the caveat survives the collapsed output preview. + + Args: + caveat: The caveat to lead with, or empty when there is none. + output: The tool's own output. + + Returns: + Whether a caveat was applied, for callers tracking whether any + surface carried it. + """ + self.set_success("\n\n".join(part for part in (caveat, output) if part)) + self._has_display_caveat = bool(caveat) + return bool(caveat) + + def _mark_display_caveat(self) -> None: + """Restore the display-caveat flag on a rehydrated row. + + For `MessageData.to_widget` only, where the output carrying the caveat + is restored separately through `_deferred_output`. On the live path use + `set_success_with_caveat`, which sets both together. + + Private so it is not available as a public way to set the flag alone: + the store already restores this widget's other private state, and a + caller outside that path wanting the flag without the sentence is the + split `set_success_with_caveat` exists to prevent. + """ + self._has_display_caveat = True + + @property + def is_failed(self) -> bool: + """Whether the tool did not succeed and should stay visible. + + Covers errored, rejected, and skipped tools. `skipped` is included so a + reject-cascade (one tool rejected, the rest skipped) keeps the skipped + rows visible and out of the group's success count, matching how + `_regroup_completed_tools` treats a hydrated transcript. + """ + return self._status in {"error", "rejected", "skipped"} + + @property + def is_pending(self) -> bool: + """Whether the tool has not finished (awaiting approval or running).""" + return self._status in {"pending", "running"} + + @property + def has_expandable_args(self) -> bool: + """Whether the tool's args are large enough to deserve a collapsible block. + + - `ask_user`: its `questions` payload is too noisy to render inline. + - `js_eval`: the header shows only the first code line (truncated at + `JS_EVAL_HEADER_MAX_LENGTH`), so the full program is offered as a + collapsible block whenever it spans more than one non-blank line *or* + a single line is long enough to be truncated in the header. + - `execute`: the header truncates the shell command at + `EXECUTE_HEADER_MAX_LENGTH`, so the full command is offered as a + collapsible block when the command, after stripping surrounding + whitespace, is longer than `EXECUTE_HEADER_MAX_LENGTH`. + """ + if self._tool_name == "ask_user": + return bool(self._args) + if self._tool_name == "js_eval": + code = self._args.get("code") + if isinstance(code, str) and code.strip(): + non_blank = sum(1 for line in code.splitlines() if line.strip()) + return non_blank > 1 or len(code.strip()) > JS_EVAL_HEADER_MAX_LENGTH + if self._tool_name == "execute": + command = self._args.get("command") + if isinstance(command, str) and command.strip(): + return len(command.strip()) > EXECUTE_HEADER_MAX_LENGTH + return False + + @property + def has_expandable_task_desc(self) -> bool: + """Whether the `task` description is long enough to be truncated. + + A `task` row renders its description on a dedicated dim line, truncated + at `_TASK_DESC_MAX_LENGTH`. When the full description exceeds that, the + truncated preview becomes expandable via click or Ctrl+O. + """ + return len(self._task_description()) > self._TASK_DESC_MAX_LENGTH + + def _task_description(self) -> str: + """Return the `task` call's description string, or empty when absent. + + A non-string `description` (schema-typed as a string) is coerced to + `""` so downstream length/slice logic stays safe; the anomaly is logged. + """ + if self._tool_name != "task": + return "" + desc = self._args.get("description", "") + if isinstance(desc, str): + return desc + if desc is not None: + logger.debug("task description is not a string: %r", type(desc)) + return "" + + def _task_desc_content(self) -> Content: + """Render the `task` description, truncated unless expanded. + + Returns: + Dim `Content`: the full description when expanded or when it already + fits within `_TASK_DESC_MAX_LENGTH`; otherwise the preview truncated + to that length (trailing whitespace trimmed) with a trailing + ellipsis. + """ + desc = self._task_description() + if self._task_desc_expanded or len(desc) <= self._TASK_DESC_MAX_LENGTH: + text = desc + else: + ellipsis = get_glyphs().ellipsis + text = desc[: self._TASK_DESC_MAX_LENGTH].rstrip() + ellipsis + return Content.styled(text, "dim") + + def _update_task_desc_display(self) -> None: + """Update the truncated/expanded `task` description and its hint.""" + if self._task_desc_widget is None or self._task_desc_hint_widget is None: + # Refs are legitimately None for non-`task` rows (never mounted). Log + # only when a `task` row that carries a description is missing them, + # so a regression that nulls them post-mount isn't a silent no-op. + if self._task_description(): + logger.debug("_update_task_desc_display: task-desc refs not cached") + return + if not self._task_description(): + self._task_desc_widget.display = False + self._task_desc_hint_widget.display = False + return + self._task_desc_widget.update(self._task_desc_content()) + self._task_desc_widget.display = True + if not self.has_expandable_task_desc: + self._task_desc_hint_widget.display = False + return + verb = "collapse" if self._task_desc_expanded else "expand" + self._task_desc_hint_widget.update( + Content.styled(f"click or Ctrl+O to {verb}", "dim italic") + ) + self._task_desc_hint_widget.display = True + + def _format_code_detail(self) -> Content: + """Render the `js_eval` program for the collapsible code block. + + The code is shown verbatim and left-aligned (its own indentation is the + only indentation), as plain uncolored `Content`. Blank lines of + top/bottom padding add breathing room between the `js_eval` header above + and the "show/hide code" hint below. + + Returns: + A plain `Content` renderable with a blank line of padding on + top and bottom. + """ + code = self._args.get("code") + code_str = code.strip("\n") if isinstance(code, str) else str(code) + code_str = render_with_unicode_markers(code_str) + + # Blank lines of top/bottom padding separate the block from the header + # line above and the "show/hide code" hint below. + return Content("\n").join((Content(""), Content(code_str), Content(""))) + + def _format_command_detail(self) -> Content: + """Render the full `execute` command for the collapsible block. + + The command is shown verbatim and left-aligned, as plain uncolored + `Content`, mirroring `_format_code_detail`. Hidden/deceptive Unicode is + rendered as visible markers so a truncated header can't conceal it. + + Returns: + A plain `Content` renderable with a blank line of padding on + top and bottom. + """ + command = self._args.get("command") + command_str = command.strip("\n") if isinstance(command, str) else str(command) + command_str = render_with_unicode_markers(command_str) + return Content("\n").join((Content(""), Content(command_str), Content(""))) + + def _format_args_detail(self) -> Content: + """Render tool arguments as an indented `Content` block. + + Renders JSON-pretty-printed args, falling back to `str(self._args)` + (with a visible marker) when JSON serialization fails — `default=str` + already handles most non-serializable values, so reaching the fallback + indicates a deeper issue worth logging. `js_eval` code is handled + separately by `_format_code_detail`. + + Returns: + Indented `Content` containing JSON-pretty-printed arguments, or a + marked fallback rendering on serialization failure. + """ + try: + text = json.dumps(self._args, ensure_ascii=False, indent=2, default=str) + except (TypeError, ValueError) as exc: + logger.warning( + "ask_user args not JSON-serializable; using repr fallback: %r", exc + ) + text = f"# (fallback rendering)\n{self._args!s}" + lines = Content(text).split("\n") + return Content("\n").join(Content.assemble(" ", line) for line in lines) + + def _update_args_display(self) -> None: + """Update the collapsed/expanded argument display.""" + if self._args_widget is None or self._args_hint_widget is None: + # Toggle invoked before on_mount cached the refs; log so a regression + # that nulls them out post-mount doesn't appear as a silent no-op. + logger.debug("_update_args_display called before widget refs are cached") + return + + if not self.has_expandable_args: + self._args_widget.display = False + self._args_hint_widget.display = False + return + + if self._tool_name == "js_eval": + noun, detail_fn = "code", self._format_code_detail + elif self._tool_name == "execute": + noun, detail_fn = "command", self._format_command_detail + else: + noun, detail_fn = "arguments", self._format_args_detail + if self._args_expanded: + self._args_widget.update(detail_fn()) + self._args_widget.display = True + self._args_hint_widget.update( + Content.styled(f"click or Ctrl+O to hide {noun}", "dim italic") + ) + else: + self._args_widget.display = False + self._args_hint_widget.update( + Content.styled(f"click or Ctrl+O to show {noun}", "dim italic") + ) + self._args_hint_widget.display = True + + def _filtered_args(self) -> dict[str, Any]: + """Filter large tool args for display. + + Returns: + Filtered args dict with only display-relevant keys for write/edit tools. + """ + if self._tool_name not in {"write_file", "edit_file"}: + return self._args + + filtered: dict[str, Any] = {} + for key in ("file_path", "path", "replace_all"): + if key in self._args: + filtered[key] = self._args[key] + return filtered + + +# Maps a tool name to the summary category it aggregates under. grep/glob share +# "search" so a mixed run folds into a single "Searched for N patterns" segment. +_TOOL_SUMMARY_CATEGORY: dict[str, str] = { + "read_file": "read", + "write_file": "write", + "edit_file": "edit", + "delete": "delete", + "ls": "ls", + "grep": "search", + "glob": "search", + "execute": "shell", + "js_eval": "js", + "web_search": "web_search", + "fetch_url": "fetch", + "task": "task", + "write_todos": "todos", +} + +# category -> (present verb, past verb, singular noun, plural noun). +_TOOL_SUMMARY_PHRASES: dict[str, tuple[str, str, str, str]] = { + "read": ("Reading", "Read", "file", "files"), + "write": ("Writing", "Wrote", "file", "files"), + "edit": ("Editing", "Edited", "file", "files"), + "delete": ("Deleting", "Deleted", "file", "files"), + "ls": ("Listing", "Listed", "directory", "directories"), + "search": ("Searching for", "Searched for", "pattern", "patterns"), + "shell": ("Running", "Ran", "shell command", "shell commands"), + "js": ("Running", "Ran", "JS evaluation", "JS evaluations"), + "fetch": ("Fetching", "Fetched", "URL", "URLs"), + "task": ("Running", "Ran", "agent", "agents"), +} + +# category -> tool-arg names naming the thing the call acts on, in fallback +# order. Only categories whose summary noun is a durable object belong here: +# their counts claim "N distinct things", so repeat calls on one target must +# collapse (see `_tally_categories`). +# +# Every other category is absent on purpose. "shell", "js", "task", and "search" +# count attempts, not objects — running one command or grepping one pattern twice +# is genuinely two pieces of work. "web_search" phrases its own repeats +# ("Searched the web 2 times") and "todos" carries no count at all. "ls" is +# excluded because a listing is a snapshot, not a durable object: a group spans a +# whole step, so an intervening write can make the second listing of one +# directory show different contents. +_TOOL_SUMMARY_TARGET_ARGS: dict[str, tuple[str, ...]] = { + "read": ("file_path", "path"), + "write": ("file_path", "path"), + "edit": ("file_path", "path"), + "delete": ("file_path", "path"), + "fetch": ("url",), +} + +_PATH_TARGET_CATEGORIES = frozenset({"read", "write", "edit", "delete"}) +"""Categories from `_TOOL_SUMMARY_TARGET_ARGS` whose target is a filesystem path. + +Only these are normalized before comparison. Path rules are wrong for a URL: +`http://x/a//b`, `http://x/a/` and `http://x/a` would each be judged the same +target as a URL the server can answer differently. That undercounts, which this +code must never do. Any category added here needs a genuine path, and any other +target is compared exactly. +""" + + +def _summary_target(tool_name: str, args: Mapping[str, Any]) -> str | None: + """Identify the object a call acts on, for repeat-call collapsing. + + Args: + tool_name: Raw tool name for the call. + args: The call's parsed arguments. + + Returns: + An identity for the target, or None when the category counts attempts + rather than objects, or the naming argument is missing or not a non-empty + string. None means "cannot be judged a repeat", so the call is always + counted — an argument list this code cannot read undercounts nothing. + """ + category = _TOOL_SUMMARY_CATEGORY.get(tool_name, tool_name) + arg_names = _TOOL_SUMMARY_TARGET_ARGS.get(category) + if arg_names is None: + return None + for arg_name in arg_names: + value = args.get(arg_name) + if isinstance(value, str) and value: + if category not in _PATH_TARGET_CATEGORIES: + # Not a path, so compare it exactly — see + # `_PATH_TARGET_CATEGORIES` for why path rules undercount a URL. + return f"{category}:{value}" + # The category prefix is load-bearing: `_tally_categories` shares one + # `seen` set across categories, so identities must be namespaced or + # reading and then editing one file would read as a repeat. + return f"{category}:{_normalize_path_target(value)}" + return None + + +def _normalize_path_target(value: str) -> str: + r"""Normalize a path using the filesystem middleware's canonical form. + + Defers to `validate_path` rather than reimplementing it, so the identity is + exactly the string the file tools act on and cannot drift from it. Every + path the middleware rejects — `..` traversal, a leading `~`, a drive prefix + like `C:/` — is returned verbatim: a call that could not have run has no + canonical form, and canonicalizing one would fold it into a valid target's + tally. `~` and `/~` are different reads and must count as two. + + Args: + value: The raw path string as the tool was called with it. + + Returns: + The normalized virtual path, or `value` when the middleware rejects it. + """ + from deepagents.backends.utils import validate_path + + try: + return validate_path(value) + except ValueError: + return value + + +# category -> plural noun for the operation, used to report repeat work on one +# target alongside the target count, e.g. "Edited 1 file (3 edits)". +# +# Every mutating category qualifies: each call is an event that changed the tree +# and owns a diff, so collapsing three edits of one file to a bare "Edited 1 +# file" hides work the reader wants. A group spans a whole step, so one path can +# genuinely be mutated twice — `delete a.py`, `write_file a.py`, `delete a.py` +# is two real deletions. "fetch" qualifies too: a repeated fetch of one URL is a +# deliberate re-request, not pagination, and "Fetched 1 URL" for two requests +# reads like the second one vanished. Of the categories that name a target, only +# "read" is absent — a repeat read is usually pagination, where the count is +# noise, so reads collapse silently. +# +# A category here has no effect unless it is also in `_TOOL_SUMMARY_TARGET_ARGS`: +# without a target, `calls` can never exceed `targets`. +_REPEAT_COUNT_NOUNS: dict[str, str] = { + "edit": "edits", + "write": "writes", + "delete": "deletions", + "fetch": "calls", +} + + +class _CategoryTally(NamedTuple): + """One category's contribution to a summary line.""" + + category: str + """The summary category being counted.""" + + rep_name: str + """First raw tool name seen for the category, for fallback phrasing.""" + + targets: int + """Distinct targets touched. Calls with no identifiable target each count + for themselves, so this never drops below the honest minimum.""" + + calls: int + """Total calls, including repeats on one target. Equals `targets` unless + something was touched more than once.""" + + +def _tally_categories( + calls: Sequence[_SummaryCall], +) -> list[_CategoryTally]: + """Aggregate calls by category, counting distinct targets and total calls. + + Both numbers are needed because the phrasing claims nouns ("Read 2 files") + while the work is calls: a file read twice is one file, and an edit made + twice is one file but two edits. + + Args: + calls: `(raw tool name, parsed args)` for each call, in call order. + + Returns: + One tally per category, in first-appearance order. + """ + tallies: dict[str, _CategoryTally] = {} + # One set across all categories is safe because `_summary_target` namespaces + # every identity by category. That is also why the first-call branch below + # may ignore `repeat`: a repeat implies an earlier call in the same category, + # which must already have created that category's tally. + seen: set[str] = set() + for tool_name, args in calls: + category = _TOOL_SUMMARY_CATEGORY.get(tool_name, tool_name) + target = _summary_target(tool_name, args) + repeat = target is not None and target in seen + if target is not None: + seen.add(target) + current = tallies.get(category) + if current is None: + tallies[category] = _CategoryTally( + category=category, rep_name=tool_name, targets=1, calls=1 + ) + continue + tallies[category] = current._replace( + targets=current.targets + (0 if repeat else 1), + calls=current.calls + 1, + ) + return list(tallies.values()) + + +_DIFF_HEADER_CATEGORIES = frozenset({"write", "edit", "delete"}) +"""Summary categories whose past verb heads a `DiffMessage`. + +The file-mutating tools — the only ones that produce a diff to head. +""" + + +def _diff_header_verb(tool_name: str | None) -> str: + """Return the past-tense verb naming the change a diff shows. + + The verb text is shared with the group-summary tables, but eligibility is + not: a newly added file tool needs an entry in `_DIFF_HEADER_CATEGORIES` as + well, or it heads its diff with no verb at all. + + Args: + tool_name: Raw name of the tool that produced the diff. + + Returns: + The verb, or empty when the tool does not mutate a file or has no + phrasing registered for its category. + """ + category = _TOOL_SUMMARY_CATEGORY.get(tool_name or "", "") + if category not in _DIFF_HEADER_CATEGORIES: + return "" + phrases = _TOOL_SUMMARY_PHRASES.get(category) + if phrases is None: + # A category listed as diff-eligible but never given phrasing. Losing + # the verb beats raising inside `compose` and killing the whole diff. + logger.warning("No summary phrasing registered for category %r", category) + return "" + return phrases[1] + + +_Tense = Literal["present", "past"] + + +def _summary_segment(tally: _CategoryTally, tense: _Tense) -> str: + """Phrase one category's segment, e.g. "Read 2 files" / "Reading 2 files". + + The lead noun counts distinct targets. When a category in + `_REPEAT_COUNT_NOUNS` repeated work on one of them, the operation count + trails in parentheses ("Edited 1 file (3 edits)") so neither number is lost. + + Args: + tally: The category's distinct-target and total-call counts. + tense: Whether to phrase the segment in the present or past tense. + + Returns: + The phrased segment for this category and tense. + """ + category, tool_name, count = tally.category, tally.rep_name, tally.targets + if category == "web_search": + base = "Searching the web" if tense == "present" else "Searched the web" + return base if count == 1 else f"{base} {count} times" + if category == "todos": + return "Updating todos" if tense == "present" else "Updated todos" + phrase = _TOOL_SUMMARY_PHRASES.get(category) + if phrase is None: + present, past = "Running", "Ran" + singular, plural = f"{tool_name} call", f"{tool_name} calls" + else: + present, past, singular, plural = phrase + verb = present if tense == "present" else past + noun = singular if count == 1 else plural + segment = f"{verb} {count} {noun}" + repeat_noun = _REPEAT_COUNT_NOUNS.get(category) + if repeat_noun is not None and tally.calls > count: + # `calls > count` implies at least two calls, so the noun is always + # plural. + segment += f" ({tally.calls} {repeat_noun})" + return segment + + +def summarize_tool_group( + calls: Sequence[_SummaryCall], *, tense: _Tense = "past" +) -> str: + """Build a one-line summary of a run of tool calls. + + Aggregates by category in first-appearance order and lowercases the lead + word of every segment after the first, e.g. two `read_file` calls on + different paths plus an `execute` -> "Read 2 files, ran 1 shell command". + + Takes args, not just names, because the counts claim distinct nouns: without + the argument naming each call's target, one file read twice is + indistinguishable from two files read once. + + Args: + calls: `(raw tool name, parsed args)` for each call, in call order. + tense: Whether to phrase the summary in the present or past tense. + + Returns: + The aggregated one-line summary string in the requested tense. + """ + tallies = _tally_categories(calls) + if not tallies: + return "Running tools" if tense == "present" else "Ran tools" + return _join_segments([_summary_segment(tally, tense) for tally in tallies]) + + +def _join_segments(segments: list[str]) -> str: + """Join summary segments, lowercasing the lead word of all but the first. + + Args: + segments: Pre-phrased segments in display order. + + Returns: + The segments joined with ", ", e.g. `["Read 2 files", "Running 1 agent"]` + -> "Read 2 files, running 1 agent". + """ + first, *rest = segments + lowered = [f"{seg[0].lower()}{seg[1:]}" if seg else seg for seg in rest] + return ", ".join([first, *lowered]) + + +def summarize_live_tool_group( + completed_calls: Sequence[_SummaryCall], + pending_calls: Sequence[_SummaryCall], +) -> str: + """Summarize an in-flight run, mixing past and present tense. + + Completed calls are phrased in the past tense so the work already done in + the step stays visible, and the still-running calls are phrased in the + present tense, e.g. two completed `execute` calls plus a pending `task` -> + "Ran 2 shell commands, running 1 agent". + + Each half is tallied independently, so a file whose second read is still + running is counted in both — collapsing across the split would drop it from + the present-tense half and the line would stop reporting the step as + reading. + + Args: + completed_calls: `(raw tool name, parsed args)` for calls that finished + successfully, in call order. Failed/rejected calls are evicted + before this runs. + pending_calls: `(raw tool name, parsed args)` for calls still pending or + running, in call order. + + Returns: + The combined one-line summary. Empty when neither half has members. + """ + segments: list[str] = [] + if completed_calls: + segments.append(summarize_tool_group(completed_calls, tense="past")) + if pending_calls: + segments.append(summarize_tool_group(pending_calls, tense="present")) + if not segments: + return "" + return _join_segments(segments) + + +def _summary_cache_key( + calls: Sequence[_SummaryCall], +) -> _SummaryCacheKey: + """Build a cache key capturing everything a summary line depends on. + + The line is a function of each call's `(name, target)`, so the key is too. + A names-only key would in fact be sufficient today — every membership + mutation, append and eviction alike, clears the cache outright, so two + cached states cannot share a name list while differing in targets. Deriving + the key from the summarizer's real inputs instead of relying on that + argument keeps it correct if grouping ever changes. Over-invalidation is the + only cost: reordering calls within a category rebuilds identical text. + + Args: + calls: `(raw tool name, parsed args)` for each call, in call order. + + Returns: + A hashable key, order-sensitive to match segment ordering. + """ + return tuple((name, _summary_target(name, args)) for name, args in calls) + + +_TOOL_GROUP_COLLAPSED_ACCESSORY_CLASS = "-tool-group-collapsed-accessory" +"""Marker class hiding a collapsed group's accessory widgets. + +See `_TOOL_AWAITING_APPROVAL_ACCESSORY_CLASS` for why each hide reason carries its +own class and why none may be replaced by assigning `display`. +""" + + +def _hides_itself(widget: Widget) -> bool: + """Whether a widget is hiding itself for reasons a group must not override. + + Returns: + `True` when the widget tracks its own hide reasons and one applies. Only + `ToolCallMessage` does; anything else is governed by its group alone. + """ + return isinstance(widget, ToolCallMessage) and widget.has_own_hide_reason + + +class ToolGroupSummary(Static): + """Collapsed one-line stand-in for an assistant step's tool calls. + + Tools are hidden from the moment they start; this single line shows live + progress ("Running 1 shell command…") and flips to the fully past-tense + line ("Ran 1 shell command") once every tool finishes. While the step is + live, finished calls stay visible in the past tense next to the ones still + running in the present tense (e.g. "Ran 2 shell commands, running 1 agent…") + so the work already done in the step doesn't disappear. Failed, rejected, + and skipped tools are evicted to standalone rows (see `_evict_unfoldable`) so + errors stay visible. Clicking the line or pressing Ctrl+O expands the + underlying tool rows (and their diffs). + + Two modes: + + - **live** (streaming): created empty, members added via `add_member` as + they mount, a spinner timer animates the line and re-renders present/past + tense, and failed tools are ejected back into view so errors stay visible. + - **finalized** (`live=False`, used for hydration/resume): a fixed set of + completed tools rendered straight to the past tense with no timer. + + Purely presentational — never tracked by the message store; it is re-derived + from the mounted tool widgets on each stream boundary and on hydration. + """ + + DEFAULT_CSS = """ + ToolGroupSummary { + height: auto; + padding: 0 1; + margin: 0 0 1 0; + color: $text-muted; + pointer: pointer; + } + + ToolGroupSummary:hover { + color: $text; + } + """ + + _SPINNER_INTERVAL: ClassVar[float] = 0.1 + + _collapsed: var[bool] = var(True) + + def __init__( + self, + tools: list[ToolCallMessage] | None = None, + collapsible: list[Widget] | None = None, + *, + accessories: dict[Widget, list[Widget]] | None = None, + live: bool = False, + **kwargs: Any, + ) -> None: + """Initialize the summary. + + Args: + tools: Tool widgets the summary aggregates (drives its text). May be + empty for a live group that grows via `add_member`. + collapsible: Every widget hidden/shown with the group, including the + tool widgets and any interleaved diff previews. + accessories: Decorations (e.g. timestamp footers) keyed by the + collapsible they trail, hidden and shown with that owner via a + marker class rather than `display`. Collapsing therefore never + clears an accessory's own visibility class, so the + `/timestamps` preference reasserts itself on expand. Keys must + appear in `collapsible`: `_apply_visibility` iterates + `collapsible`, so an accessory keyed by a non-member is never + synced. + live: When True, animate progress and accept new members until + `close`. When False, render a finalized past-tense summary. + **kwargs: Additional arguments passed to `Static`. + """ + super().__init__("", **kwargs) + self._tools = list(tools or []) + self._collapsible = list(collapsible or []) + self._accessories: dict[Widget, list[Widget]] = {} + for owner, widgets in (accessories or {}).items(): + self._attach_accessories(owner, widgets) + self._accepting_members = live + self._finalized = not live + self._spinner_pos = 0 + self._timer: Timer | None = None + # Cached summary phrasing, rebuilt only when membership changes (not on + # every spinner tick). None means "recompute on next render". + self._present_text: str | None = None + self._past_text: str | None = None + # The (completed, pending) `_summary_cache_key` pair the cached live line + # was built from. The line mixes finished (past tense) and running + # (present tense) members, so it must be rebuilt whenever a member + # finishes, not just when membership grows. + self._present_key: _LiveSummaryKey | None = None + + def on_mount(self) -> None: + """Apply initial visibility, render, and arm the spinner if live.""" + self._apply_visibility() + self._render_line() + self._sync_timer() + + def _attach_accessories(self, owner: Widget, accessories: Iterable[Widget]) -> None: + """Record `owner`'s accessories and link them to its approval hiding. + + The single registration point for every fold path (constructor, + `add_member`, `add_collapsible`) so the group's `_accessories` map and a + tool's own `_visibility_accessories` cannot drift apart — a tool folded + via one path would otherwise hide its footer on collapse but not while + an approval prompt replaced it. + """ + linked = [a for a in accessories if a not in self._accessories.get(owner, ())] + if not linked: + return + self._accessories.setdefault(owner, []).extend(linked) + if isinstance(owner, ToolCallMessage): + owner._register_visibility_accessories(*linked) + + def add_member(self, tool: ToolCallMessage, *accessories: Widget) -> None: + """Add a tool to a live group and link its accessories. + + Args: + tool: Tool widget folded into the group. + accessories: Decorations (e.g. the tool's timestamp footer) that + follow the tool's visibility via a marker class. Not group + members: they take neither `-grouped` nor a `display` flip, so + their own visibility class survives the fold. Also linked to + the tool's own hide reasons — approval and diff supersession + both — via `has_own_hide_reason`. + """ + tool.add_class("-grouped") + self._tools.append(tool) + self._collapsible.append(tool) + self._attach_accessories(tool, accessories) + self._present_text = self._past_text = self._present_key = None + self._apply_visibility() + in_progress = self._sync_lifecycle() + self._render_line(in_progress=in_progress) + + def add_collapsible(self, widget: Widget, *accessories: Widget) -> None: + """Attach a non-tool widget (e.g. a diff) and its accessories. + + Args: + widget: Non-tool widget folded with the group. + accessories: Decorations (e.g. the widget's timestamp footer) that + follow the widget's visibility via a marker class. Not group + members, so their own visibility class survives the fold. No + approval linkage: only a `ToolCallMessage` can await approval. + """ + widget.add_class("-grouped") + self._collapsible.append(widget) + self._attach_accessories(widget, accessories) + self._apply_collapsible_visibility(widget, visible=not self._collapsed) + + def close(self) -> None: + """Stop accepting members and finalize after every tool settles. + + A non-tool stream event can close a group before middleware-generated + terminal results arrive. Keep the live timer running in that case so a + later error or rejection is evicted instead of being summarized in the + past tense as though the tool ran successfully. + """ + self._accepting_members = False + self._evict_unfoldable() + in_progress = self._sync_lifecycle() + if not self.is_attached: + return + if self._tools: + self._render_line(in_progress=in_progress) + else: + # Every tool failed and was ejected — nothing left to summarize. + # Release whatever is still folded first: once this summary is gone + # nothing can expand it, so a retained widget (and its accessories) + # would stay hidden for the rest of the session. + self._release_all_collapsible() + self.remove() + + def _release_collapsible(self, widget: Widget) -> None: + """Drop a widget's group linkage and clear its collapsed accessory class. + + Only the *group's* hide reason is released. A tool's own linkage + (`_visibility_accessories`) intentionally survives, so a revealed row + still hides its footer for any of its own hide reasons — an approval + prompt replacing it, or a diff superseding it. See `has_own_hide_reason` + for the full set. + """ + if widget in self._collapsible: + self._collapsible.remove(widget) + widget.remove_class("-grouped") + for accessory in self._accessories.pop(widget, []): + accessory.remove_class(_TOOL_GROUP_COLLAPSED_ACCESSORY_CLASS) + + def _release_all_collapsible(self) -> None: + """Release and reveal every remaining folded widget. + + Must run before this summary is removed: a widget left folded keeps + `-grouped`, `display = False`, and its accessories' marker class with no + summary left to expand it, which no later toggle can undo. + """ + for widget in list(self._collapsible): + self._release_collapsible(widget) + if widget.is_attached and not _hides_itself(widget): + widget.display = True + + def reveal_pending(self) -> None: + """Remove unfinished tool calls from the collapsed group.""" + pending = [tool for tool in self._tools if tool.is_pending] + if not pending: + return + for tool in pending: + self._tools.remove(tool) + self._release_collapsible(tool) + if tool.is_attached and not _hides_itself(tool): + tool.display = True + self._present_text = self._past_text = self._present_key = None + in_progress = self._sync_lifecycle() + if self._tools: + self._render_line(in_progress=in_progress) + return + self._release_all_collapsible() + if self.is_attached: + self.remove() + + @property + def has_attached_members(self) -> bool: + """Whether any collapsed widget is still attached to the DOM.""" + return any(widget.is_attached for widget in self._collapsible) + + def toggle(self) -> None: + """Toggle between collapsed and expanded.""" + self._collapsed = not self._collapsed + + def watch__collapsed(self, _collapsed: bool) -> None: + """Re-render and re-apply member visibility when the state changes. + + Coalesced into one repaint so expanding a multi-tool group reveals every + row at once instead of bouncing the transcript per member. + """ + if not self.is_attached: + self._apply_visibility() + self._render_line() + return + with self.app.batch_update(): + self._apply_visibility() + self._render_line() + + def on_click(self, event: Click) -> None: + """Toggle the group on click.""" + event.stop() + self.toggle() + + def _in_progress(self) -> bool: + """Whether any member tool is still pending or running. + + Returns: + True if at least one member tool has not finished. + """ + return any(tool.is_pending for tool in self._tools) + + def _sync_lifecycle(self, *, in_progress: bool | None = None) -> bool: + """Finalize only once a closed group's retained tools have settled. + + Returns: + Whether any retained tool is still in progress. + """ + if in_progress is None: + in_progress = self._in_progress() + self._finalized = not self._accepting_members and not in_progress + self._sync_timer() + return in_progress + + def _evict_unfoldable(self) -> None: + """Un-fold tools the summary line cannot speak for. + + Two reasons qualify. A non-success (errored, rejected, skipped) must stay + visible so a failure is not summarized away. So must a success whose + output opens with a display caveat: the summary is built from tool names + and arguments, never from tool output, so folding one hides the only + statement that the change could not be shown — see + `ToolCallMessage.has_display_caveat`. + """ + failed = [t for t in self._tools if t.is_failed or t.has_display_caveat] + if not failed: + return + for tool in failed: + self._tools.remove(tool) + self._release_collapsible(tool) + if tool.is_attached and not _hides_itself(tool): + tool.display = True + self._present_text = self._past_text = self._present_key = None + + def _sync_timer(self) -> None: + """Run the spinner timer only while live members are in progress.""" + if not self._finalized and self._in_progress(): + if self._timer is None: + self._timer = self.set_interval(self._SPINNER_INTERVAL, self._tick) + else: + self._stop_timer() + + def _stop_timer(self) -> None: + if self._timer is not None: + self._timer.stop() + self._timer = None + + def _tick(self) -> None: + """Advance the spinner, eject failures, and flip to past tense when done.""" + try: + self._spinner_pos += 1 + before = len(self._tools) + self._evict_unfoldable() + evicted = len(self._tools) != before + if self._collapsed: + # Re-assert hidden state in case a member was shown externally + # (e.g. ToolCallMessage.clear_awaiting_approval after HITL). + self._apply_visibility() + if not self._tools: + self._sync_lifecycle(in_progress=False) + # Nothing can expand this summary once it is gone, so release + # anything still folded before removing it. + self._release_all_collapsible() + if self.is_attached: + self.remove() + return + in_progress = self._sync_lifecycle() + # A bare spinner advance keeps the line height. `_render_line` + # promotes this to a layout update if the pending summary changed. + self._render_line( + in_progress=in_progress, layout=evicted or not in_progress + ) + except Exception: + # Fires ~10x/second, so an unhandled raise would propagate out of the + # interval callback and can crash the app repeatedly. The group is + # purely presentational; stop animating and log rather than take the + # transcript down. + logger.exception("ToolGroupSummary spinner tick failed; stopping timer") + self._stop_timer() + + def _apply_collapsible_visibility(self, widget: Widget, *, visible: bool) -> None: + """Apply the group's visibility to a widget and its accessories. + + The owner is driven directly via `display`; accessories are driven by a + marker class instead, so hiding them leaves their independent visibility + class intact and it reasserts itself when the group expands. The two + mechanisms are not interchangeable — see + `_TOOL_AWAITING_APPROVAL_ACCESSORY_CLASS`. + + Accessories are classed even while detached: `set_class` is safe off-DOM + and nothing revisits a skipped accessory, so guarding on `is_attached` + here would leave a late-mounted footer stranded visible over a hidden + row. + + Expanding the group does not reveal a widget hiding for its own reasons + (`_hides_itself`); its accessories still follow the group, since their + self-hide reasons carry their own independent classes. + """ + target = visible and not _hides_itself(widget) + if widget.is_attached and widget.display != target: + widget.display = target + for accessory in self._accessories.get(widget, []): + accessory.set_class(not visible, _TOOL_GROUP_COLLAPSED_ACCESSORY_CLASS) + + def _apply_visibility(self) -> None: + """Show or hide every folded widget, and its accessories, per collapse.""" + visible = not self._collapsed + for widget in self._collapsible: + self._apply_collapsible_visibility(widget, visible=visible) + + def _render_line( + self, *, in_progress: bool | None = None, layout: bool = True + ) -> None: + """Refresh the summary line for the current tense and collapsed state. + + Args: + in_progress: Pre-computed progress state to avoid re-scanning members + on the spinner hot path; recomputed when omitted. + layout: Whether to force a layout update. A changed summary always + triggers layout; the spinner hot path passes False so a bare + glyph swap doesn't relayout the whole transcript 10x/second. + """ + if not self.is_attached: + return + if not self._tools: + self.update(Content(""), layout=layout) + return + glyphs = get_glyphs() + if in_progress is None: + in_progress = self._in_progress() + if not self._finalized and in_progress: + # Tallied per bucket, not across both: a file whose second read is + # still in flight must stay visible as being read. + pending = [t.summary_call for t in self._tools if t.is_pending] + completed = [t.summary_call for t in self._tools if not t.is_pending] + key = (_summary_cache_key(completed), _summary_cache_key(pending)) + summary_changed = self._present_text is None or key != self._present_key + if summary_changed: + self._present_text = summarize_live_tool_group(completed, pending) + self._present_key = key + frames = glyphs.spinner_frames + spinner = frames[self._spinner_pos % len(frames)] + self.update( + Content(f"{spinner} {self._present_text}{glyphs.ellipsis}"), + layout=layout or summary_changed, + ) + else: + mark = ( + glyphs.disclosure_collapsed + if self._collapsed + else glyphs.disclosure_expanded + ) + if self._past_text is None: + self._past_text = summarize_tool_group( + [tool.summary_call for tool in self._tools], tense="past" + ) + self.update(Content(f"{mark} {self._past_text}"), layout=layout) + + +class DiffMessage(Static): + """Widget displaying a diff with syntax highlighting. + + Two behaviors beyond rendering, both easy to break from `compose` without + noticing, and documented per-parameter on `__init__`: + + - Any `outcome` other than `shown` replaces the diff body with a caveat + sentence. A body rendered under a lost pre-image would be a whole-file + insertion that never happened, so suppression is the honest result rather + than a degradation. + - A path this session treats as sensitive suppresses the body *and* the + counts. Leaking `+40 -3` for a credentials file still describes its + contents, so the header cannot survive a redacted body. + + `renders_caveat` reports whether this widget's body states the change could + not be shown. The adapter reads it back when deciding whether any surface + carried the caveat — conjoined with the mount result, since this says only + what the widget would render, not that it reached the screen. + """ + + DEFAULT_CSS = """ + DiffMessage { + height: auto; + padding: 0 1; + margin: 0 0 1 0; + background: transparent; + border-left: wide $panel; + pointer: text; + } + + DiffMessage .diff-header { + margin-bottom: 1; + } + """ + """Deliberately carries no per-line color: the row gutters and the + `.diff-line-*` backgrounds supply it.""" + + def __init__( + self, + diff_content: str, + file_path: str = "", + *, + tool_name: str | None = None, + before: str = "", + after: str = "", + stats: DiffStats | None = None, + outcome: DiffOutcome = "shown", + show_caveat: bool = True, + show_numbers: bool = True, + **kwargs: Any, + ) -> None: + """Initialize a diff message. + + Args: + diff_content: The unified diff content + file_path: Path to the file being modified + tool_name: Name of the file tool that produced the diff + before: Source aligned to the diff's old line numbers. Pass the + whole file, or a prefix `highlight_source_prefixes` previously + produced — which is what `MessageData` round-trips. Stored + trimmed, and dropped entirely for a credential path. + after: Source aligned to the diff's new line numbers, same contract. + stats: Authoritative `(additions, deletions)`, counted before + truncation. Always preferred over recounting the diff body; + `None` recounts. + outcome: What the operation can honestly say about what it changed. + Anything but `shown` suppresses the body and replaces it with + that outcome's caveat. Taken as the outcome rather than as + independent flags because they are not independent: a + `stats`-plus-"counts are fiction" pair is representable, and + whichever of the two a reader trusts, the other contradicts it. + show_caveat: Whether to render the outcome's caveat. Suppresses only + the sentence, never the body — an untrusted body stays hidden + either way. Pass `False` only when a tool row already on screen + carries the identical sentence, which for `edit_file` is + guaranteed: it can never be folded into a group, so both would + render adjacent. The caller owns that judgement because this + widget cannot see what else is mounted. + show_numbers: Whether file-relative line numbers may be rendered. + Diffs whose numbers are not file-relative remain unnumbered. + The caller owns this judgement: a live `edit_file` diff is + computed from the full before/after file contents and has + file-relative numbers, while a resumed-thread `edit_file` + diff is rebuilt from `old_string`/`new_string` fragments and + does not. + **kwargs: Additional arguments passed to parent + """ + super().__init__(**kwargs) + self._diff_content = diff_content + self._file_path = file_path + self._tool_name = tool_name + self._redacted = is_sensitive_file_path(file_path) + if self._redacted: + self._before = "" + self._after = "" + else: + self._before, self._after = highlight_source_prefixes( + diff_content, before, after + ) + self._stats = stats + self._outcome = outcome + self._show_caveat = show_caveat + self._show_numbers = show_numbers + + @property + def renders_caveat(self) -> bool: + """Whether this widget's body states the change could not be shown. + + Read by the adapter instead of inferring from the record's outcome: the + adapter cannot otherwise know whether the mounted widget actually put + the caveat on screen, and asserting it did is how a caveat came to be + both suppressed and reported as delivered. + """ + return self._outcome != "shown" and self._show_caveat + + def compose(self) -> ComposeResult: + """Compose the diff message layout. + + Yields: + Widgets displaying the diff header and formatted content. + """ + parts: list[str | tuple[str, str] | Content] = [] + if verb := _diff_header_verb(self._tool_name): + parts.append((f"{verb} ", "bold")) + parts.append(Content.from_markup("[dim]$path[/dim]", path=self._file_path)) + + # Never render the contents or line counts of credential files (e.g. + # `.env`) — the diff would leak secrets into the terminal UI and + # scrollback, and the counts would describe them. + if self._redacted: + yield Static(Content.assemble(*parts), classes="diff-header") + if self.renders_caveat: + # Rendered alongside the redaction notice, not instead of it. + # The two say different things: redaction means the diff was + # withheld deliberately, the caveat means it could not be + # produced at all. Showing only the former tells a reader the + # change is known and merely hidden. The caveat names the tool + # and nothing else, so it leaks no file content. + yield Static( + Content.styled( + display_caveat(self._outcome, self._tool_name or "operation"), + "dim", + ) + ) + yield Static( + Content.styled("Diff hidden — file may contain credentials", "dim") + ) + elif self._outcome != "shown": + # The body cannot be trusted. Under `untrusted_before` the diff was + # computed against a stand-in empty file, so a one-line edit renders + # as a whole-file insertion; suppressing only the counts would leave + # the body making the same false claim more loudly, so the caveat + # replaces it outright. + # + # Gated on the outcome, not on `renders_caveat`: `show_caveat` + # suppresses the sentence when a row already carries it, and must + # never be able to bring the untrusted body back. + # + # The caveat is the shared one, so this widget stands on its own: + # the tool row that also carries it can be folded into a group, or + # never have mounted at all, and pointing at it would leave the + # reader chasing text that is not on screen. + yield Static(Content.assemble(*parts), classes="diff-header") + if self.renders_caveat: + yield Static( + Content.styled( + display_caveat(self._outcome, self._tool_name or "operation"), + "dim", + ) + ) + else: + stats = self._stats if self._stats is not None else self._recount() + if stats is None: + parts.append((" change counts unavailable", "dim")) + elif stats.additions or stats.deletions: + parts += [" ", format_diff_stats(stats)] + elif not self._diff_content: + parts.append((" no changes", "dim")) + header = Content.assemble(*parts) + if header.plain: + yield Static(header, classes="diff-header") + if self._diff_content: + yield from compose_diff_lines( + self._diff_content, + max_lines=100, + path=self._file_path, + before=self._before, + after=self._after, + show_numbers=self._show_numbers, + ) + + def _recount(self) -> DiffStats | None: + """Recount the diff body when no authoritative counts were supplied. + + Returns: + Counts from the body, or `None` when the body was clipped. A + truncated diff is missing lines by construction, so counting it would + assert a number that is known to be short and indistinguishable from + a correct one. `None` says the counts are unavailable, which is + different from — and more honest than — silently showing none. + """ + lines = split_diff_lines(self._diff_content) + # Any position, not just the last line, and via the shared predicate: + # the renderer marks a truncated row wherever it appears, and the two + # must not disagree about whether this body is complete. + if any(is_truncation_marker(line) for line in lines): + return None + return count_diff_change_lines(lines) + + def on_mount(self) -> None: + """Set border style based on charset mode.""" + if is_ascii_mode(): + colors = theme.get_theme_colors(self) + self.styles.border_left = ("ascii", colors.panel) + + +class ErrorMessage(Static): + """Widget displaying an error message.""" + + DEFAULT_CSS = """ + ErrorMessage { + height: auto; + padding: 1; + margin: 0 0 1 0; + background: $error-muted; + color: white; + border-left: wide $error; + pointer: text; + } + """ + """Tinted background + left border to visually separate errors from output.""" + + def __init__(self, error: str | Content, **kwargs: Any) -> None: + """Initialize an error message. + + Args: + error: Plain string, or `Content` for pre-styled bodies + (e.g. with `link`-styled spans). + **kwargs: Additional arguments passed to parent. + """ + self._content = error + super().__init__(**kwargs) + + def render(self) -> Content: + """Render with theme-aware colors. + + Returns: + Styled error content; spans on a `Content` body are preserved. + """ + colors = theme.get_theme_colors(self) + return Content.assemble( + Content.styled("Error: ", f"bold {colors.error}"), + self._content, + ) + + def on_mount(self) -> None: + """Set border style based on charset mode.""" + if is_ascii_mode(): + colors = theme.get_theme_colors(self) + self.styles.border_left = ("ascii", colors.error) + + def on_click(self, event: Click) -> None: # noqa: PLR6301 # Textual event handler + """Open clicked URLs.""" + if event.style.link: + open_style_link(event) + + +class _RubricResultToggle(Static): + """Clickable summary or hint for a rubric result.""" + + +class RubricResultMessage(Vertical): + """Compact grader result with complete, scrollable details on demand.""" + + class ExpansionChanged(Message): + """Posted when the grader-details expansion state changes.""" + + def __init__(self, widget: RubricResultMessage, expanded: bool) -> None: + """Initialize an expansion-state message. + + Args: + widget: The rubric result whose expansion state changed. + expanded: Whether the grader details are now expanded. + """ + super().__init__() + self.widget = widget + self.expanded = expanded + + DEFAULT_CSS = """ + RubricResultMessage { + height: auto; + padding: 0 1; + margin: 0 0 1 0; + color: $text-muted; + border-left: wide $warning; + } + + RubricResultMessage .rubric-result-summary { + height: auto; + } + + RubricResultMessage .rubric-result-details-scroll { + display: none; + height: auto; + max-height: 16; + margin: 1 0 0 2; + overflow-y: auto; + scrollbar-size-vertical: 1; + } + + RubricResultMessage .rubric-result-details { + height: auto; + padding: 0; + } + + RubricResultMessage .rubric-result-hint { + height: auto; + margin-left: 2; + color: $text-muted; + } + + RubricResultMessage.-expanded .rubric-result-details-scroll { + display: block; + } + """ + + _expanded: var[bool] = var(False, toggle_class="-expanded") + + def __init__( + self, + summary: str, + details: str, + **kwargs: Any, + ) -> None: + """Initialize a grader result. + + Args: + summary: Concise default transcript line. + details: Complete user-facing grader explanation and criteria gaps. + **kwargs: Additional arguments passed to `Vertical`. + """ + super().__init__(**kwargs) + self._summary = summary + self._details = details + self._hint_widget: _RubricResultToggle | None = None + self._deferred_expanded = False + # Last expansion value published to the message store. Deduping against it + # keeps the reactive's initialization watcher and the deferred restore from + # re-emitting a value the store already holds. + self._published_expanded = False + + def compose(self) -> ComposeResult: + """Compose the compact summary, details viewport, and expansion hint. + + Yields: + Summary, scrollable details, and toggle hint widgets. + """ + yield _RubricResultToggle( + Content.styled(self._summary, "dim italic"), + classes="rubric-result-summary", + ) + with VerticalScroll(classes="rubric-result-details-scroll"): + yield Static( + Content(self._details), + classes="rubric-result-details", + ) + yield _RubricResultToggle("", classes="rubric-result-hint") + + def on_mount(self) -> None: + """Initialize the expansion hint and restore deferred state.""" + self._hint_widget = self.query_one( + ".rubric-result-hint", + _RubricResultToggle, + ) + if is_ascii_mode(): + colors = theme.get_theme_colors(self) + self.styles.border_left = ("ascii", colors.warning) + if not self._details: + self._hint_widget.display = False + return + # The store already holds the restored state, so record it as published + # first; the assignment below then dedupes instead of re-emitting it. + self._published_expanded = self._deferred_expanded + if self._deferred_expanded: + self._expanded = True + self._deferred_expanded = False + self._update_hint() + + def toggle_details(self) -> None: + """Toggle the complete grader details.""" + if self._details: + self._expanded = not self._expanded + + def watch__expanded(self, expanded: bool) -> None: + """Refresh the hint and publish user-driven expansion for virtualization.""" + self._update_hint() + # Publish only genuine changes: dedupe against the store's known value to + # drop the reactive's initialization watcher and the deferred restore, and + # require `is_attached` so `_expanded` set in pre-mount test setup does not + # `post_message` on a detached widget (NoActiveAppError). + if self.is_attached and expanded != self._published_expanded: + self._published_expanded = expanded + self.post_message(self.ExpansionChanged(self, expanded)) + + def _update_hint(self) -> None: + """Render the current expansion hint.""" + if self._hint_widget is None or not self._details: + return + action = "hide" if self._expanded else "show" + self._hint_widget.update( + Content.styled(f"click or Ctrl+O to {action} details", "dim italic") + ) + + @on(Click, "_RubricResultToggle") + def _on_toggle_click(self, event: Click) -> None: + """Toggle details from the summary or hint.""" + event.stop() + self.toggle_details() + + +class _MutedRichMarkdown: + """Render Rich markdown to match `AppMessage`'s muted-italic base. + + Plain `AppMessage` strings render as `dim italic` via `Content.styled` + plus the widget's CSS. Rich's default markdown theme paints h2-h4 + magenta and table headers/borders cyan, and doesn't apply `dim` to + paragraphs, so markdown blocks look visually distinct. This wrapper: + + - Applies a `rich.theme.Theme` while rendering that strips the stock + colors while keeping structural emphasis (bold/underline/italic), and + - Layers `dim` over the whole document via `rich.styled.Styled` so + body text matches the `dim italic` baseline used elsewhere. + """ + + _THEME_OVERRIDES: ClassVar[dict[str, str]] = { + "markdown.h1": "bold underline", + "markdown.h2": "bold underline", + "markdown.h3": "bold", + "markdown.h4": "italic", + "markdown.table.header": "bold", + "markdown.table.border": "", + } + + def __init__(self, markup: str) -> None: + from rich.markdown import ( + Markdown as RichMarkdown, + MarkdownElement, + TableElement, + ) + from rich.table import Table + + class _FoldingTableElement(TableElement): + """Render long Markdown table cells by folding instead of eliding.""" + + def __rich_console__( # noqa: PLW3201 # Rich renderable protocol + self, console: RichConsole, options: ConsoleOptions + ) -> RenderResult: + for renderable in super().__rich_console__(console, options): + if isinstance(renderable, Table): + for column in renderable.columns: + column.overflow = "fold" + yield renderable + + class _FoldingMarkdown(RichMarkdown): + """Rich Markdown variant that never ellipsizes table cells.""" + + elements: ClassVar[dict[str, type[MarkdownElement]]] = { + **RichMarkdown.elements, + "table_open": _FoldingTableElement, + } + + self._markdown = _FoldingMarkdown(markup) + self._markup = markup + + def __rich_console__( # noqa: PLW3201 # Rich renderable protocol + self, console: RichConsole, options: ConsoleOptions + ) -> RenderResult: + from rich.styled import Styled + from rich.theme import Theme + + theme = Theme(self._THEME_OVERRIDES, inherit=True) + try: + with console.use_theme(theme): + yield from Styled(self._markdown, "dim").__rich_console__( + console, options + ) + except Exception: + # Rich markdown or theme application blew up on malformed input. + # Fall back to the raw source so the chat view keeps rendering. + logger.warning( + "Rich markdown rendering failed; falling back to plain text", + exc_info=True, + ) + yield from Styled(self._markup, "dim italic").__rich_console__( + console, options + ) + + +# Floor for markdown layout width so a not-yet-sized widget still renders a +# readable table instead of collapsing to a single column. +_MARKDOWN_MIN_RENDER_WIDTH = 20 + +# One-shot flag (mutable holder to avoid a `global` statement) set once the first +# markdown style conversion fails, so a systematic breakage (e.g. a Rich/Textual +# version drift) surfaces at `warning` once instead of staying invisible at +# `debug`, without spamming a line per unconvertible span. +_markdown_style_conversion_warned = [False] + + +def _markdown_to_content( + markup: str, width: int, console: RichConsole | None = None +) -> Content: + """Render muted markdown to selectable `Content` at a fixed width. + + Textual's mouse text-selection only works over widgets whose rendered + visual is `Content` or Rich `Text`; a raw Rich renderable (such as + `_MutedRichMarkdown`) renders as a `RichVisual`, which carries none of the + per-cell offset metadata selection relies on, so its text can be neither + highlighted nor copied. Rendering the markdown to segments and rebuilding + them as `Content` preserves the visual (tables, rules, emphasis) while + making the text selectable. + + Args: + markup: The markdown source to render. + width: Target render width in cells; the markdown is laid out to fit. + console: Console used to render segments; a default is created when + `None`. + + Returns: + `Content` visually equivalent to the rendered markdown, with trailing + whitespace trimmed from each line so copies stay clean. + """ + from rich.console import Console + from rich.segment import Segment + from textual.content import Span + from textual.style import Style + + render_width = max(width, 1) + if console is None: + console = Console(width=render_width) + segments = console.render( + _MutedRichMarkdown(markup), console.options.update_width(render_width) + ) + content_lines: list[Content] = [] + for line in Segment.split_lines(segments): + text = "".join(segment.text for segment in line) + stripped = text.rstrip() + spans: list[Span] = [] + position = 0 + for segment in line: + start = position + position += len(segment.text) + if start >= len(stripped): + break + end = min(position, len(stripped)) + if segment.style is not None and end > start: + try: + style = Style.from_rich_style(segment.style) + except Exception: # style conversion is best-effort + if not _markdown_style_conversion_warned[0]: + _markdown_style_conversion_warned[0] = True + logger.warning( + "Failed to convert a markdown style; markdown will " + "render without some styling (later occurrences log " + "at debug)", + exc_info=True, + ) + else: + logger.debug( + "Skipping unconvertible markdown style", exc_info=True + ) + else: + spans.append(Span(start, end, style)) + content_lines.append(Content(stripped, spans)) + while content_lines and not content_lines[-1].plain: + content_lines.pop() + return Content("\n").join(content_lines) + + +class AppMessage(Static): + """Widget displaying an app message.""" + + # Disable Textual's auto_links to prevent a flicker cycle: Style.__add__ + # calls .copy() for linked styles, generating a fresh random _link_id on + # each render. This means highlight_link_id never stabilizes, causing an + # infinite hover-refresh loop. + auto_links = False + + DEFAULT_CSS = """ + AppMessage { + height: auto; + padding: 0 1; + margin: 0 0 1 0; + color: $text-muted; + text-style: italic; + pointer: text; + } + """ + + def __init__( + self, + message: str | Content, + *, + markdown: bool = False, + **kwargs: Any, + ) -> None: + """Initialize a system message. + + Args: + message: The system message as a string or pre-styled `Content`. + markdown: When `True`, render `message` as markdown (tables, + headings, bold, etc.). + + Requires a string message — `Content` objects already carry + their own structure. + **kwargs: Additional arguments passed to parent. + + Raises: + TypeError: If `markdown=True` is combined with a non-string + `message`. + """ + self._content = message + self._is_markdown = markdown + # Markdown is rendered lazily in `render()` so it can be laid out to the + # widget's current width and rebuilt as selectable `Content`. + self._markdown_cache: tuple[int, Content] | None = None + if markdown: + if not isinstance(message, str): + msg = "AppMessage(markdown=True) requires a string message" + raise TypeError(msg) + rendered: Content = Content("") + elif isinstance(message, Content): + rendered = message + else: + rendered = Content.styled(message, "dim italic") + super().__init__(rendered, **kwargs) + + def render(self) -> Content: + """Render the message, laying out markdown to the current width. + + Returns: + The message `Content`. Markdown is rendered to selectable `Content` + sized to the widget's current render width so it can be + highlighted and copied. + """ + if not self._is_markdown: + return super().render() # ty: ignore[invalid-return-type] + width = self._markdown_render_width() + # The cache is keyed on width only: captured spans are late-bound styles + # (`dim`, `bold`, ANSI colors) that Textual resolves against the active + # theme at display time, so cached `Content` still recolors on a theme + # switch and does not need re-rendering. + if self._markdown_cache is None or self._markdown_cache[0] != width: + try: + console = self.app.console + except NoActiveAppError: + console = None + content = _markdown_to_content(str(self._content), width, console) + self._markdown_cache = (width, content) + return self._markdown_cache[1] + + def _markdown_render_width(self) -> int: + """Best-known content width for laying out markdown, with fallbacks. + + Returns: + The widget's content width, falling back to the container or app + width, and never below `_MARKDOWN_MIN_RENDER_WIDTH`. + """ + width = self.content_size.width + if width <= 0: + # `content_size` is not known yet; fall back to the container (then + # app) width and subtract this widget's own horizontal padding, + # which those outer widths — unlike `content_size` — don't exclude. + outer = self.container_size.width + if outer <= 0: + try: + outer = self.app.size.width + except NoActiveAppError: + outer = 0 + padding = self.styles.padding + width = outer - padding.left - padding.right + return max(width, _MARKDOWN_MIN_RENDER_WIDTH) + + def on_click(self, event: Click) -> None: # noqa: PLR6301 # Textual event handler + """Open style-embedded hyperlinks on single click.""" + open_style_link(event) + + def on_mouse_move(self, event: MouseMove) -> None: + """Show a pointer cursor over embedded links, text cursor elsewhere.""" + self.styles.pointer = "pointer" if event_targets_link(event) else "text" + + def on_leave(self) -> None: + """Restore the pointer shape when the mouse leaves the message. + + `"text"` restates this widget's CSS default rather than clearing the + inline style, so a subclass declaring a different `pointer` would be + forced back to `text` on leave. + """ + self.styles.pointer = "text" + + +class SummarizationMessage(AppMessage): + """Widget displaying a summarization completion notification.""" + + DEFAULT_CSS = """ + SummarizationMessage { + height: auto; + padding: 0 1; + margin: 0 0 1 0; + color: $primary; + background: $surface; + border-left: wide $primary; + text-style: bold; + pointer: text; + } + """ + + def __init__(self, message: str | Content | None = None, **kwargs: Any) -> None: + """Initialize a summarization notification message. + + Args: + message: Optional message override used when rehydrating from the + message store. + + Defaults to the standard summary notification. + **kwargs: Additional arguments passed to parent. + """ + self._raw_message = message + # Pass the default text to AppMessage for _content serialization; + # render() supplies theme-aware styling at display time. + super().__init__(message or "✓ Conversation offloaded", **kwargs) + + def render(self) -> Content: + """Render with theme-aware colors. + + Returns: + Styled summarization content with theme-appropriate color. + """ + colors = theme.get_theme_colors(self) + if self._raw_message is None: + return Content.styled("✓ Conversation offloaded", f"bold {colors.primary}") + if isinstance(self._raw_message, Content): + return self._raw_message + return Content.styled(self._raw_message, f"bold {colors.primary}") diff --git a/libs/code/deepagents_code/tui/widgets/model_selector.py b/libs/code/deepagents_code/tui/widgets/model_selector.py new file mode 100644 index 0000000000..64d569d2e3 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/model_selector.py @@ -0,0 +1,2306 @@ +"""Interactive model selector screen for `/model` command.""" + +from __future__ import annotations + +import asyncio +import logging +import os +from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple + +from textual.binding import Binding, BindingType +from textual.containers import Container, Vertical, VerticalScroll +from textual.content import Content +from textual.css.query import NoMatches +from textual.events import ( + Click, # noqa: TC002 - needed at runtime for Textual event dispatch +) +from textual.fuzzy import Matcher +from textual.message import Message +from textual.screen import ModalScreen +from textual.widgets import Input, Static + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from textual.app import ComposeResult + from textual.timer import Timer + +from deepagents_code import _env_vars, theme +from deepagents_code.auth_display import format_auth_indicator +from deepagents_code.config import Glyphs, get_glyphs, is_ascii_mode +from deepagents_code.model_config import ( + CODEX_PROVIDER, + ModelConfig, + ModelProfileEntry, + ModelSpec, + ProviderAuthState, + ProviderAuthStatus, + clear_auto_classifier_model, + clear_default_model, + get_available_models, + get_credential_env_var, + get_model_profiles, + get_provider_auth_status, + load_recent_models, + save_auto_classifier_model, + save_default_model, +) + +logger = logging.getLogger(__name__) + +_MODEL_LIST_MAX_HEIGHT = 16 +"""Upper bound (in cells) for the model selector list. + +Keep in sync with the `max-height: 16` in the `.model-list` CSS below; Textual +CSS cannot reference Python constants, so the static cap and the runtime +`_fit_model_list` clamp must agree. +""" + +_MODEL_LIST_MIN_HEIGHT = 1 +"""Floor (in cells) so the model selector list never collapses to zero.""" + +_RECENT_SECTION_LABEL = "Recent" +"""Header label for the MRU pseudo-provider section pinned at the top of `/model`. + +Recent picks are surfaced regardless of the recommended-only toggle — +they're a personal signal that outweighs curation — and de-duplicated +from the per-provider sections below. +""" + + +_RECOMMENDED_MODELS: dict[str, str] = { + "anthropic:claude-opus-4-8": "Claude Opus 4.8", + "anthropic:claude-opus-5": "Claude Opus 5", + "anthropic:claude-sonnet-5": "Claude Sonnet 5", + "baseten:deepseek-ai/DeepSeek-V4-Flash-0731": "DeepSeek V4 Flash 0731", + "baseten:deepseek-ai/DeepSeek-V4-Pro": "DeepSeek V4 Pro", + "baseten:moonshotai/Kimi-K3": "Kimi K3", + "baseten:nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B": "Nemotron 3 Ultra 550B A55B", + "baseten:zai-org/GLM-5.2": "GLM 5.2", + "baseten:zai-org/GLM-5.2-Fast": "GLM 5.2 Fast", + "fireworks:accounts/fireworks/models/deepseek-v4-flash-0731": ( + "DeepSeek V4 Flash 0731" + ), + "fireworks:accounts/fireworks/models/deepseek-v4-pro": "DeepSeek V4 Pro", + "fireworks:accounts/fireworks/models/glm-5p2": "GLM 5.2", + "fireworks:accounts/fireworks/models/kimi-k3": "Kimi K3", + "fireworks:accounts/fireworks/models/minimax-m3": "MiniMax-M3", + "fireworks:accounts/fireworks/models/qwen3p7-plus": "Qwen 3.7 Plus", + "google_genai:gemini-3.6-flash": "Gemini 3.6 Flash", + "meta:muse-spark-1.1": "Muse Spark 1.1", + "meta:muse-spark-1.2": "Muse Spark 1.2", + "ollama:deepseek-v4-flash:cloud": "DeepSeek V4 Flash", + "ollama:deepseek-v4-pro:cloud": "DeepSeek V4 Pro", + "ollama:glm-5.2:cloud": "GLM 5.2", + "ollama:minimax-m3:cloud": "MiniMax-M3", + "openai:gpt-5.6-luna": "GPT-5.6 Luna", + "openai:gpt-5.6-sol": "GPT-5.6 Sol", + "openai:gpt-5.6-terra": "GPT-5.6 Terra", + "openai_codex:gpt-5.6-luna": "GPT-5.6 Luna", + "openai_codex:gpt-5.6-sol": "GPT-5.6 Sol", + "openai_codex:gpt-5.6-terra": "GPT-5.6 Terra", + "openrouter:anthropic/claude-opus-4.8": "Claude Opus 4.8", + "openrouter:anthropic/claude-sonnet-5": "Claude Sonnet 5", + "openrouter:deepseek/deepseek-v4-flash-0731": "DeepSeek V4 Flash 0731", + "openrouter:deepseek/deepseek-v4-flash:free": "DeepSeek V4 Flash (free)", + "openrouter:deepseek/deepseek-v4-pro": "DeepSeek V4 Pro", + "openrouter:google/gemini-3.6-flash": "Gemini 3.6 Flash", + "openrouter:moonshotai/kimi-k3": "Kimi K3", + "openrouter:nvidia/nemotron-3-ultra-550b-a55b": "Nemotron 3 Ultra 550B A55B", + "openrouter:openrouter/fusion": "OpenRouter Fusion", + "openrouter:qwen/qwen3.7-plus": "Qwen 3.7 Plus", + "openrouter:z-ai/glm-5.2": "GLM 5.2", + "xai:grok-4.5": "Grok 4.5", +} +"""Hand-curated frontier-tier models promoted across the UI, mapped to a +human-readable display name. + +Used by the onboarding picker (`curated=True`) and by the in-`/model` +"Recommended only" toggle (Ctrl+R). Membership tests and iteration operate on +the spec keys; the names are a display fallback for `_get_model_display_name` +when a provider package (and thus its profile `name`) is not installed — the +common case for uninstalled recommendations and onboarding, where the raw +model id (e.g. `accounts/fireworks/models/kimi-k3`) would otherwise +show. When a profile is available its upstream `name` wins, so these stay a +safety net rather than a second source of truth. + +Same model IDs may appear under multiple providers (e.g. GLM 5.2 via +`baseten`, `fireworks`, `ollama`, and `openrouter`) and are listed under each +provider intentionally so the user can pick whichever provider they have +credentials for. +""" + + +class DefaultModelScope(NamedTuple): + """Which stored preference Ctrl+S toggles, and how the footer names it. + + The selector is reused for pickers that choose something other than the + main agent model (for example the `/auto` classifier), where persisting + `[models].default` would silently retarget the model the agent itself runs + on. Each caller supplies the scope whose `[models]` key its Ctrl+S owns, or + `None` to disable Ctrl+S entirely (see `ModelSelectorScreen.__init__`). + + Nothing ties `load`, `save`, and `clear` to a single `[models]` key — that + they agree is a property of how each instance is built, so define scopes as + module-level constants next to each other rather than assembling them at a + call site. + + Attributes: + noun: Lowercase name of the preference, used verbatim mid-sentence + ("Failed to save default") and capitalized on the first character + for sentence-initial use ("Default set to …", "Default cleared"). + Must be non-empty and read correctly in both positions. + hint: Footer hint text following `'Ctrl+S '`. + load: Reads the currently stored spec, for the `(default)` marker. + save: Persists a spec, returning `False` on I/O failure. + clear: Removes the stored spec, returning `False` on I/O failure. + override_env_var: Environment variable that outranks the stored key at + launch, if any. When it is set, a successful Ctrl+S warns that the + stored value will not take effect — the marker alone would imply the + keypress changed which model runs. + """ + + noun: str + hint: str + load: Callable[[], str | None] + save: Callable[[str], bool] + clear: Callable[[], bool] + override_env_var: str | None = None + + +MAIN_MODEL_DEFAULT_SCOPE = DefaultModelScope( + noun="default", + hint="set default", + load=lambda: ModelConfig.load().default_model, + save=save_default_model, + clear=clear_default_model, +) +"""Ctrl+S target for `/model`: the main agent model (`[models].default`). + +Persisting is validation-free, but `action_set_default` refuses rows whose +provider integration is not installed (those can never build). `-M/--model` +outranks this key for a single launch. +""" + +AUTO_CLASSIFIER_DEFAULT_SCOPE = DefaultModelScope( + noun="default classifier model", + hint="set classifier default", + load=lambda: ModelConfig.load().auto_classifier_model, + save=save_auto_classifier_model, + clear=clear_auto_classifier_model, + override_env_var=_env_vars.AUTO_CLASSIFIER_MODEL, +) +"""Ctrl+S target for `/auto model`: the Auto approval classifier +(`[models].auto_classifier`). + +Persisting is validation-free and, as with `/model`'s Ctrl+S, +`action_set_default` refuses rows whose provider integration is not installed +(those can never build). A stored classifier that cannot be built for any other +reason fails closed at review time — those actions are denied and repeated +failures escalate to human approval — rather than quietly reverting to the main +agent model. + +Unlike `[models].default`, this key can also be overridden by an environment +variable (`DEEPAGENTS_CODE_AUTO_CLASSIFIER_MODEL`) as well as by +`--auto-classifier-model`, so a stored value is not necessarily the classifier +in force. `action_set_default` says so in its success toast when the export is +set, since otherwise a stored spec that changes nothing still renders +`(default)`. +""" + + +class _ModelData(NamedTuple): + """Model discovery data returned by `ModelSelectorScreen._load_model_data`. + + Attributes: + all_models: `(provider:model spec, provider)` pairs for every model to + surface, including install-required recommended models. + default_spec: The stored spec for the screen's `DefaultModelScope`, or + `None` when nothing is stored or the screen has no scope. + profiles: Spec string to profile entry mapping. + recent_specs: Most-recent-first `provider:model` specs read from + `~/.deepagents/.state/recent_models.json`. + install_extras: Each surfaced-but-uninstalled provider mapped to the + extra that installs it. + """ + + all_models: list[tuple[str, str]] + default_spec: str | None + profiles: Mapping[str, ModelProfileEntry] + recent_specs: list[str] + install_extras: dict[str, str] + + +class ModelOption(Static): + """A clickable model option in the selector.""" + + def __init__( + self, + label: str | Content, + model_spec: str, + provider: str, + index: int, + *, + auth_status: ProviderAuthStatus | None = None, + classes: str = "", + show_provider: bool = True, + ) -> None: + """Initialize a model option. + + Args: + label: Display content — a `Content` object (preferred) or a + plain string that `Static` will parse as markup. + model_spec: The model specification (provider:model format). + provider: The provider name. + index: The index of this option in the filtered list. + auth_status: Provider auth/readiness status. + classes: CSS classes for styling. + show_provider: Whether the row appends a dim `(provider)` tag after + the model name. `True` for the cross-provider "Recent" section, + which has no provider header to disambiguate the same model + offered by multiple providers; `False` for provider-grouped + rows where the header already names the provider. Persisted on + the widget so incremental relabels in `_move_selection` + reproduce the same display. + """ + super().__init__(label, classes=classes) + self.model_spec = model_spec + self.index = index + self.show_provider = show_provider + self.auth_status = auth_status or ProviderAuthStatus( + state=ProviderAuthState.UNKNOWN, + provider=provider, + detail="credentials unknown", + ) + + @property + def provider(self) -> str: + """Provider name, derived from the embedded auth status.""" + return self.auth_status.provider + + class Clicked(Message): + """Message sent when a model option is clicked.""" + + def __init__(self, model_spec: str, provider: str, index: int) -> None: + """Initialize the Clicked message. + + Args: + model_spec: The model specification. + provider: The provider name. + index: The index of the clicked option. + """ + super().__init__() + self.model_spec = model_spec + self.provider = provider + self.index = index + + def on_click(self, event: Click) -> None: + """Handle click on this option. + + Args: + event: The click event. + """ + event.stop() + self.post_message(self.Clicked(self.model_spec, self.provider, self.index)) + + +class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]): + """Full-screen modal for model selection. + + Displays available models grouped by provider with keyboard navigation + and search filtering. Current model is highlighted. + + Returns (model_spec, provider) tuple on selection, or None on cancel. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", "Up", show=False, priority=True), + Binding("down", "move_down", "Down", show=False, priority=True), + Binding("tab", "tab_complete", "Tab complete", show=False, priority=True), + Binding("pageup", "page_up", "Page up", show=False, priority=True), + Binding("pagedown", "page_down", "Page down", show=False, priority=True), + Binding("enter", "select", "Select", show=False, priority=True), + Binding("ctrl+s", "set_default", "Set default", show=False, priority=True), + Binding( + "ctrl+r", + "toggle_recommended", + "Recommended only", + show=False, + priority=True, + ), + # Description stays mode-neutral because the footer hint (see + # `_help_text`) flips with `_show_specs` while this string cannot. + Binding( + "ctrl+n", "toggle_names", "Toggle model IDs", show=False, priority=True + ), + Binding("escape", "cancel", "Cancel", show=False, priority=True), + ] + """Key bindings for model navigation, selection, defaulting, and cancel. + + Arrows move the cursor, Page Up/Down jump by a visual page, Tab copies + the highlighted spec into the filter input, Enter selects, Ctrl+S toggles + the screen's stored default (which preference that is comes from its + `DefaultModelScope`; inert when the screen has none), Ctrl+R toggles + between showing all installed models and the hand-curated "recommended" + subset, Ctrl+N toggles rows between friendly display names and raw + `provider:model` specs (the analog of the `/theme` picker's `n` key), and + Esc dismisses. All bindings use + `priority=True` so they take precedence over the embedded `Input`; + vim-style `j`/`k` bindings — and a bare `n` mirroring the `/theme` + picker — are deliberately omitted because they would prevent typing those + letters into the always-focused filter input, which is why the names + toggle is bound to `ctrl+n` instead. + """ + + CSS = """ + ModelSelectorScreen { + align: center middle; + } + + ModelSelectorScreen > Vertical { + width: 76; + max-width: 90%; + height: auto; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + ModelSelectorScreen .model-selector-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + ModelSelectorScreen .model-selector-description { + height: auto; + color: $text-muted; + margin-bottom: 1; + } + + ModelSelectorScreen .model-selector-info { + height: auto; + color: $text-muted; + margin-bottom: 1; + } + + ModelSelectorScreen #model-filter { + margin-bottom: 1; + border: solid $primary-lighten-2; + } + + ModelSelectorScreen #model-filter:focus { + border: solid $primary; + } + + ModelSelectorScreen .model-list { + height: auto; + min-height: 1; + max-height: 16; /* keep in sync with `_MODEL_LIST_MAX_HEIGHT` */ + scrollbar-gutter: stable; + background: $background; + } + + ModelSelectorScreen #model-options { + height: auto; + } + + ModelSelectorScreen .model-provider-header { + color: $primary; + margin-top: 1; + } + + ModelSelectorScreen #model-options > .model-provider-header:first-child { + margin-top: 0; + } + + ModelSelectorScreen .model-option { + height: 1; + padding: 0 1; + } + + ModelSelectorScreen .model-option:hover { + background: $surface-lighten-1; + } + + ModelSelectorScreen .model-option-selected { + background: $primary; + color: $background; + text-style: bold; + } + + ModelSelectorScreen .model-option-selected:hover { + background: $primary-lighten-1; + } + + ModelSelectorScreen .model-option-current { + text-style: italic; + } + + ModelSelectorScreen .model-selector-help { + height: auto; /* keep auto so the standard footer wraps; see _help_text */ + color: $text-muted; + text-style: italic; + margin-top: 1; + text-align: center; + } + + ModelSelectorScreen .model-detail-footer { + height: 4; + padding: 0 2; + margin-top: 1; + } + """ + """Styling for the modal shell, filter input, provider-grouped list, detail + footer, and help text.""" + + def __init__( + self, + current_model: str | None = None, + current_provider: str | None = None, + cli_profile_override: dict[str, Any] | None = None, + *, + curated: bool = False, + recommended_models: Mapping[str, str] | None = None, + include_recent_models: bool = True, + title: str | None = None, + description: str | Content | None = None, + default_scope: DefaultModelScope | None, + result_callback: Callable[[tuple[str, str] | None], None] | None = None, + ) -> None: + """Initialize the ModelSelectorScreen. + + Data loading (model discovery, profiles) is deferred to `on_mount` + so the screen pushes instantly and populates asynchronously. + + Args: + current_model: The currently active model name (to highlight). + current_provider: The provider of the current model. + cli_profile_override: Extra profile fields from `--profile-override`. + + Merged on top of upstream + config.toml profiles so that app + overrides appear with `*` markers in the detail footer. + curated: Whether to show a short, profile-ranked model subset. + recommended_models: Optional `provider:model` to display-name mapping + that replaces the standard recommendation set for this selector. + include_recent_models: Whether recent main-model picks should be + included in the recommended view. + title: Optional title override for the selector. + description: Optional description shown below the title. + default_scope: Preference Ctrl+S toggles. Required, with no default: + every picker must state which key its Ctrl+S owns, because a + picker choosing something other than the main agent model (e.g. + the `/auto` classifier) would otherwise silently retarget the + model the agent itself runs on. Pass + `MAIN_MODEL_DEFAULT_SCOPE` for `/model`, or `None` to disable + Ctrl+S and drop its footer hint — for pickers whose choice has + no persistent config key (the `/goal model` and `/rubric model` + graders) and for onboarding, which advertises no Ctrl+S. + result_callback: Optional callback for selector results when the + screen is displayed without a `push_screen` result callback. + """ + super().__init__() + self._current_model = current_model + self._current_provider = current_provider + self._cli_profile_override = cli_profile_override + self._curated = curated + self._recommended_models = ( + _RECOMMENDED_MODELS + if recommended_models is None + else dict(recommended_models) + ) + self._include_recent_models = include_recent_models + self._title = title + self._description = description + self._default_scope = default_scope + self._result_callback = result_callback + # Standard /model defaults to the curated recommended subset so users + # face less decision fatigue; onboarding (`curated=True`) already + # constrains the list via `_curated`, so leaving this False there + # avoids double-flagging in `_apply_subset`. + self._recommended_only = not curated + # Rows show friendly display names by default; Ctrl+N flips every row + # to its raw `provider:model` spec (mirrors the `/theme` picker's + # label/key toggle) so a user can read or copy the canonical id. + self._show_specs = False + # True while the footer is displaying a Ctrl+S failure notice, so other + # footer writers (Ctrl+N) leave it alone until its restore timer fires. + self._help_error_shown = False + # The pending Ctrl+S success-message restore timer, tracked so a + # subsequent failure can cancel it: an orphaned timer would otherwise + # fire mid-error and wipe the persistent failure notice. Failure paths + # schedule no timer of their own, so this is the only handle that can + # clobber an error. + self._help_restore_timer: Timer | None = None + + self._unfiltered_models: list[tuple[str, str]] = [] + self._recent_specs: list[str] = [] + # Providers surfaced in the list whose integration package is not + # installed, mapped to the extra that installs them. Selecting one + # routes through the install-confirm modal instead of an auth prompt. + self._install_extras: dict[str, str] = {} + # Set when the user confirms installing a provider's extra; the app + # reads this off the screen after dismissal to install then switch. + self.pending_install_extra: str | None = None + + self._all_models: list[tuple[str, str]] = [] + self._filtered_models: list[tuple[str, str]] = [] + self._selected_index = 0 + self._options_container: Container | None = None + self._option_widgets: list[ModelOption] = [] + self._filter_text = "" + self._current_spec: str | None = None + if current_model and current_provider: + self._current_spec = f"{current_provider}:{current_model}" + self._default_spec: str | None = None + self._profiles: Mapping[str, ModelProfileEntry] = {} + self._loaded = False + + def _info_line_content(self) -> Content: + """Build the info line shown above the filter input. + + Reflects whether the screen is filtered to the recommended subset. + + Returns: + Styled `Content` for the info line. + """ + if self._filter_text.strip(): + return Content.styled("Searching all models from installed providers") + if self._recommended_only: + return Content.styled( + "Showing recommended models — Ctrl+R for all", + ) + return Content.styled( + "Showing all models from installed providers — Ctrl+R for recommended", + ) + + def _update_info_line(self) -> None: + """Refresh the standard selector info line.""" + if self._curated: + return + info = self.query_one("#model-selector-info", Static) + info.update(self._info_line_content()) + + def _help_text(self) -> str: + """Build the footer help text. + + Curated/onboarding mode omits the Ctrl+S, Ctrl+R, and Ctrl+N hints. + Escape stays bound but is left off the hint line — modal dismissal via + Escape is conventional, and advertising it would only lengthen an + already-wrapping line. In standard mode the full line exceeds the modal + width, so the help `Static` is sized to grow (auto height) and wraps to + two rows rather than clipping the trailing hints. + + The Ctrl+N hint names what the next press *does* rather than the + current mode, so it reads "Ctrl+N IDs" while friendly names are shown + and "Ctrl+N names" once rows are flipped to raw `provider:model` specs. + The Ctrl+S hint comes from the screen's `DefaultModelScope`, so a picker + that stores something other than the main agent model says so, and a + picker with no scope (`default_scope=None`) omits the hint rather than + advertising a key that does nothing. Curated mode omits the hint for a + different reason — a shorter footer — so it is also constructed with + `default_scope=None`, keeping "no hint" and "Ctrl+S is inert" the same + condition rather than two that can disagree. + + Returns: + The bullet-separated help line. + """ + glyphs = get_glyphs() + parts = [ + f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate", + "Tab autocomplete", + "Enter select", + ] + if not self._curated: + names_hint = "Ctrl+N names" if self._show_specs else "Ctrl+N IDs" + if self._default_scope is not None: + parts.append(f"Ctrl+S {self._default_scope.hint}") + parts.extend(("Ctrl+R recommended", names_hint)) + sep = f" {glyphs.bullet} " + return sep.join(parts) + + def _find_current_model_index(self) -> int: + """Find the index of the current model in the filtered list. + + Returns: + Index of the current model, or 0 if not found. + """ + if not self._current_model or not self._current_provider: + return 0 + + current_spec = f"{self._current_provider}:{self._current_model}" + for i, (model_spec, _) in enumerate(self._filtered_models): + if model_spec == current_spec: + return i + return 0 + + def _initial_selected_index(self) -> int: + """Return the default highlighted row for the current selector mode.""" + if self._curated: + return 0 + return self._find_current_model_index() + + def compose(self) -> ComposeResult: + """Compose the screen layout. + + Yields: + Widgets for the model selector UI. + """ + with Vertical(): + # Title with current model in provider:model format + if self._title: + title = self._title + elif self._current_model and self._current_provider: + current_spec = f"{self._current_provider}:{self._current_model}" + title = f"Select Model (current: {current_spec})" + elif self._current_model: + title = f"Select Model (current: {self._current_model})" + else: + title = "Select Model" + yield Static(title, classes="model-selector-title") + if self._description: + yield Static( + self._description, + classes="model-selector-description", + ) + + if not self._curated: + yield Static( + self._info_line_content(), + classes="model-selector-info", + id="model-selector-info", + ) + + # Search input + yield Input( + placeholder="Type to filter or enter provider:model...", + id="model-filter", + ) + + # Scrollable model list + with VerticalScroll(classes="model-list"): + self._options_container = Container(id="model-options") + yield self._options_container + + # Model detail footer + yield Static("", classes="model-detail-footer", id="model-detail-footer") + + yield Static(self._help_text(), classes="model-selector-help") + + @staticmethod + def _load_model_data( + cli_override: dict[str, Any] | None, + *, + include_uninstalled: bool = True, + include_recent: bool = True, + recommended_models: Mapping[str, str] | None = None, + default_scope: DefaultModelScope | None, + ) -> _ModelData: + """Gather model discovery data synchronously. + + Intended to be called via `asyncio.to_thread` so filesystem I/O in + `get_available_models` does not block the event loop. + + Args: + cli_override: Extra profile fields from `--profile-override`. + include_uninstalled: When `True`, append recommended models that + aren't already surfaced, in two cases: (1) the provider + integration isn't installed, added as greyed-out + install-required rows; (2) the provider is installed but its + upstream profiles omit the model, added as normal selectable + rows. + include_recent: When `True`, load the recent-models MRU so the + pinned "Recent" section can render. Onboarding sets this + `False`: first-run users have never picked a model, and the + startup default-fallback resolution writes its auto-detected + pick into the MRU, which would otherwise surface as a bogus + "Recent" entry the user never chose. + recommended_models: Recommendation set whose missing provider models + should be surfaced. `None` uses the standard model shortlist. + default_scope: Preference whose stored spec is read for the + `(default)` marker, stripped of surrounding whitespace so it can + match a row. `None` yields no marker. + + Returns: + A `_ModelData` bundle of the discovered models, default spec, + profiles, recent specs, and install-required provider extras. + """ + available = get_available_models() + config = ModelConfig.load() + all_models: list[tuple[str, str]] = [ + (f"{provider}:{model}", provider) + for provider, models in available.items() + for model in models + ] + + install_extras: dict[str, str] = {} + if include_uninstalled: + from deepagents_code.config_manifest import ( + is_provider_package_installed, + provider_install_extra, + ) + + # Seeded from the discovered models; a recommended spec already + # surfaced here is skipped below. Recommended specs are unique (dict + # keys iterated once), so this entry guard is the only dedup needed + # and the set never has to grow inside the loop. + existing_specs = {spec for spec, _ in all_models} + installed_recommended: list[tuple[str, str]] = [] + uninstalled_recommended: list[tuple[str, str]] = [] + recommendations = ( + _RECOMMENDED_MODELS + if recommended_models is None + else recommended_models + ) + for spec in sorted(recommendations): + if spec in existing_specs: + continue + provider = spec.split(":", 1)[0] + try: + if not config.is_provider_enabled(provider): + continue + extra = provider_install_extra(provider) + provider_installed = is_provider_package_installed(provider) + except Exception: + # Isolate per-provider probe failures so one bad recommended + # provider can't take down the entire model list (the caller + # degrades any raise here to an empty selector). The append + # bookkeeping below stays outside this guard so genuine logic + # bugs surface instead of being silently swallowed. + logger.warning( + "Skipping recommended model %r while merging " + "recommendations into the model list", + spec, + exc_info=True, + ) + continue + if provider in available and provider_installed: + # Provider is installed and discoverable, but its upstream + # profiles don't surface this curated model (missing entry + # or filtered out). Add it as a normal selectable row so the + # hardcoded recommendation isn't silently dropped when the + # profile list lags. + installed_recommended.append((spec, provider)) + continue + if extra is None or provider_installed: + continue + install_extras[provider] = extra + uninstalled_recommended.append((spec, provider)) + all_models.extend(installed_recommended) + all_models.extend(uninstalled_recommended) + + profiles = get_model_profiles(cli_override=cli_override) + recent_specs = load_recent_models() if include_recent else [] + stored_default = default_scope.load() if default_scope is not None else None + if stored_default is not None: + # Hand-edited TOML can carry surrounding whitespace, and the launch + # resolvers strip it. Strip here too or the stored spec would match + # no row: no `(default)` marker, and Ctrl+S on the model the user + # believes is stored would take the *save* branch instead of + # toggling it off, leaving no in-app way to remove it. Blank + # degrades to "nothing stored", matching the launch warning that + # ignores a blank value. + stored_default = stored_default.strip() or None + return _ModelData( + all_models, + stored_default, + profiles, + recent_specs, + install_extras, + ) + + def _apply_subset( + self, + all_models: list[tuple[str, str]], + ) -> list[tuple[str, str]]: + """Apply the active subset filter (onboarding or recommended-only). + + Recently-used specs are unioned in even when the recommended-only + toggle is on, so personal usage always wins over curation. Onboarding + intentionally keeps a tight curated subset and skips this union. + + Args: + all_models: Full list of `(provider:model, provider)` pairs. + + Returns: + The list reduced to the recommended subset when either + `_curated` (onboarding) or `_recommended_only` (Ctrl+R) is + active. Falls back to the full list when no recommended + models are installed so the screen is never empty. + """ + if self._curated: + return self._curate_models( + all_models, + recommended_models=self._recommended_models, + ) + if self._recommended_only: + curated = self._curate_models( + all_models, + recommended_models=self._recommended_models, + ) + curated_specs = {spec for spec, _ in curated} + # Order follows all_models (insertion), not MRU; _update_display + # rebuilds visual order by iterating self._recent_specs directly. + recent_extra = ( + [ + (spec, provider) + for spec, provider in all_models + if spec in self._recent_specs and spec not in curated_specs + ] + if self._include_recent_models + else [] + ) + return [*recent_extra, *curated] + return list(all_models) + + @staticmethod + def _curate_models( + all_models: list[tuple[str, str]], + *, + recommended_models: Mapping[str, str] | None = None, + ) -> list[tuple[str, str]]: + """Return the active recommendation list in model-switcher order. + + When none of the recommendations are available, returns the full + switcher list so the selector never becomes empty. + + Args: + all_models: Full list of `(provider:model, provider)` pairs. + recommended_models: Recommendation set to filter against. `None` + uses the standard model shortlist. + + Returns: + Models from the active recommendation set, or `all_models` when no + recommendation is available. + """ + recommendations = ( + _RECOMMENDED_MODELS if recommended_models is None else recommended_models + ) + frontier = [ + (spec, provider) for spec, provider in all_models if spec in recommendations + ] + return frontier or all_models + + async def on_mount(self) -> None: + """Set up the screen on mount. + + Loads model data in a background thread so the screen frame renders + immediately, then populates the model list. + """ + if is_ascii_mode(): + colors = theme.get_theme_colors(self) + container = self.query_one(Vertical) + container.styles.border = ("ascii", colors.success) + self.call_after_refresh(self._fit_model_list) + + # Focus the filter input immediately so the user can start typing + # while model data loads. + filter_input = self.query_one("#model-filter", Input) + filter_input.focus() + + # Offload to thread because get_available_models does filesystem I/O + try: + data = await asyncio.to_thread( + self._load_model_data, + self._cli_profile_override, + include_uninstalled=True, + include_recent=self._include_recent_models and not self._curated, + recommended_models=self._recommended_models, + default_scope=self._default_scope, + ) + except Exception: + logger.exception("Failed to load model data for /model selector") + self._loaded = True + if self.is_running: + self.notify( + "Could not load model list. " + "Check provider packages and config.toml.", + severity="error", + timeout=10, + markup=False, + ) + await self._update_display() + self._update_footer() + return + + # Screen may have been dismissed while the thread was running + if not self.is_running: + return + + self._unfiltered_models = data.all_models + self._default_spec = data.default_spec + self._profiles = data.profiles + self._recent_specs = data.recent_specs + self._install_extras = data.install_extras + self._all_models = self._apply_subset(self._unfiltered_models) + self._filtered_models = list(self._all_models) + self._selected_index = self._initial_selected_index() + self._loaded = True + + # Re-apply any filter text the user typed while data was loading + if self._filter_text: + self._update_filtered_list() + + await self._update_display() + self._update_footer() + + def on_resize(self) -> None: + """Refit the model list when terminal dimensions change.""" + self.call_after_refresh(self._fit_model_list) + + def _fit_model_list(self) -> None: + """Cap the model list so modal controls stay visible.""" + try: + container = self.query_one(Vertical) + except NoMatches: + # This runs deferred via `call_after_refresh`/`on_resize`; the + # screen may have been popped before it fires (e.g. a resize racing + # dismissal). Sizing is cosmetic, so skip quietly but leave a + # breadcrumb rather than letting it surface in the event loop. + logger.debug( + "Skipping model-list refit; screen not mounted", + exc_info=True, + ) + return + # The screen is still mounted, so `.model-list` (always composed) must + # exist; a missing body here is a structural regression, not the + # teardown race, so let `NoMatches` surface rather than silently + # rendering an uncapped list. + body = self.query_one(".model-list", VerticalScroll) + non_body_height = max(0, container.region.height - body.region.height) + available_height = self.size.height - non_body_height + max_height = max( + _MODEL_LIST_MIN_HEIGHT, + min(_MODEL_LIST_MAX_HEIGHT, available_height), + ) + current = body.styles.max_height + if current is not None and current.cells == max_height: + return + body.styles.max_height = max_height + + def on_input_changed(self, event: Input.Changed) -> None: + """Filter models as user types. + + Args: + event: The input changed event. + """ + self._filter_text = event.value + self._update_info_line() + if not self._loaded: + return # on_mount will re-apply filter after data loads + self._update_filtered_list() + self.call_after_refresh(self._update_display) + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle Enter key when filter input is focused. + + Args: + event: The input submitted event. + """ + event.stop() + self.action_select() + + def on_model_option_clicked(self, event: ModelOption.Clicked) -> None: + """Handle click on a model option. + + Args: + event: The click event with model info. + """ + self._selected_index = event.index + self._select_with_auth_check(event.model_spec, event.provider) + + def _update_filtered_list(self) -> None: + """Update the filtered models based on search text using fuzzy matching. + + Results are sorted by match score (best first), with installed + providers ranked above not-yet-installed ones so the common case of + picking an available model is never displaced by an install-required + suggestion. In standard `/model` mode, non-empty searches span the + full installed model list even when the default view is currently + constrained to recommended models. + """ + query = self._filter_text.strip() + if not query: + self._filtered_models = list(self._all_models) + self._selected_index = self._initial_selected_index() + return + + tokens = query.split() + search_models = self._all_models if self._curated else self._unfiltered_models + + # Match against what the user actually sees, not just the raw spec: the + # friendly model name and provider label are folded into the search + # haystack so e.g. "Opus 4.8" finds `anthropic:claude-opus-4-8` (whose + # spec, with hyphens, the "4.8" token can't subsequence-match) and + # "OpenAI Codex" finds the codex rows. The spec stays in the haystack so + # existing muscle-memory queries keep working. + from deepagents_code.tui.widgets.auth import provider_display_name + + config = ModelConfig.load() + provider_labels: dict[str, str] = {} + + # Resolve the display labels up front, *outside* the try below. That + # fallback exists for `Matcher` choking on edge-case input; folding + # label resolution into it would misattribute an error from + # `_get_model_display_name`/`provider_display_name` to the matcher and + # silently drop the user's filter. Any failure here should surface, not + # masquerade as "no matches". + haystacks: list[tuple[str, str, str]] = [] + for spec, provider in search_models: + label = provider_labels.get(provider) + if label is None: + label = provider_display_name(provider, config) + provider_labels[provider] = label + haystacks.append( + (spec, provider, f"{spec} {self._get_model_display_name(spec)} {label}") + ) + + try: + matchers = [Matcher(token, case_sensitive=False) for token in tokens] + scored: list[tuple[float, str, str]] = [] + for spec, provider, haystack in haystacks: + scores = [m.match(haystack) for m in matchers] + if all(s > 0 for s in scores): + scored.append((min(scores), spec, provider)) + except Exception: + # graceful fallback if Matcher fails on edge-case input + logger.warning( + "Fuzzy matcher failed for query %r, falling back to full list", + query, + exc_info=True, + ) + self._filtered_models = list(search_models) + self._selected_index = self._initial_selected_index() + return + + self._filtered_models = [ + (spec, provider) + for _installed, _score, spec, provider in sorted( + ( + (provider not in self._install_extras, score, spec, provider) + for score, spec, provider in scored + ), + reverse=True, + ) + ] + self._selected_index = 0 + + @staticmethod + def _unique_model_entries( + models: list[tuple[str, str]], + ) -> list[tuple[str, str]]: + """Return unique model/provider pairs while preserving first-seen order.""" + seen: set[tuple[str, str]] = set() + unique: list[tuple[str, str]] = [] + for entry in models: + if entry in seen: + continue + seen.add(entry) + unique.append(entry) + return unique + + @staticmethod + def _find_same_occurrence_index( + models: list[tuple[str, str]], + entry: tuple[str, str], + occurrence: int, + ) -> int: + """Find the `occurrence`th matching model/provider tuple in `models`. + + Args: + models: Render-ordered model/provider pairs to search. + entry: Exact `(model_spec, provider)` tuple to match. + occurrence: One-based occurrence count to find. + + Returns: + Matching index, or `0` if no matching occurrence exists. + """ + matches = 0 + first_match: int | None = None + for i, candidate in enumerate(models): + if candidate != entry: + continue + if first_match is None: + first_match = i + matches += 1 + if matches == occurrence: + return i + return first_match or 0 + + # Lower ranks render first: providers the user can use right now lead, + # then providers whose readiness is unknown, then providers needing a + # missing credential, then ones that aren't even installed. A missing + # credential sits above not-installed since fixing it is just an auth + # prompt away. + _PROVIDER_AVAILABLE_RANK = 0 + _PROVIDER_UNKNOWN_RANK = 1 + _PROVIDER_MISSING_RANK = 2 + _PROVIDER_UNINSTALLED_RANK = 3 + + def _provider_availability_rank( + self, + provider: str, + auth_status: ProviderAuthStatus, + ) -> int: + """Return a sort rank that floats usable providers to the top. + + Args: + provider: Provider name being ranked. + auth_status: The provider's resolved auth/readiness status. + + Returns: + A rank where lower values sort earlier: ready-to-use providers + first, then unknown, then missing-credential, then + not-installed providers. + """ + if provider in self._install_extras: + return self._PROVIDER_UNINSTALLED_RANK + state = auth_status.state + if state in { + ProviderAuthState.CONFIGURED, + ProviderAuthState.NOT_REQUIRED, + ProviderAuthState.IMPLICIT, + ProviderAuthState.MANAGED, + }: + return self._PROVIDER_AVAILABLE_RANK + if state is ProviderAuthState.UNKNOWN: + return self._PROVIDER_UNKNOWN_RANK + return self._PROVIDER_MISSING_RANK + + async def _update_display(self) -> None: + """Render the model list grouped by provider. + + Performs a full DOM rebuild (removes all children, re-mounts). + Arrow-key navigation uses `_move_selection` instead to avoid + the cost of a full rebuild. + """ + if not self._options_container: + return + + await self._options_container.remove_children() + self._option_widgets = [] + + if not self._filtered_models: + if not self._loaded: + empty_content: Content = Content.styled("Loading models…", "dim") + else: + typed = self._filter_text.strip() + if typed and ":" in typed: + empty_content = Content.assemble( + ("No matching models — press ", "dim"), + ("Enter", "bold"), + (" to use ", "dim"), + (typed, "bold"), + (" as a custom provider:model spec", "dim"), + ) + elif typed: + empty_content = Content.assemble( + ("No matching models — press ", "dim"), + ("Enter", "bold"), + (" to use ", "dim"), + (typed, "bold"), + (" as a custom model spec (no provider prefix)", "dim"), + ) + else: + empty_content = Content.styled("No matching models", "dim") + await self._options_container.mount(Static(empty_content)) + self._update_footer() + self.call_after_refresh(self._fit_model_list) + return + + has_filter = bool(self._filter_text.strip()) + source = self._filtered_models if has_filter else self._all_models + source_models = self._unique_model_entries(source) + + # Resolve which recent specs are present in the current filtered set. + # Recent rendering only happens at the top of an unfiltered view; once + # the user starts fuzzy-filtering, recents are surfaced through the + # match logic like any other model so the search remains predictable. + if has_filter: + recent_entries: list[tuple[str, str]] = [] + else: + spec_to_provider = dict(source_models) + recent_entries = [ + (spec, spec_to_provider[spec]) + for spec in self._recent_specs + if spec in spec_to_provider + ] + # Group models by provider, preserving insertion order so models + # from the same provider cluster together in the visual list. Specs + # also in the Recent section are intentionally kept here so a user + # who opens `/model` always finds their model at its provider's + # familiar position in addition to the MRU shortcut at the top. + by_provider: dict[str, list[tuple[str, str]]] = {} + for model_spec, provider in source_models: + by_provider.setdefault(provider, []).append((model_spec, provider)) + + # Resolve provider auth upfront so it can both drive the + # availability-first ordering below and feed the widget-building loop. + auth_statuses = {p: get_provider_auth_status(p) for p in by_provider} + + # In the default (unfiltered) view, float providers the user can + # actually use to the top so a usable model is reachable without + # scrolling or searching. Providers needing missing credentials or a + # package install sink to the bottom. A search already orders by match + # score (installed providers first), so leave that ordering untouched. + if not has_filter: + ordered_providers = sorted( + by_provider, + key=lambda p: self._provider_availability_rank(p, auth_statuses[p]), + ) + by_provider = {p: by_provider[p] for p in ordered_providers} + + # Rebuild _filtered_models to match the rendered order (recents first, + # then provider-grouped). Without this, _filtered_models stays in + # score-sorted order while _option_widgets follow rendered order, + # causing _update_footer to look up the wrong model for the + # highlighted index. + grouped_order: list[tuple[str, str]] = list(recent_entries) + for entries in by_provider.values(): + grouped_order.extend(entries) + + # Remap selected_index so the same visual occurrence stays highlighted. + old_entry = self._filtered_models[self._selected_index] + old_occurrence = self._filtered_models[: self._selected_index + 1].count( + old_entry + ) + self._filtered_models = grouped_order + self._selected_index = self._find_same_occurrence_index( + grouped_order, + old_entry, + old_occurrence, + ) + + glyphs = get_glyphs() + flat_index = 0 + selected_widget: ModelOption | None = None + + # Build current model spec for comparison + current_spec = None + if self._current_model and self._current_provider: + current_spec = f"{self._current_provider}:{self._current_model}" + + # Collect all widgets first, then batch-mount once to avoid + # individual DOM mutations per widget + all_widgets: list[Static] = [] + + # Pinned "Recent" section — pseudo-provider header with no auth badge + # because each entry already carries its real provider's auth state + # via `ModelOption.auth_status`. + if recent_entries: + all_widgets.append( + Static( + Content.from_markup( + "[bold]$label[/bold]", label=_RECENT_SECTION_LABEL + ), + classes="model-provider-header", + ) + ) + for model_spec, real_provider in recent_entries: + auth_status = auth_statuses[real_provider] + is_current = model_spec == current_spec + is_selected = flat_index == self._selected_index + + classes = "model-option" + if is_selected: + classes += " model-option-selected" + if is_current: + classes += " model-option-current" + + label = self._build_option_label( + model_spec, real_provider, auth_status, selected=is_selected + ) + widget = ModelOption( + label=label, + model_spec=model_spec, + provider=real_provider, + index=flat_index, + auth_status=auth_status, + classes=classes, + ) + all_widgets.append(widget) + self._option_widgets.append(widget) + if is_selected: + selected_widget = widget + flat_index += 1 + + # Resolve friendly provider labels via the shared helper so headers + # match the `/auth` and install UIs (e.g. `openai_codex` renders as + # "OpenAI Codex (ChatGPT login)"). Load config once; the helper reads a + # user-configured `display_name` before the built-in map. + from deepagents_code.tui.widgets.auth import provider_display_name + + config = ModelConfig.load() + + for provider, model_entries in by_provider.items(): + # Provider header; auth/readiness indicator appended only when non-empty. + auth_status = auth_statuses[provider] + provider_label = provider_display_name(provider, config) + if provider in self._install_extras: + auth_indicator = self._install_indicator() + else: + auth_indicator = self._format_auth_indicator(auth_status, glyphs) + if auth_indicator: + header_content = Content.from_markup( + "[bold]$provider[/bold] [dim]$auth[/dim]", + provider=provider_label, + auth=auth_indicator, + ) + else: + header_content = Content.from_markup( + "[bold]$provider[/bold]", + provider=provider_label, + ) + all_widgets.append(Static(header_content, classes="model-provider-header")) + + for model_spec, _prov in model_entries: + is_current = model_spec == current_spec + is_selected = flat_index == self._selected_index + + classes = "model-option" + if is_selected: + classes += " model-option-selected" + if is_current: + classes += " model-option-current" + + label = self._build_option_label( + model_spec, + provider, + auth_status, + selected=is_selected, + show_provider=False, + ) + widget = ModelOption( + label=label, + model_spec=model_spec, + provider=provider, + index=flat_index, + auth_status=auth_status, + classes=classes, + show_provider=False, + ) + all_widgets.append(widget) + self._option_widgets.append(widget) + + if is_selected: + selected_widget = widget + + flat_index += 1 + + await self._options_container.mount(*all_widgets) + + # Scroll the selected item into view without animation so the list + # appears already scrolled to the current model on first paint. + if selected_widget: + if self._selected_index == 0: + # First item: scroll to top so header is visible + scroll_container = self.query_one(".model-list", VerticalScroll) + scroll_container.scroll_home(animate=False) + else: + selected_widget.scroll_visible(animate=False) + + self._update_footer() + self.call_after_refresh(self._fit_model_list) + + @staticmethod + def _format_auth_indicator( + auth_status: ProviderAuthStatus, + glyphs: Glyphs, + ) -> str: + """Build the provider header auth indicator. + + Args: + auth_status: Provider auth/readiness status. + glyphs: Glyph table for the active terminal mode. + + Returns: + Text shown next to the provider name, or an empty string when no + indicator should be rendered (e.g., `CONFIGURED`). + """ + return format_auth_indicator(auth_status, glyphs) + + @staticmethod + def _install_indicator() -> str: + """Return the provider-header text for an uninstalled provider.""" + return "not installed" + + def _build_option_label( + self, + model_spec: str, + provider: str, + auth_status: ProviderAuthStatus, + *, + selected: bool, + show_provider: bool = True, + ) -> Content: + """Build a model-option label from the current screen state. + + Every row shows the model's human-readable name (via + `_get_model_display_name`). The cross-provider "Recent" section + (`show_provider=True`) additionally appends a dim `(provider)` tag, + since it has no provider header to disambiguate the same model offered + by multiple providers. + + Centralizes the per-row flag derivation (current/default/status/ + `install_required`) shared by the full rebuild in `_update_display` + and the incremental relabel in `_move_selection`, so the two paths + cannot drift. The original `/model` dim-persistence bug came from + exactly such drift: `_move_selection` omitted `install_required`, + so uninstalled rows stopped rendering dimmed after navigation. + + Args: + model_spec: The `provider:model` string for the row. + provider: The row's provider key, tested against the + install-required set. + auth_status: Provider auth/readiness status for the row. + selected: Whether this row is the highlighted one. + show_provider: Whether to append the dim `(provider)` tag — `True` + for Recent rows, `False` for provider-grouped rows. The tag + uses the compact brand label (`provider_short_name`). + + Returns: + Styled `Content` label. + """ + provider_label: str | None = None + if show_provider: + from deepagents_code.tui.widgets.auth import provider_short_name + + provider_label = provider_short_name(provider) + # `_show_specs` (Ctrl+N) renders the raw `provider:model` spec instead + # of the friendly name; `display_name=None` makes `_format_option_label` + # fall back to the spec and drop the redundant `(provider)` tag, which + # the spec already embeds. + display_name = ( + None if self._show_specs else self._get_model_display_name(model_spec) + ) + return self._format_option_label( + model_spec, + selected=selected, + current=model_spec == self._current_spec, + auth_status=auth_status, + is_default=model_spec == self._default_spec, + status=self._get_model_status(model_spec), + install_required=provider in self._install_extras, + display_name=display_name, + provider_label=provider_label, + ) + + @staticmethod + def _format_option_label( + model_spec: str, + *, + selected: bool, + current: bool, + auth_status: ProviderAuthStatus, + is_default: bool = False, + status: str | None = None, + install_required: bool = False, + display_name: str | None = None, + provider_label: str | None = None, + ) -> Content: + """Build the display label for a model option. + + Args: + model_spec: The `provider:model` string. + selected: Whether this option is currently highlighted. + current: Whether this is the active model. + auth_status: Provider auth/readiness status. + is_default: Whether this is the spec stored for the screen's + `DefaultModelScope` — the main agent model under `/model`, but + e.g. the stored classifier under `/auto model`. + status: Model status from profile (e.g., `'deprecated'`, + `'beta'`, `'alpha'`). `'deprecated'` renders in red; + other non-None values render in yellow. + install_required: Whether the provider's integration package is not + installed; renders the spec dimmed since selecting it prompts + an install rather than switching immediately. + display_name: Text to show in place of the full `model_spec`. When + `None`, the full spec is shown. Both the Recent and + provider-grouped rows pass the model's human-readable name (see + `_get_model_display_name`). + provider_label: When set (and `display_name` is given), appends a + dim ` (provider)` tag after the name — used by the + cross-provider Recent section, which has no provider header to + disambiguate the same model across providers. `None` for + provider-grouped rows. Ignored when `display_name` is `None`, + since the raw spec already embeds the provider. + + Returns: + Styled Content label. + """ + colors = theme.get_theme_colors() + glyphs = get_glyphs() + cursor = f"{glyphs.cursor} " if selected else " " + display = model_spec if display_name is None else display_name + # When selected, skip the inline primary color — CSS already flips the + # row to ($primary bg, $background fg). Keep `bold` so the default + # emphasis survives both states. + if install_required and not selected: + spec = Content.styled(display, "dim") + elif auth_status.blocks_start: + spec = Content.styled(display, colors.warning) + elif is_default and selected: + spec = Content.styled(display, "bold") + elif is_default: + spec = Content.styled(display, f"bold {colors.primary}") + else: + spec = Content(display) + # Dim provider tag disambiguates Recent rows (no provider header). + # Styled like `(current)` — always dim, so it survives row selection. + # Requires a friendly `display_name`: tagging the raw spec (which + # already embeds the provider) would print the provider twice. + if provider_label and display_name is not None: + provider_tag = Content.styled(f" ({provider_label})", "dim") + else: + provider_tag = Content("") + suffix = Content.styled(" (current)", "dim") if current else Content("") + if is_default and selected: + default_suffix = Content.styled(" (default)", "bold") + elif is_default: + default_suffix = Content.styled(" (default)", f"bold {colors.primary}") + else: + default_suffix = Content("") + if status == "deprecated": + status_suffix = Content.styled(" (deprecated)", colors.error) + elif status: + status_suffix = Content.styled(f" ({status})", colors.warning) + else: + status_suffix = Content("") + return Content.assemble( + cursor, spec, provider_tag, suffix, default_suffix, status_suffix + ) + + @staticmethod + def _format_footer( + profile_entry: ModelProfileEntry | None, + glyphs: Glyphs, + ) -> Content: + """Build the detail footer text for the highlighted model. + + Args: + profile_entry: Profile data with override tracking, or None. + glyphs: Glyph set for display characters. + + Returns: + Styled `Content` for the 4-line footer. + """ + from deepagents_code._session_stats import format_token_count + + if profile_entry is None or not profile_entry["profile"]: + return Content.styled("Model profile not available :(\n\n\n", "dim") + + profile = profile_entry["profile"] + overridden = profile_entry["overridden_keys"] + + colors = theme.get_theme_colors() + + def _mark(key: str, text: str) -> Content: + if key in overridden: + return Content.styled(f"*{text}", colors.warning) + return Content(text) + + def _format_token(key: str, suffix: str) -> Content | None: + """Format a token-count profile key, falling back to the raw value. + + Returns: + Styled `Content` with override marker, or None if key absent. + """ + val = profile.get(key) + if val is None: + return None + try: + text = f"{format_token_count(int(val))} {suffix}" + except (ValueError, TypeError, OverflowError): + text = f"{val} {suffix}" + return _mark(key, text) + + def _format_flags(keys: list[tuple[str, str]]) -> list[Content]: + """Render boolean profile keys as green (on) or dim (off) labels. + + Returns: + List of styled `Content` objects for present keys. + """ + parts: list[Content] = [] + for key, label in keys: + if key in profile: + base = ( + Content.styled(label, colors.success) + if profile[key] + else Content.styled(label, "dim") + ) + if key in overridden: + base = Content.assemble( + Content.styled("*", colors.warning), base + ) + parts.append(base) + return parts + + # Line 1: Context window + token_keys = [("max_input_tokens", "in"), ("max_output_tokens", "out")] + ctx_parts = [p for k, s in token_keys if (p := _format_token(k, s)) is not None] + bullet_sep = Content(f" {glyphs.bullet} ") + line1 = ( + Content.assemble("Context: ", bullet_sep.join(ctx_parts)) + if ctx_parts + else Content("") + ) + + # Line 2: Input modalities + modality_keys = [ + ("text_inputs", "text"), + ("image_inputs", "image"), + ("audio_inputs", "audio"), + ("pdf_inputs", "pdf"), + ("video_inputs", "video"), + ] + modality_parts = _format_flags(modality_keys) + space = Content(" ") + line2 = ( + Content.assemble("Input: ", space.join(modality_parts)) + if modality_parts + else Content("") + ) + + # Line 3: Capabilities + capability_keys = [ + ("reasoning_output", "reasoning"), + ("tool_calling", "tool calling"), + ("structured_output", "structured output"), + ] + cap_parts = _format_flags(capability_keys) + line3 = ( + Content.assemble("Capabilities: ", space.join(cap_parts)) + if cap_parts + else Content("") + ) + + # Line 4: Override notice + displayed_keys = {k for k, _ in token_keys + modality_keys + capability_keys} + has_visible_override = bool(overridden & displayed_keys) + line4 = ( + Content.from_markup("[dim][yellow]*[/yellow] = override[/dim]") + if has_visible_override + else Content("") + ) + + return Content.assemble(line1, "\n", line2, "\n", line3, "\n", line4) + + def _get_model_status(self, model_spec: str) -> str | None: + """Look up the status field for a model from its profile. + + Args: + model_spec: The `provider:model` string. + + Returns: + Status string (e.g., `'deprecated'`) if the model has a profile + with a `status` key, otherwise None. + """ + entry = self._profiles.get(model_spec) + if entry is None: + return None + profile = entry.get("profile") + if not profile: + return None + return profile.get("status") + + def _get_model_display_name(self, model_spec: str) -> str: + """Resolve the friendly display name for a model spec. + + Used by every row (provider-grouped and the cross-provider Recent + section) and folded into the search haystack. Prefers the profile's + human-readable `name` (e.g. `'Claude Sonnet 5'`), which reads better + than the raw model id. When no profile is loaded — the case for + uninstalled recommendations and onboarding — falls back to the + hardcoded name in the selector's active recommendation set, then the + model portion of the spec, then the spec itself. + + Args: + model_spec: The `provider:model` string. + + Returns: + The display name for the row. + """ + entry = self._profiles.get(model_spec) + if entry: + profile = entry.get("profile") + # `profile` originates from provider packages, so guard its type + # rather than trusting the schema before `.get`. + if isinstance(profile, dict): + name = profile.get("name") + if isinstance(name, str) and name: + return name + recommended = self._recommended_models.get(model_spec) + if recommended: + return recommended + parsed = ModelSpec.try_parse(model_spec) + # `parsed.model` can be empty for a malformed spec like `provider:`; + # fall back to the raw spec rather than rendering a blank row. + return parsed.model if parsed and parsed.model else model_spec + + def _update_footer(self) -> None: + """Update the detail footer for the currently highlighted model.""" + footer = self.query_one("#model-detail-footer", Static) + if not self._filtered_models: + footer.update(Content.styled("No model selected", "dim")) + return + index = min(self._selected_index, len(self._filtered_models) - 1) + spec, _ = self._filtered_models[index] + entry = self._profiles.get(spec) + try: + text = self._format_footer(entry, get_glyphs()) + except (KeyError, ValueError, TypeError): # Resilient footer rendering + logger.warning("Failed to format footer for %s", spec, exc_info=True) + text = Content.styled("Could not load profile details\n\n\n", "dim") + footer.update(text) + + def _move_selection(self, delta: int) -> None: + """Move selection by delta, updating only the affected widgets. + + Args: + delta: Number of positions to move (-1 for up, +1 for down). + """ + if not self._filtered_models or not self._option_widgets: + return + + count = len(self._filtered_models) + old_index = self._selected_index + new_index = (old_index + delta) % count + self._selected_index = new_index + + # Update the previously selected widget + old_widget = self._option_widgets[old_index] + old_widget.remove_class("model-option-selected") + old_widget.update( + self._build_option_label( + old_widget.model_spec, + old_widget.provider, + old_widget.auth_status, + selected=False, + show_provider=old_widget.show_provider, + ) + ) + + # Update the newly selected widget + new_widget = self._option_widgets[new_index] + new_widget.add_class("model-option-selected") + new_widget.update( + self._build_option_label( + new_widget.model_spec, + new_widget.provider, + new_widget.auth_status, + selected=True, + show_provider=new_widget.show_provider, + ) + ) + + # Scroll the selected item into view + if new_index == 0: + scroll_container = self.query_one(".model-list", VerticalScroll) + scroll_container.scroll_home(animate=False) + else: + new_widget.scroll_visible() + + self._update_footer() + + def action_move_up(self) -> None: + """Move selection up.""" + self._move_selection(-1) + + def action_move_down(self) -> None: + """Move selection down.""" + self._move_selection(1) + + def action_tab_complete(self) -> None: + """Replace search text with the currently selected model spec.""" + if not self._filtered_models: + return + model_spec, _ = self._filtered_models[self._selected_index] + filter_input = self.query_one("#model-filter", Input) + filter_input.value = model_spec + filter_input.cursor_position = len(model_spec) + + def _visible_page_size(self) -> int: + """Return the number of model options that fit in one visual page. + + Returns: + Number of model options per page, at least 1. + """ + default_page_size = 10 + try: + scroll = self.query_one(".model-list", VerticalScroll) + height = scroll.size.height + except Exception: # noqa: BLE001 # Fallback to default page size on any widget query error + return default_page_size + if height <= 0: + return default_page_size + + total_models = len(self._filtered_models) + if total_models == 0: + return default_page_size + + # Each provider header = 1 row + margin-top: 1 (first has margin 0) + num_headers = len(self.query(".model-provider-header")) + header_rows = max(0, num_headers * 2 - 1) if num_headers else 0 + total_rows = total_models + header_rows + return max(1, int(height * total_models / total_rows)) + + def action_page_up(self) -> None: + """Move selection up by one visible page.""" + if not self._filtered_models: + return + page = self._visible_page_size() + target = max(0, self._selected_index - page) + delta = target - self._selected_index + if delta != 0: + self._move_selection(delta) + + def action_page_down(self) -> None: + """Move selection down by one visible page.""" + if not self._filtered_models: + return + count = len(self._filtered_models) + page = self._visible_page_size() + target = min(count - 1, self._selected_index + page) + delta = target - self._selected_index + if delta != 0: + self._move_selection(delta) + + def action_select(self) -> None: + """Select the current model.""" + # If there are filtered results, always select the highlighted model + if self._filtered_models: + model_spec, provider = self._filtered_models[self._selected_index] + self._select_with_auth_check(model_spec, provider) + return + + # No matches - check if user typed a custom provider:model spec + filter_input = self.query_one("#model-filter", Input) + custom_input = filter_input.value.strip() + + if custom_input and ":" in custom_input: + provider = custom_input.split(":", 1)[0] + self._select_with_auth_check(custom_input, provider) + elif custom_input: + self._dismiss_with_result((custom_input, "")) + + def _select_with_auth_check(self, model_spec: str, provider: str) -> None: + """Either dismiss with the selection, or prompt for credentials first. + + When the highlighted provider has `blocks_start` auth (typically a + missing API key), open the in-TUI auth prompt instead of dismissing. + On save, dismiss with the originally-selected model. On cancel, stay + on the selector and refresh the credential indicator so the user can + try again or pick a different provider. + """ + if not provider: + self._dismiss_with_result((model_spec, provider)) + return + + from deepagents_code.config_manifest import ( + is_provider_package_installed, + provider_install_extra, + ) + + extra = provider_install_extra(provider) + if extra is not None and not is_provider_package_installed(provider): + if self._curated: + # Onboarding installs first, then prompts for credentials from the + # launch flow, matching the dependency screen's auto-install copy. + self._dismiss_with_result((model_spec, provider)) + return + self._prompt_install_provider(model_spec, provider, extra) + return + + status = get_provider_auth_status(provider) + if not status.blocks_start: + self._dismiss_with_result((model_spec, provider)) + return + + if provider == CODEX_PROVIDER: + # ChatGPT auth is an OAuth browser flow, not an API key, so the + # generic key/base-url prompt doesn't apply. Route to the + # dedicated sign-in modal (the same one the auth manager uses). + self._prompt_codex_sign_in(model_spec, provider) + return + + env_var = status.env_var or get_credential_env_var(provider) + + from deepagents_code.tui.widgets.auth import AuthPromptScreen, AuthResult + + def _on_auth_done(result: AuthResult | None) -> None: + if result is AuthResult.SAVED: + self._dismiss_with_result((model_spec, provider)) + return + # On DELETED or CANCELLED the user explicitly chose not to + # provide a key; refresh the credential indicator and stay on + # the selector so they can pick a different provider. + self.call_after_refresh(self._update_display) + + self.app.push_screen( + AuthPromptScreen( + provider, + env_var, + reason=f"Required to use {model_spec}", + ), + _on_auth_done, + ) + + def _prompt_install_provider( + self, model_spec: str, provider: str, extra: str + ) -> None: + """Confirm installing a provider's extra before selecting its model. + + On confirm, record the extra on `pending_install_extra` and dismiss + with the selected model so the app can install the extra and then + switch. On cancel, refresh the credential indicator and stay on the + selector so the user can pick a different provider. + """ + from deepagents_code.tui.widgets.install_confirm import ( + InstallProviderConfirmScreen, + ) + + def _on_confirm(proceed: bool | None) -> None: + if proceed: + self.pending_install_extra = extra + self._dismiss_with_result((model_spec, provider)) + return + self.call_after_refresh(self._update_display) + + self.app.push_screen( + InstallProviderConfirmScreen(provider, extra, model_spec), + _on_confirm, + ) + + def _prompt_codex_sign_in(self, model_spec: str, provider: str) -> None: + """Confirm, then run the ChatGPT OAuth flow for the selected model. + + Signing in launches a browser and a multi-minute loopback wait, so a + confirmation modal is shown first. If the user declines, refresh the + credential indicator and stay on the selector so they can retry or + pick a different provider. + """ + from deepagents_code.tui.widgets.auth import AuthConfirmScreen + + def _on_confirm(proceed: bool | None) -> None: + if proceed: + self._run_codex_oauth(model_spec, provider) + return + self.call_after_refresh(self._update_display) + + confirm = AuthConfirmScreen( + title="No ChatGPT sign-in detected", + body=Content.from_markup( + "[bold]$model[/bold] authenticates with ChatGPT, but no " + "sign-in was detected. Sign in now, or return to the model " + "list.", + model=model_spec, + ), + help_text="Enter to sign in, Esc to return to model list", + ) + self.app.push_screen(confirm, _on_confirm) + + def _run_codex_oauth(self, model_spec: str, provider: str) -> None: + """Run the ChatGPT OAuth sign-in flow for the selected codex model. + + On a successful sign-in, dismiss with the originally-selected model. + On cancel or error, refresh the credential indicator and stay on the + selector so the user can retry or pick a different provider. + """ + from deepagents_code.model_config import clear_caches + from deepagents_code.tui.widgets.codex_auth import CodexAuthScreen + + def _on_codex_done(signed_in: bool | None) -> None: + clear_caches() + if signed_in: + self._dismiss_with_result((model_spec, provider)) + return + self.call_after_refresh(self._update_display) + + self.app.push_screen(CodexAuthScreen(), _on_codex_done) + + def is_stored_default(self, model_spec: str) -> bool: + """Return whether `model_spec` is the preference stored by this screen.""" + return model_spec == self._default_spec + + async def action_set_default(self) -> None: + """Toggle the highlighted model as the screen's stored default. + + If the highlighted model is already stored, clears it. Otherwise stores + it. Which preference is written — and how the footer names it — comes + from the screen's `DefaultModelScope`, so the `/auto` classifier picker + cannot silently retarget the main agent model. A screen constructed with + `default_scope=None` has nothing to write and returns early. + + Rows whose provider integration is missing are refused rather than + persisted: unlike the session-only Enter path (which offers to install + the package), a stored spec that can never build would degrade every + future launch with no prompt at the moment of the keypress. The refusal + applies only to *storing* — when the highlighted row is already the + stored spec, Ctrl+S must still clear it, since the clear path is the + only in-app way to drop a persisted value whose provider integration + was later removed. + + Write failures leave their notice in place instead of scheduling a + restore timer, and (when the screen is running) raise a toast naming the + remedy — a 3-second footer flash is not enough to read, let alone act + on. The install refusal is a different class: the user fixes it by + moving the cursor to an installed row, so it restores on the usual timer + rather than pinning the footer for the life of the modal. + """ + if not self._filtered_models or not self._option_widgets: + return + + scope = self._default_scope + if scope is None: + return + + model_spec, provider = self._filtered_models[self._selected_index] + help_widget = self.query_one(".model-selector-help", Static) + noun = scope.noun + # `noun[:1]` rather than `noun[0]` so a malformed empty scope degrades to + # an odd message instead of an IndexError inside a key handler. + sentence_noun = noun[:1].upper() + noun[1:] + + def _fail(message: str, remedy: str, *, persistent: bool = True) -> None: + help_widget.update( + Content.styled( + message, + f"bold {theme.get_theme_colors(self).error}", + ) + ) + self._help_error_shown = True + if persistent: + # A restore timer left over from an immediately preceding + # successful Ctrl+S would fire mid-error and wipe this notice; + # cancel it so the failure stays visible as intended. + self._stop_help_restore_timer() + else: + self._restart_help_restore_timer() + if self.is_running: + self.notify(remedy, severity="error", timeout=10, markup=False) + else: + # Nothing is mounted to read the footer update, so the toast — + # the only text carrying the remedy — is dropped. Leave a trace + # rather than failing invisibly. + logger.warning( + "Ctrl+S failed while the selector was not running: %s (%s)", + message, + remedy, + ) + + if provider in self._install_extras and model_spec != self._default_spec: + from deepagents_code.update_check import ( + install_extra_command, + safe_install_extra_recovery_command, + ) + + extra = self._install_extras[provider] + # Build the recovery hint the same way `/install` failures do: + # `install_extra_command` is the display-only install-script + # command, upgraded to the receipt-preserving uv command when the + # running install supports it — a bare `pip install` would target + # the wrong environment for `uv tool` installs. + try: + fallback = install_extra_command(extra) + except ValueError: + # `_install_extras` values come from curated metadata, but a + # malformed one must not turn a footer notice into a crash. Log + # it: the fallback is the bare `pip install` this comment warns + # targets the wrong environment, so the hint is degraded and + # nothing else records that the metadata is broken. + logger.warning( + "install_extra_command failed for extra %r; " + "falling back to a bare pip command", + extra, + exc_info=True, + ) + fallback = f"pip install 'deepagents-code[{extra}]'" + remedy_cmd = safe_install_extra_recovery_command(extra, fallback=fallback) + _fail( + f"Cannot store {noun}: {provider} not installed", + f"{provider} is not installed, so {model_spec} could never be " + f"used. Install it with: {remedy_cmd}", + persistent=False, + ) + return + + # `False` from the writers covers an unwritable file, unparseable TOML, + # and a `[models]` section of the wrong shape — the accurate diagnosis + # only reaches the log — so the remedy names both possibilities rather + # than sending the user to check permissions that are already correct. + write_remedy = ( + "Could not update ~/.deepagents/config.toml. It may be unwritable " + "(check permissions for ~/.deepagents/) or malformed; see the log " + "for the specific error." + ) + + if model_spec == self._default_spec: + # Already stored — clear it + if await asyncio.to_thread(scope.clear): + self._default_spec = None + self.call_after_refresh(self._update_display) + help_widget.update(Content.styled(f"{sentence_noun} cleared", "bold")) + self._restart_help_restore_timer() + else: + _fail(f"Failed to clear {noun}", write_remedy) + elif await asyncio.to_thread(scope.save, model_spec): + self._default_spec = model_spec + self.call_after_refresh(self._update_display) + help_widget.update( + Content.from_markup( + "[bold]$noun set to $spec[/bold]", + noun=sentence_noun, + spec=model_spec, + ) + ) + self._restart_help_restore_timer() + # The write succeeded but an override outranks it at launch, so the + # `(default)` marker this just rendered would otherwise imply the + # keypress changed which model runs. + if ( + scope.override_env_var is not None + and scope.override_env_var in os.environ + and self.is_running + ): + self.notify( + f"Default classifier model saved. {scope.override_env_var} " + "is currently set; if it remains set, it overrides this " + "default at next launch.", + severity="warning", + timeout=10, + markup=False, + ) + else: + _fail(f"Failed to save {noun}", write_remedy) + + def _stop_help_restore_timer(self) -> None: + """Stop the pending footer-restore timer, if any, and drop the handle.""" + if self._help_restore_timer is not None: + self._help_restore_timer.stop() + self._help_restore_timer = None + + def _restart_help_restore_timer(self) -> None: + """Schedule a fresh footer restore, stopping any timer already pending. + + Two quick successful Ctrl+S toggles must not orphan the first timer: + its callback would clear the handle while the second timer is still + pending, leaving nothing for `_fail` to cancel. + """ + self._stop_help_restore_timer() + self._help_restore_timer = self.set_timer(3.0, self._restore_help_text) + + def _restore_help_text(self) -> None: + """Restore the default help text after a temporary message. + + Recomputes `_help_text()` rather than replaying a captured string, so + a timer scheduled before a Ctrl+N press still restores the hint for the + display mode that is current when it fires. + """ + self._help_restore_timer = None + self._help_error_shown = False + help_widget = self.query_one(".model-selector-help", Static) + help_widget.update(self._help_text()) + + async def action_toggle_recommended(self) -> None: + """Toggle between the full model list and the recommended subset. + + Disabled while in `_curated` (onboarding) mode — that screen is + already constrained to the recommended subset and the user should + finish or skip onboarding rather than browse the full list. + Preserves the highlighted model when it survives the toggle and + falls back to the current/default/first model otherwise. + """ + if self._curated or not self._loaded: + return + + prev_spec: str | None = None + if self._filtered_models and 0 <= self._selected_index < len( + self._filtered_models + ): + prev_spec = self._filtered_models[self._selected_index][0] + + self._recommended_only = not self._recommended_only + self._all_models = self._apply_subset(self._unfiltered_models) + + if self._filter_text.strip(): + self._update_filtered_list() + else: + self._filtered_models = list(self._all_models) + self._selected_index = self._find_current_model_index() + + if prev_spec is not None: + for i, (spec, _) in enumerate(self._filtered_models): + if spec == prev_spec: + self._selected_index = i + break + + info = self.query_one("#model-selector-info", Static) + info.update(self._info_line_content()) + + await self._update_display() + + def action_toggle_names(self) -> None: + """Toggle rows between friendly display names and raw model specs. + + Mirrors the `/theme` picker's label/key toggle: pressing Ctrl+N flips + every visible row between its human-readable name (e.g. + `Claude Sonnet 5`) and the canonical `provider:model` spec (e.g. + `anthropic:claude-sonnet-5`) so the user can read or copy the exact id + without leaving the picker. Relabels the mounted rows in place — the + toggle changes neither ordering nor selection, so a full rebuild is + unnecessary — and stays available in curated/onboarding mode since it + only affects presentation. + + In standard mode the footer hint is rewritten so it advertises the mode + the next press switches to; curated mode omits the hint entirely, so + the refresh is a no-op there. + + The refresh is skipped while a Ctrl+S *error* notice is on the footer, + since that notice is the only signal a save failed and the user may not + have read it yet. Successful Ctrl+S messages are clobbered freely, and + their pending restore timer is stopped rather than left to fire: the + refresh already rendered the hint this timer would render, and an + untracked timer is one `_fail` could not cancel. + """ + if not self._loaded: + return + self._show_specs = not self._show_specs + self._relabel_options() + if not self._help_error_shown: + # Stop the pending timer before restoring: `_restore_help_text` + # drops the handle without stopping it (correct when the timer is + # its own caller), so a synchronous call here would leave a live, + # untracked timer that a later `_fail` cannot cancel — it would fire + # mid-error and wipe the failure notice. + self._stop_help_restore_timer() + self._restore_help_text() + + def _relabel_options(self) -> None: + """Rebuild each mounted row's label for the current display mode. + + Used by `action_toggle_names` after flipping `_show_specs`. Each entry + in `_option_widgets` lines up with its `_filtered_models` index, so the + highlighted row is re-derived from `_selected_index` to preserve the + selected styling. + """ + for index, widget in enumerate(self._option_widgets): + widget.update( + self._build_option_label( + widget.model_spec, + widget.provider, + widget.auth_status, + selected=index == self._selected_index, + show_provider=widget.show_provider, + ) + ) + + def action_cancel(self) -> None: + """Cancel the selection.""" + self._dismiss_with_result(None) + + def _dismiss_with_result(self, result: tuple[str, str] | None) -> None: + """Dismiss the selector and notify an optional direct result callback.""" + if self._result_callback is not None: + self._result_callback(result) + self.dismiss(result) diff --git a/libs/cli/deepagents_cli/widgets/notification_center.py b/libs/code/deepagents_code/tui/widgets/notification_center.py similarity index 80% rename from libs/cli/deepagents_cli/widgets/notification_center.py rename to libs/code/deepagents_code/tui/widgets/notification_center.py index 1b77487469..c6f2f0ed2c 100644 --- a/libs/cli/deepagents_cli/widgets/notification_center.py +++ b/libs/code/deepagents_code/tui/widgets/notification_center.py @@ -4,11 +4,15 @@ Selecting a row drills into a dedicated detail modal (`UpdateAvailableScreen` for update entries, `NotificationDetailScreen` otherwise) stacked on top of the center. When the detail modal -dismisses with any non-SUPPRESS action the center dismisses with a -`NotificationActionResult` so the app layer can dispatch; SUPPRESS is -handled in place via `NotificationSuppressRequested` so the remaining -notifications stay reachable. When the detail cancels, the center -stays open on the list. +dismisses with a terminal action (one that closes the center) the +center dismisses with a `NotificationActionResult` so the app layer can +dispatch. Actions that must keep the center open are handled in place: +SUPPRESS via +`NotificationSuppressRequested` (so the remaining notifications stay +reachable) and actions in `IN_PLACE_ACTIONS` via +`NotificationActionRequested` (so a follow-up modal, e.g. the API-key +prompt, stacks on top and Esc returns to the center). When the detail +cancels, the center stays open on the list. """ from __future__ import annotations @@ -28,11 +32,11 @@ from textual.app import ComposeResult from textual.events import Click - from deepagents_cli.notifications import PendingNotification + from deepagents_code.notifications import PendingNotification -from deepagents_cli import theme -from deepagents_cli.config import get_glyphs, is_ascii_mode -from deepagents_cli.notifications import ActionId, UpdateAvailablePayload +from deepagents_code import theme +from deepagents_code.config import get_glyphs, is_ascii_mode +from deepagents_code.notifications import ActionId, UpdateAvailablePayload logger = logging.getLogger(__name__) @@ -88,6 +92,50 @@ def __init__(self, key: str) -> None: self.key = key +IN_PLACE_ACTIONS: frozenset[ActionId] = frozenset({ActionId.ENTER_API_KEY}) +"""Actions handled in place without dismissing the center. + +Each opens a follow-up modal on top of the center, so the center stays +mounted and Esc in that modal returns here (rationale in +`NotificationActionRequested`). SUPPRESS is also handled in place but +routes through its own `NotificationSuppressRequested` message, so it is +deliberately excluded from this set. +""" + + +class NotificationActionRequested(Message): + """Posted for an action that opens a follow-up modal in place. + + Some actions (those in `IN_PLACE_ACTIONS`, currently `ENTER_API_KEY`) + push another modal, such as the API-key prompt, on top of the + still-open center. Dismissing the center first would drop that stack, + so Esc in the follow-up modal would fall through to the base screen + instead of returning here. The app handles this message by dispatching + the action while the center stays mounted, then reloading it with the + refreshed registry snapshot. + """ + + def __init__(self, key: str, action_id: ActionId) -> None: + """Initialize the message. + + Args: + key: Registry key of the notification the action targets. + action_id: The in-place action the user selected. Must be a + member of `IN_PLACE_ACTIONS`. + + Raises: + ValueError: If `action_id` is not an in-place action, which + would be a programmer error (the message is only meant to + carry actions that keep the center open). + """ + super().__init__() + if action_id not in IN_PLACE_ACTIONS: + msg = f"{action_id} is not an in-place action" + raise ValueError(msg) + self.key = key + self.action_id = action_id + + class _NotificationRow(Static): """Clickable single-line row displaying a notification's title.""" @@ -106,12 +154,12 @@ def __init__(self, notification: PendingNotification, index: int) -> None: @property def notification(self) -> PendingNotification: - """Return the underlying notification.""" + """Underlying notification.""" return self._notification @property def index(self) -> int: - """Return the row index in the parent list.""" + """Row index in the parent list.""" return self._index def set_selected(self, selected: bool) -> None: @@ -163,7 +211,6 @@ class NotificationCenterScreen(ModalScreen[NotificationActionResult | None]): CSS = """ NotificationCenterScreen { align: center middle; - background: transparent; } NotificationCenterScreen > Vertical { @@ -333,6 +380,12 @@ def handle_detail(action_id: ActionId | None) -> None: # in `NotificationSuppressRequested`'s class docstring. self.post_message(NotificationSuppressRequested(entry.key)) return + if action_id in IN_PLACE_ACTIONS: + # Keep the center open so the follow-up modal (e.g. the + # API-key prompt) stacks on top and Esc returns here. + # Rationale is in `NotificationActionRequested`'s docstring. + self.post_message(NotificationActionRequested(entry.key, action_id)) + return self.dismiss(NotificationActionResult(entry.key, action_id)) try: @@ -391,9 +444,13 @@ def _detail_screen_for( `ActionId` or `None` when the user cancels. """ if isinstance(entry.payload, UpdateAvailablePayload): - from deepagents_cli.widgets.update_available import UpdateAvailableScreen + from deepagents_code.tui.widgets.update_available import ( + UpdateAvailableScreen, + ) return UpdateAvailableScreen(entry) - from deepagents_cli.widgets.notification_detail import NotificationDetailScreen + from deepagents_code.tui.widgets.notification_detail import ( + NotificationDetailScreen, + ) return NotificationDetailScreen(entry) diff --git a/libs/cli/deepagents_cli/widgets/notification_detail.py b/libs/code/deepagents_code/tui/widgets/notification_detail.py similarity index 97% rename from libs/cli/deepagents_cli/widgets/notification_detail.py rename to libs/code/deepagents_code/tui/widgets/notification_detail.py index d2c191dce9..17344756e3 100644 --- a/libs/cli/deepagents_cli/widgets/notification_detail.py +++ b/libs/code/deepagents_code/tui/widgets/notification_detail.py @@ -24,14 +24,14 @@ from textual.app import ComposeResult from textual.events import Click - from deepagents_cli.notifications import ( + from deepagents_code.notifications import ( ActionId, NotificationAction, PendingNotification, ) -from deepagents_cli import theme -from deepagents_cli.config import get_glyphs, is_ascii_mode +from deepagents_code import theme +from deepagents_code.config import get_glyphs, is_ascii_mode class DetailActionActivated(Message): @@ -64,7 +64,7 @@ def __init__(self, action: NotificationAction, widget_id: str) -> None: @property def action(self) -> NotificationAction: - """Return the underlying action.""" + """Underlying action.""" return self._action def set_selected(self, selected: bool) -> None: diff --git a/libs/cli/deepagents_cli/widgets/notification_settings.py b/libs/code/deepagents_code/tui/widgets/notification_settings.py similarity index 83% rename from libs/cli/deepagents_cli/widgets/notification_settings.py rename to libs/code/deepagents_code/tui/widgets/notification_settings.py index 892499a164..05d765431e 100644 --- a/libs/cli/deepagents_cli/widgets/notification_settings.py +++ b/libs/code/deepagents_code/tui/widgets/notification_settings.py @@ -1,4 +1,4 @@ -"""Notification settings screen for /notifications command.""" +"""Notification settings screen for `/notifications` command.""" from __future__ import annotations @@ -14,21 +14,23 @@ if TYPE_CHECKING: from textual.app import ComposeResult -from deepagents_cli import theme -from deepagents_cli.config import get_glyphs, is_ascii_mode +from deepagents_code import theme +from deepagents_code.approval_mode import YOLO_WARNING_KEY +from deepagents_code.config import get_glyphs, is_ascii_mode logger = logging.getLogger(__name__) # Warning keys and their user-facing labels. -# Checked = warning is shown at startup (not suppressed). Unchecked = suppressed. +# Checked = warning is shown (not suppressed). Unchecked = suppressed. WARNING_TOGGLES: list[tuple[str, str]] = [ ("ripgrep", "Warn when ripgrep is not installed"), ("tavily", "Warn when TAVILY_API_KEY is not set (web search)"), + (YOLO_WARNING_KEY, "Warn when YOLO mode is active (no approval review)"), ] class NotificationSettingsScreen(ModalScreen[None]): - """Modal dialog for managing startup warning preferences. + """Modal dialog for managing warning preferences. Each checkbox maps to a key in `[warnings].suppress` in `~/.deepagents/config.toml`. Toggling a checkbox immediately @@ -52,7 +54,6 @@ class NotificationSettingsScreen(ModalScreen[None]): CSS = """ NotificationSettingsScreen { align: center middle; - background: transparent; } NotificationSettingsScreen > VerticalGroup { @@ -115,7 +116,7 @@ def compose(self) -> ComposeResult: ) help_text = ( f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab navigate" - f" {glyphs.bullet} Space toggle" + f" {glyphs.bullet} Space/Enter toggle" f" {glyphs.bullet} Esc close" ) yield Static(help_text, classes="ns-help") @@ -137,7 +138,7 @@ def on_checkbox_changed(self, event: Checkbox.Changed) -> None: enabled = event.value async def _persist() -> None: - from deepagents_cli.model_config import ( + from deepagents_code.model_config import ( suppress_warning, unsuppress_warning, ) @@ -155,6 +156,12 @@ async def _persist() -> None: ) ok = False if not ok: + # Roll the box back to what is actually on disk. Leaving it + # showing the requested state would claim a warning is armed + # when it is still suppressed — the unsafe direction to lie in. + # `prevent` keeps the rollback from re-entering this handler. + with event.checkbox.prevent(Checkbox.Changed): + event.checkbox.value = not enabled self.app.notify( "Could not save notification preference. " "Check file permissions for ~/.deepagents/config.toml.", diff --git a/libs/code/deepagents_code/tui/widgets/plugin_reload.py b/libs/code/deepagents_code/tui/widgets/plugin_reload.py new file mode 100644 index 0000000000..fa11a3f1f1 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/plugin_reload.py @@ -0,0 +1,96 @@ +"""Confirmation modal offered after reload-relevant plugin changes.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal + +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Static + +if TYPE_CHECKING: + from textual.app import ComposeResult + + +PluginReloadChoice = Literal["reload", "later"] +"""Outcome of the prompt: apply plugin changes now or defer.""" + + +class PluginReloadPromptScreen(ModalScreen[PluginReloadChoice]): + """Ask whether to reload after leaving the plugin manager.""" + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "reload", "Reload", show=False, priority=True), + Binding("escape", "later", "Later", show=False, priority=True), + ] + + CSS = """ + PluginReloadPromptScreen { + align: center middle; + } + + PluginReloadPromptScreen > Vertical { + width: 64; + max-width: 90%; + height: auto; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + PluginReloadPromptScreen .plugin-reload-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + PluginReloadPromptScreen .plugin-reload-body { + height: auto; + color: $text; + margin-bottom: 1; + } + + PluginReloadPromptScreen .plugin-reload-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual requires an instance method + """Compose the title, explanation, and keyboard help. + + Yields: + Widgets for the plugin reload prompt. + """ + with Vertical(): + yield Static( + "Reload plugins?", + classes="plugin-reload-title", + markup=False, + ) + yield Static( + "Reload to apply changes to plugin skills and MCP tools.", + classes="plugin-reload-body", + markup=False, + ) + yield Static( + "Enter to reload, Esc for later", + classes="plugin-reload-help", + markup=False, + ) + + def action_reload(self) -> None: + """Choose to apply plugin changes now.""" + self.dismiss("reload") + + def action_later(self) -> None: + """Defer the reload.""" + self.dismiss("later") + + def action_cancel(self) -> None: + """Treat the app-level Esc action as an explicit deferral.""" + self.action_later() diff --git a/libs/code/deepagents_code/tui/widgets/restart_prompt.py b/libs/code/deepagents_code/tui/widgets/restart_prompt.py new file mode 100644 index 0000000000..c89c84749d --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/restart_prompt.py @@ -0,0 +1,158 @@ +"""Confirmation modal offered when a change needs an owned-server respawn. + +Some changes take effect only when the app-owned LangGraph server subprocess +spawns: provider/sandbox extras and `--package` installs are imported at spawn +time, and a Tavily key saved via `/auth` binds the `web_search` tool only at +spawn time. A `/restart` respawns the subprocess without exiting the TUI, so +rather than make the user type `/restart` by hand, this modal offers to run +that restart immediately while leaving deferral one keypress away. The title +`verb` and `body` are caller-supplied so one modal serves each flow. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Literal + +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.content import Content +from textual.screen import ModalScreen +from textual.widgets import Static + +from deepagents_code.config import get_glyphs + +if TYPE_CHECKING: + from textual.app import ComposeResult + + +RestartChoice = Literal["restart", "later"] +"""Outcome of the prompt: restart the server now or defer.""" + + +class RestartPromptScreen(ModalScreen[RestartChoice]): + """Modal asking whether to restart the server for a spawn-time change. + + Serves both the post-install offer and the post-`/auth` web-search offer; + the caller supplies the title `verb` and `body` copy. + + Dismisses with `"restart"` when the user accepts and `"later"` when the + user defers. Esc is treated as "later" so the user is never forced into a + restart they did not explicitly choose. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "restart", "Restart", show=False, priority=True), + Binding("escape", "later", "Later", show=False, priority=True), + ] + + CSS = """ + RestartPromptScreen { + align: center middle; + } + + RestartPromptScreen > Vertical { + width: 64; + max-width: 90%; + height: auto; + background: $surface; + border: solid $primary; + padding: 1 2; + } + + RestartPromptScreen .restart-prompt-title { + text-style: bold; + color: $primary; + text-align: center; + margin-bottom: 1; + } + + RestartPromptScreen .restart-prompt-body { + height: auto; + color: $text; + margin-bottom: 1; + } + + RestartPromptScreen .restart-prompt-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + _DEFAULT_BODY = "Restart the server to load it now." + + def __init__( + self, + label: str, + *, + verb: str, + body: str | None = None, + ) -> None: + """Initialize the prompt. + + Args: + label: The subject surfaced in the title (e.g. an installed extra + name, or a saved credential like ``"Tavily API key"``). + verb: Past-tense action shown before `label` in the title — e.g. + `"Installed"` for the post-install flow or `"Saved"` for a + saved credential. Required (no default) so each flow states its + own intent and a future caller can't inherit install-only copy. + body: Optional override for the explanatory line under the title. + Defaults to the generic restart copy. + """ + super().__init__() + self._label = label + self._verb = verb + self._body = body or self._DEFAULT_BODY + + def compose(self) -> ComposeResult: + """Compose the confirmation dialog. + + Yields: + Title, body, and help-row widgets parented inside a `Vertical`. + """ + glyphs = get_glyphs() + with Vertical(): + yield Static( + Content.from_markup( + "$check $verb [bold]$name[/bold]", + check=glyphs.checkmark, + verb=self._verb, + name=self._label, + ), + classes="restart-prompt-title", + markup=False, + ) + yield Static( + self._body, + classes="restart-prompt-body", + markup=False, + ) + yield Static( + "Enter to restart, Esc to defer", + classes="restart-prompt-help", + markup=False, + ) + + def action_restart(self) -> None: + """Dismiss with `"restart"`.""" + self.dismiss("restart") + + def action_later(self) -> None: + """Dismiss with `"later"`.""" + self.dismiss("later") + + def action_cancel(self) -> None: + """Alias for `action_later` so Esc resolves to a deliberate defer. + + The app's `action_interrupt` (`escape` binding, `priority=True`) + fires before this screen's own `escape` binding. When the active + screen is a `ModalScreen`, it dispatches to `action_cancel` if + present, else falls through to `dismiss(None)`. The current caller + no-ops on both `None` and `"later"`, but defining this alias pins Esc + to an explicit `"later"` — matching the sibling reconnect/cwd-switch + modals and keeping the outcome unambiguous for any future caller that + branches on a deliberate defer versus a programmatic dismiss. + """ + self.action_later() diff --git a/libs/code/deepagents_code/tui/widgets/skill_trust.py b/libs/code/deepagents_code/tui/widgets/skill_trust.py new file mode 100644 index 0000000000..1c161706eb --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/skill_trust.py @@ -0,0 +1,131 @@ +"""Trust prompt for skills that resolve outside trusted skill directories. + +When a `/skill:` invocation reads a `SKILL.md` whose resolved path (via +symlink) falls outside every trusted skill root, `load_skill_content` refuses +the read. Rather than forcing the user to quit, edit env/config, and relaunch, +this non-blocking modal asks for an in-the-moment decision. Allowing persists +the resolved target directory to the skill trust store. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from textual.binding import Binding, BindingType +from textual.containers import Vertical +from textual.content import Content +from textual.screen import ModalScreen +from textual.widgets import Static + +if TYPE_CHECKING: + from textual.app import ComposeResult + + +class SkillTrustScreen(ModalScreen[bool | None]): + """Approval overlay for a skill resolving outside trusted directories. + + Dismisses with `True` when the user allows the resolved target and `False` + when the user declines. Esc is treated as deny so the user is never forced + into reading from an untrusted location they did not explicitly choose. + + Typed `bool | None` rather than `bool`: a programmatic pop, or the app's + priority Esc binding falling through to `dismiss(None)` (see + `action_cancel`), can yield `None`. The caller collapses `None` and `False` + to deny (`if not allowed`), so both dismiss values fail closed. + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("enter", "confirm", "Allow", show=False, priority=True), + Binding("escape", "cancel", "Deny", show=False, priority=True), + ] + + CSS = """ + SkillTrustScreen { + align: center middle; + } + + SkillTrustScreen > Vertical { + width: 72; + max-width: 90%; + height: auto; + background: $surface; + border: solid $warning; + padding: 1 2; + } + + SkillTrustScreen .skill-trust-title { + text-style: bold; + color: $warning; + text-align: center; + margin-bottom: 1; + } + + SkillTrustScreen .skill-trust-body { + height: auto; + color: $text; + margin-bottom: 1; + } + + SkillTrustScreen .skill-trust-help { + height: 1; + color: $text-muted; + text-style: italic; + text-align: center; + } + """ + + def __init__(self, skill_name: str, target_dir: str) -> None: + """Initialize the prompt. + + Args: + skill_name: Name of the skill being invoked. + target_dir: Resolved directory the skill's `SKILL.md` lives in. + """ + super().__init__() + self._skill_name = skill_name + self._target_dir = target_dir + + def compose(self) -> ComposeResult: + """Compose the skill trust dialog. + + Yields: + Title, body, and help-row widgets parented inside a `Vertical`. + """ + with Vertical(): + yield Static( + "Allow skill from outside trusted directories?", + classes="skill-trust-title", + markup=False, + ) + yield Static( + Content.from_markup( + "Skill [bold]$name[/bold] resolves to [bold]$dir[/bold], " + "outside your trusted skill directories. Allowing reads " + "instructions from there and remembers this location for " + "future sessions.", + name=self._skill_name, + dir=self._target_dir, + ), + classes="skill-trust-body", + markup=False, + ) + yield Static( + "Enter to allow, Esc to deny", + classes="skill-trust-help", + markup=False, + ) + + def action_confirm(self) -> None: + """Dismiss with `True`.""" + self.dismiss(True) + + def action_cancel(self) -> None: + """Dismiss with `False`. + + The method name must stay `cancel`: the app owns a priority `escape` + binding that, for an active `ModalScreen`, dispatches to + `action_cancel` if present and otherwise falls through to + `dismiss(None)`. Renaming this would silently regress Esc to a + `None` dismiss instead of an explicit deny. + """ + self.dismiss(False) diff --git a/libs/code/deepagents_code/tui/widgets/startup_tip.py b/libs/code/deepagents_code/tui/widgets/startup_tip.py new file mode 100644 index 0000000000..91971f666e --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/startup_tip.py @@ -0,0 +1,142 @@ +"""Startup tip widget shown above the chat input.""" + +from __future__ import annotations + +import random +from typing import Any + +from textual.content import Content +from textual.widgets import Static + +from deepagents_code._env_vars import HIDE_SPLASH_TIPS, is_env_truthy +from deepagents_code.editor import editor_display_name + +_TIP_EXTERNAL_EDITOR = "Press ctrl+x to compose prompts in your external editor" +"""Generic editor tip replaced at construction when an editor is configured.""" + +_TIP_SHIFT_TAB_WITH_YOLO = "Press Shift+Tab to cycle Manual, Auto, and YOLO modes" +"""Tip used when `startup.yolo_switcher` keeps YOLO in the approval cycle.""" + +_TIP_SHIFT_TAB_WITHOUT_YOLO = "Press Shift+Tab to toggle Manual and Auto modes" +"""Tip used when orgs/users disable YOLO entry via the approval switcher.""" + +_TIPS: dict[str, int] = { + "Use @ to reference files and / for commands": 3, + "Try /threads to resume a previous conversation": 2, + "Use /offload to summarize older messages and free up the context window": 2, + "Use /context to see context window usage and remaining space": 1, + "Use /copy to copy the latest message": 3, + "Use /cost to see a breakdown of estimated spend": 1, + "Use /tools to list the tools available to the agent": 1, + "Use /mcp login to authenticate MCP servers": 1, + "Use /remember to save learnings from this conversation": 1, + "Use /model to switch models mid-conversation": 2, + "Use /effort to change the current model's reasoning effort": 1, + _TIP_EXTERNAL_EDITOR: 1, + "Use /skill: to invoke a skill directly": 1, + "Use /theme to customize the TUI's colors": 1, + "Use /skill-creator to build reusable agent skills": 1, + "Ask for a workflow to fan work out to subagents in parallel": 3, + "Use /timestamps to show or hide message timestamp footers": 1, + "Click a collapsed message or press Ctrl+O to expand it": 1, + "Use /agents to browse and switch between your available agents": 2, + "Use /auto model to review Auto actions with a faster, cheaper model": 1, + _TIP_SHIFT_TAB_WITH_YOLO: 2, + "Use !! for incognito shell commands that stay out of model context": 1, + "Deep Agents can explain its own features and look up its docs. Ask it how to use.": 3, # noqa: E501 +} +"""Tips shown above the chat input. One is chosen at random per launch, +weighted by these relative selection weights. + +The Shift+Tab tip is varied at pick time via `_active_tips` so disabled-YOLO +installs do not advertise a switcher path that policy has removed. +""" + +# Fail fast at import if the registry is ever emptied or given a non-positive +# weight: `random.choices` would otherwise raise a cryptic error at widget +# construction. `_TIPS` is a hardcoded constant, so this never fires in +# practice — it just guards future edits. +if not _TIPS: + msg = "_TIPS must not be empty" + raise ValueError(msg) +if any(weight <= 0 for weight in _TIPS.values()): + msg = "_TIPS weights must be positive" + raise ValueError(msg) + + +def _active_tips(*, yolo_switcher_enabled: bool | None = None) -> dict[str, int]: + """Return the weighted tip registry for the current switcher policy. + + Args: + yolo_switcher_enabled: Override for whether YOLO appears in the + Shift+Tab cycle. When omitted, resolves `startup.yolo_switcher`. + + Returns: + Weighted tip map appropriate for the active YOLO switcher setting. + """ + if yolo_switcher_enabled is None: + from deepagents_code.config import is_yolo_switcher_enabled + + yolo_switcher_enabled = is_yolo_switcher_enabled() + + tips = dict(_TIPS) + editor = editor_display_name() + if editor is not None: + weight = tips.pop(_TIP_EXTERNAL_EDITOR) + tips[f"Press ctrl+x to compose prompts in {editor}"] = weight + + if not yolo_switcher_enabled: + # Replace the YOLO cycle tip with the Manual/Auto-only wording so the + # splash never claims Shift+Tab can enter unrestricted mode when policy + # has removed that entry from the switcher. + weight = tips.pop(_TIP_SHIFT_TAB_WITH_YOLO, None) + if weight is not None: + tips[_TIP_SHIFT_TAB_WITHOUT_YOLO] = weight + return tips + + +def _pick_tip(*, yolo_switcher_enabled: bool | None = None) -> str: + """Pick one startup tip using the configured relative weights. + + Args: + yolo_switcher_enabled: Optional override forwarded to `_active_tips`. + + Returns: + Tip text selected from the active tip registry. + """ + tips = _active_tips(yolo_switcher_enabled=yolo_switcher_enabled) + return random.choices(list(tips.keys()), weights=list(tips.values()), k=1)[0] # noqa: S311 + + +def show_startup_tip() -> bool: + """Return whether startup tips should be shown. + + Returns: + `True` when startup tips are enabled for the current process. + """ + return not is_env_truthy(HIDE_SPLASH_TIPS) + + +class StartupTip(Static): + """One startup tip displayed above the chat input.""" + + DEFAULT_CSS = """ + StartupTip { + height: auto; + color: $text-muted; + text-style: dim italic; + padding: 0 1; + } + """ + + def __init__(self, tip: str | None = None, **kwargs: Any) -> None: + """Initialize the startup tip widget. + + Args: + tip: Tip text to display. When omitted, one weighted tip is selected. + **kwargs: Additional arguments passed to `Static`. + """ + self.tip: str = tip if tip is not None else _pick_tip() + # Styling (dim italic, muted color) is owned by DEFAULT_CSS, which + # applies to the whole widget — no need to restyle the spans here. + super().__init__(Content.assemble("Tip: ", self.tip), **kwargs) diff --git a/libs/code/deepagents_code/tui/widgets/status.py b/libs/code/deepagents_code/tui/widgets/status.py new file mode 100644 index 0000000000..ad83f48960 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/status.py @@ -0,0 +1,1001 @@ +"""Status bar widget.""" + +from __future__ import annotations + +import logging +from contextlib import suppress +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, get_args + +from textual.containers import Horizontal, Vertical +from textual.content import Content +from textual.css.query import NoMatches +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Static + +from deepagents_code import theme +from deepagents_code._constants import FIREWORKS_MODEL_ID_PREFIXES +from deepagents_code._env_vars import HIDE_CWD, HIDE_GIT_BRANCH, is_env_truthy +from deepagents_code._session_stats import format_cost, format_token_count +from deepagents_code.config import get_glyphs +from deepagents_code.tui.widgets.loading import Spinner + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from textual import events + from textual.app import ComposeResult, RenderResult + from textual.geometry import Size + from textual.timer import Timer + +PROVIDER_PREFIX_STRIPS: dict[str, tuple[str, ...]] = { + "fireworks": FIREWORKS_MODEL_ID_PREFIXES, +} +"""Some providers (e.g. Fireworks) require fully-qualified IDs like +`accounts/fireworks/models/...` or `accounts/fireworks/routers/...` that crowd +out the rest of the status bar; strip the registered prefixes before display.""" + +ConnectionState = Literal["", "connecting", "reconnecting", "resuming"] +"""Connection states the status bar can display (`''` means cleared).""" + +CONNECTION_STATES = frozenset(get_args(ConnectionState)) +"""Runtime view of `ConnectionState` for `set_connection`'s defensive guard. + +Derived from the `Literal` so the two can never drift.""" + +StatusMessageSource = Literal["agent", "hooks"] +"""Owners that may write the shared status-message slot.""" + + +def _compact_tokens(count: int) -> str: + """Format a token count without a trailing `.0`. + + Returns: + Compact token count. + """ + text = format_token_count(count) + return text.replace(".0", "", 1) if ".0" in text else text + + +class ModelLabel(Widget): + """A label that displays a model name with smart truncation. + + When the full `provider:model` text doesn't fit, the provider is dropped + first. If the bare model name still doesn't fit, it is left-truncated + with a leading ellipsis so the most distinctive tail stays visible. + + When a reasoning effort is set, its label is appended to the model and + participates in the same ladder: the effort suffix is preserved (with the + model left-truncated to make room) and is only dropped once even the + left-truncated model plus effort cannot fit. + """ + + provider: reactive[str] = reactive("", layout=True) + model: reactive[str] = reactive("", layout=True) + effort: reactive[str] = reactive("", layout=True) + + def _clean_model(self) -> str: + """Strip the provider's registered prefix so the status bar stays compact. + + Returns: + Model name with the provider's registered prefix removed if present, + otherwise the original name. + """ + name = self.model + if not name or not self.provider: + return name + # Match on normalized text but slice the original to preserve its casing. + name_lower = name.lower() + for prefix in PROVIDER_PREFIX_STRIPS.get(self.provider, ()): + if name_lower.startswith(prefix): + return name[len(prefix) :] + return name + + def _with_effort(self, text: str) -> str: + """Append the reasoning effort label when one is set. + + Args: + text: Base model display text. + + Returns: + Model display text with the effort suffix (a per-session override or + the provider default) when one is present, else + `text` unchanged. + """ + return f"{text} {self.effort}" if self.effort else text + + def get_content_width(self, container: Size, viewport: Size) -> int: # noqa: ARG002 + """Return the intrinsic width so `width: auto` works. + + Args: + container: Size of the container. + viewport: Size of the viewport. + + Returns: + Character length of the full provider:model string. + """ + if not self.model: + return 0 + model = self._clean_model() + full = f"{self.provider}:{model}" if self.provider else model + return len(self._with_effort(full)) + + def render(self) -> RenderResult: + """Render the model label with width-aware truncation. + + Returns: + Text content, truncated from the left when necessary. + """ + width = self.content_size.width + if not self.model or width <= 0: + return "" + model = self._clean_model() + full = f"{self.provider}:{model}" if self.provider else model + full_with_effort = self._with_effort(full) + model_with_effort = self._with_effort(model) + if len(full_with_effort) <= width: + return Content(full_with_effort) + if len(model_with_effort) <= width: + return Content(model_with_effort) + suffix = f" {self.effort}" if self.effort else "" + if suffix and width > len(suffix) + 1: + model_width = width - len(suffix) + return Content(f"\u2026{model[-(model_width - 1) :]}{suffix}") + if len(model) <= width: + return Content(model) + if width > 1: + return Content("\u2026" + model[-(width - 1) :]) + return Content("\u2026") + + +class BranchLabel(Widget): + """A label that displays the git branch with glyph-aware truncation. + + Unlike CSS `text-overflow: ellipsis` (which always uses the Unicode + ellipsis character), this widget truncates manually in :meth:`render` using + :func:`get_glyphs` so ASCII mode (`DEEPAGENTS_CODE_UI_CHARSET_MODE=ascii`) + gets `"..."` instead of `"…"`. + """ + + branch: reactive[str] = reactive("", layout=True) + + def get_content_width(self, container: Size, viewport: Size) -> int: # noqa: ARG002 + """Return the intrinsic width so the widget participates in flex layout. + + Args: + container: Size of the container. + viewport: Size of the viewport. + + Returns: + Character length of the full branch string (icon + space + name), + or `0` when the branch is empty. + """ + if not self.branch: + return 0 + icon = get_glyphs().git_branch + return len(icon) + 1 + len(self.branch) + + def render(self) -> RenderResult: + """Render the branch label, truncating with the configured glyph. + + Returns: + Branch text (icon + name) truncated from the right with + :func:`get_glyphs`'s ellipsis when it overflows the available + width, or an empty string when no branch is set. + """ + width = self.content_size.width + if not self.branch or width <= 0: + return "" + icon = get_glyphs().git_branch + full = f"{icon} {self.branch}" + if len(full) <= width: + return full + ellipsis = get_glyphs().ellipsis + if width <= len(ellipsis): + return full[:width] + return full[: width - len(ellipsis)] + ellipsis + + +class MetricsLine(Widget): + """A bullet-separated chain of session metrics. + + Segments are supplied pre-styled and in priority order. When the chain is + wider than the bar, trailing segments are dropped one at a time (so the + least important metric goes first and the surviving segments never shift + position), and a lone segment that still overflows is ellipsized. + """ + + segments: reactive[tuple[Content, ...]] = reactive((), layout=True) + + def _separator(self) -> Content: # noqa: PLR6301 — reads the active glyph set + """Return the styled separator drawn between two segments.""" + return Content(f" {get_glyphs().bullet} ") + + def _chain(self, count: int) -> Content: + """Join the first `count` segments with bullet separators. + + Returns: + Joined metric segments. + """ + return self._separator().join(self.segments[:count]) + + def get_content_width(self, container: Size, viewport: Size) -> int: # noqa: ARG002 + """Return the intrinsic width of the full chain so `width: auto` works. + + Args: + container: Size of the container. + viewport: Size of the viewport. + + Returns: + Cell width of every segment joined by the separator. + """ + if not self.segments: + return 0 + return self._chain(len(self.segments)).cell_length + + def render(self) -> RenderResult: + """Render as many leading segments as the available width allows. + + Returns: + The joined chain, shortened from the tail until it fits. + """ + width = self.content_size.width + if not self.segments or width <= 0: + return Content("") + for count in range(len(self.segments), 0, -1): + chain = self._chain(count) + if chain.cell_length <= width: + return chain + ellipsis = get_glyphs().ellipsis + if width <= len(ellipsis): + return Content("") + first = self._chain(1).truncate(width - len(ellipsis)) + return first + ellipsis + + +class StatusBar(Vertical): + """Two-line status bar for session identity and runtime metrics.""" + + DEFAULT_CSS = """ + StatusBar { + height: 2; + dock: bottom; + background: $background; + } + + StatusBar .status-session, + StatusBar .status-metrics { + width: 1fr; + height: 1; + } + + StatusBar .status-mode { + width: auto; + padding: 0 1; + } + + StatusBar .status-mode.normal { + display: none; + } + + StatusBar .status-mode.shell { + background: $mode-bash; + color: white; + text-style: bold; + } + + StatusBar .status-mode.command { + background: $mode-command; + color: white; + } + + StatusBar .status-mode.shell-incognito { + background: $mode-incognito; + color: $background; + text-style: bold; + } + + StatusBar .status-auto-approve { + width: auto; + padding: 0 1; + margin-right: 1; + } + + StatusBar .status-auto-approve.yolo { + background: $error; + color: white; + text-style: bold; + } + + StatusBar .status-auto-approve.auto { + background: $success; + color: $background; + } + + StatusBar .status-auto-approve.manual { + background: $warning; + color: $background; + } + + StatusBar .status-connection { + width: auto; + padding: 0 1 0 0; + color: $warning; + text-style: bold; + } + + StatusBar .status-message { + width: auto; + padding: 0 1 0 0; + color: $text-muted; + } + + StatusBar .status-message.thinking { + color: $warning; + } + + StatusBar .status-cwd { + width: auto; + max-width: 45%; + padding: 0 1 0 0; + color: $text-muted; + overflow-x: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + } + + StatusBar .status-branch { + width: 1fr; + min-width: 0; + overflow-x: hidden; + text-wrap: nowrap; + } + + StatusBar .status-cache-line { + width: 1fr; + min-width: 0; + padding: 0; + color: $text-muted; + } + + StatusBar .status-context-line { + width: auto; + max-width: 55%; + min-width: 0; + padding: 0 0 0 2; + color: $text-muted; + text-align: right; + } + + StatusBar .status-rubric { + width: auto; + padding: 0 0 0 2; + color: $success; + text-style: bold; + } + + StatusBar ModelLabel { + width: auto; + max-width: 40%; + min-width: 0; + padding: 0 0 0 2; + color: $text-muted; + text-align: right; + } + + StatusBar BranchLabel { + color: $text-muted; + padding: 0; + } + """ + """Mode badges color the input mode; the approval mode is colored text.""" + + mode: reactive[str] = reactive("normal", init=False) + status_message: reactive[str] = reactive("", init=False) + connection_state: reactive[ConnectionState] = reactive("", init=False) + queued_count: reactive[int] = reactive(0, init=False) + approval_mode: reactive[str] = reactive(default="manual", init=False) + cwd: reactive[str] = reactive("", init=False) + branch: reactive[str] = reactive("", init=False) + tokens: reactive[int] = reactive(0, init=False) + cost_usd: reactive[float] = reactive(0.0, init=False) + rubric_label: reactive[str] = reactive("", init=False) + + def __init__(self, cwd: str | Path | None = None, **kwargs: Any) -> None: + """Initialize the status bar. + + Args: + cwd: Current working directory to display + **kwargs: Additional arguments passed to parent + """ + super().__init__(**kwargs) + # Store initial cwd - will be used in compose() + self._initial_cwd = str(cwd) if cwd else str(Path.cwd()) + self._hide_cwd = is_env_truthy(HIDE_CWD) + self._hide_git_branch = is_env_truthy(HIDE_GIT_BRANCH) + self._spinner = Spinner() + self._spinner_timer: Timer | None = None + self._busy_message = "" + self.context_limit: int | None = None + self.cache_input_tokens = 0 + self.cache_read_tokens = 0 + self.cache_write_tokens = 0 + self._status_by_source: dict[StatusMessageSource, str] = { + "agent": "", + "hooks": "", + } + + def compose(self) -> ComposeResult: # noqa: PLR6301 — Textual widget method + """Compose the status bar layout. + + Yields: + The model/workspace line followed by cache and context metrics. + """ + with Horizontal(classes="status-session"): + yield Static("", classes="status-mode normal", id="mode-indicator") + yield Static( + "manual", + classes="status-auto-approve manual", + id="auto-approve-indicator", + ) + yield Static("", classes="status-cwd", id="cwd-display") + yield BranchLabel(classes="status-branch", id="branch-display") + yield Static("", classes="status-rubric", id="rubric-display") + yield ModelLabel(id="model-display") + with Horizontal(classes="status-metrics"): + yield Static("", classes="status-connection", id="connection-indicator") + yield Static("", classes="status-message", id="status-message") + yield MetricsLine(classes="status-cache-line", id="cache-display") + yield MetricsLine(classes="status-context-line", id="tokens-display") + + _CWD_WIDTH_THRESHOLD = 70 + """Hide cwd display below this terminal width.""" + + def on_resize(self, event: events.Resize) -> None: + """Hide the cwd on very narrow terminals. + + The git branch stays visible at any width (unless disabled via + `HIDE_GIT_BRANCH`) and ellipsizes to fit; only the cwd is dropped + outright to reclaim space when the terminal gets narrow. + """ + width = event.size.width + self._set_cwd_visible(not self._hide_cwd and width >= self._CWD_WIDTH_THRESHOLD) + + def _set_cwd_visible(self, visible: bool) -> None: + """Show or hide the cwd.""" + with suppress(NoMatches): + self.query_one("#cwd-display", Static).display = visible + + def on_unmount(self) -> None: + """Stop the spinner timer so it can't tick on a detached widget.""" + self._stop_spinner() + + def on_mount(self) -> None: + """Set reactive values after mount to trigger watchers safely.""" + from deepagents_code.config import settings + + self.cwd = self._initial_cwd + if self._hide_cwd: + self._set_cwd_visible(False) + if self._hide_git_branch: + with suppress(NoMatches): + self.query_one("#branch-display", BranchLabel).display = False + # Set initial model display + label = self.query_one("#model-display", ModelLabel) + label.provider = settings.model_provider or "" + label.model = settings.model_name or "" + self.set_context_limit(settings.model_context_limit) + with suppress(NoMatches): + self.query_one("#rubric-display", Static).display = False + # Reactives are `init=False`, so the connection watcher never fires on + # mount; render once to hide the empty indicator (and its padding). + self._render_connection() + self.watch_status_message(self.status_message) + self._refresh_metrics() + + def watch_mode(self, mode: str) -> None: + """Update mode indicator when mode changes.""" + try: + indicator = self.query_one("#mode-indicator", Static) + except NoMatches: + return + indicator.remove_class("normal", "shell", "command", "shell-incognito") + + if mode == "shell": + indicator.update("SHELL") + indicator.add_class("shell") + elif mode == "shell_incognito": + indicator.update("SHELL") + indicator.add_class("shell-incognito") + elif mode == "command": + indicator.update("CMD") + indicator.add_class("command") + else: + indicator.update("") + indicator.add_class("normal") + + def watch_approval_mode(self, new_value: str) -> None: + """Update the three-state approval indicator.""" + try: + indicator = self.query_one("#auto-approve-indicator", Static) + except NoMatches: + return + indicator.remove_class("manual", "auto", "yolo") + mode = new_value if new_value in {"manual", "auto", "yolo"} else "manual" + indicator.update("YOLO" if mode == "yolo" else mode) + indicator.add_class(mode) + + def watch_cwd(self, new_value: str) -> None: + """Update cwd display when it changes.""" + try: + display = self.query_one("#cwd-display", Static) + except NoMatches: + return + display.update(self._format_cwd(new_value)) + + def watch_branch(self, new_value: str) -> None: + """Update branch display when it changes.""" + try: + display = self.query_one("#branch-display", BranchLabel) + except NoMatches: + return + display.branch = new_value + + def watch_status_message(self, new_value: str) -> None: + """Update status message display.""" + if self._busy_message: + # The busy indicator owns the status-message slot while active; + # defer regular status updates until `set_busy("")` clears it. + return + try: + msg_widget = self.query_one("#status-message", Static) + except NoMatches: + return + + msg_widget.remove_class("thinking") + # Hide when empty so the widget's padding doesn't reserve a blank gap + # in the footer (mirrors the connection indicator). + msg_widget.display = bool(new_value) + if new_value: + # Plain Content: hook-configured statusMessage may contain brackets. + msg_widget.update(Content(new_value)) + if "thinking" in new_value.lower() or "executing" in new_value.lower(): + msg_widget.add_class("thinking") + else: + msg_widget.update("") + + def watch_connection_state(self, _new_value: ConnectionState) -> None: + """Start or stop the spinner and re-render when connection state changes.""" + self._sync_spinner() + self._render_connection() + + def watch_queued_count(self, _new_value: int) -> None: + """Re-render the connection indicator when the queued count changes.""" + self._render_connection() + + def _spinner_active(self) -> bool: + """Whether any indicator (connection or busy) needs the shared spinner. + + Returns: + `True` when a connection state or a busy message is active. + """ + return bool(self.connection_state) or bool(self._busy_message) + + def _sync_spinner(self) -> None: + """Start or stop the shared spinner to match connection/busy state.""" + if self._spinner_active(): + self._start_spinner() + else: + self._stop_spinner() + + def _start_spinner(self) -> None: + """Begin cycling the shared spinner frames. + + No-op when not yet running (e.g. before mount) since `set_interval` + requires a live event loop, or when an animation is already active. + """ + if self._spinner_timer is not None or not self._running: + return + # 0.1s mirrors LoadingWidget so this spinner ticks in step with the + # in-thread "Thinking" spinner. + self._spinner_timer = self.set_interval(0.1, self._tick_spinner) + + def _stop_spinner(self) -> None: + """Stop the spinner animation and reset to the first frame.""" + if self._spinner_timer is not None: + self._spinner_timer.stop() + self._spinner_timer = None + self._spinner = Spinner() + + def _tick_spinner(self) -> None: + """Advance the spinner frame and re-render the animated indicators.""" + self._spinner.next_frame() + self._render_connection() + self._render_busy() + + def _render_connection(self) -> None: + """Render the combined connection + queued-count indicator text.""" + try: + widget = self.query_one("#connection-indicator", Static) + except NoMatches: + return + + parts: list[str] = [] + if self.connection_state == "reconnecting": + parts.append(f"{self._spinner.current_frame()} Reconnecting") + elif self.connection_state == "resuming": + parts.append(f"{self._spinner.current_frame()} Resuming") + elif self.connection_state == "connecting": + parts.append(f"{self._spinner.current_frame()} Connecting") + if self.queued_count > 0: + label = "message" if self.queued_count == 1 else "messages" + parts.append(f"{self.queued_count} {label} queued") + separator = f" {get_glyphs().bullet} " + text = separator.join(parts) + widget.display = bool(text) + widget.update(text) + + def _render_busy(self) -> None: + """Render the animated busy indicator into the status-message slot.""" + if not self._busy_message: + return + try: + widget = self.query_one("#status-message", Static) + except NoMatches: + return + widget.remove_class("thinking") + widget.display = True + frame = self._spinner.current_frame() + widget.update(Content.assemble(frame, " ", Content(self._busy_message))) + + def set_busy(self, message: str) -> None: + """Show or clear an animated busy indicator in the status-message slot. + + Reuses the shared status-bar spinner so heavier UI operations (e.g. a + model switch that imports a provider package) show activity instead of + appearing to hang. + + Args: + message: Busy text to animate with a spinner, or empty string to + clear it and restore the regular status message. + """ + self._busy_message = message + self._sync_spinner() + if message: + self._render_busy() + else: + self.watch_status_message(self.status_message) + + def set_connection(self, state: ConnectionState) -> None: + """Set the connection indicator state. + + Args: + state: One of `''` (clear), `'connecting'`, `'reconnecting'`, or + `'resuming'`. + + Raises: + ValueError: If `state` is not a recognized connection state. + """ + if state not in CONNECTION_STATES: + msg = f"Unknown connection state: {state!r}" + raise ValueError(msg) + self.connection_state = state + + def set_queued(self, count: int) -> None: + """Set the number of messages waiting in the queue. + + Args: + count: Count of queued messages (negative values clamp to `0`). + """ + self.queued_count = max(count, 0) + + def _format_cwd(self, cwd_path: str = "") -> str: + """Format the current working directory for display. + + Returns: + Formatted path string, using ~ for home directory when possible. + """ + path = Path(cwd_path or self.cwd or self._initial_cwd) + try: + # Try to use ~ for home directory + home = Path.home() + if path.is_relative_to(home): + return "~/" + path.relative_to(home).as_posix() + except (ValueError, RuntimeError): + pass + return str(path) + + def set_mode(self, mode: str) -> None: + """Set the current input mode. + + Args: + mode: One of "normal", "shell", or "command" + """ + self.mode = mode + + @property + def auto_approve(self) -> bool: + """Whether unrestricted compatibility mode is active.""" + return self.approval_mode == "yolo" + + @auto_approve.setter + def auto_approve(self, enabled: bool) -> None: + self.set_approval_mode("yolo" if enabled else "manual") + + def set_approval_mode(self, mode: str) -> None: + """Set the approval mode. + + Args: + mode: `manual`, `auto`, or `yolo`. + """ + self.approval_mode = mode if mode in {"manual", "auto", "yolo"} else "manual" + + def set_auto_approve(self, *, enabled: bool) -> None: + """Set the compatibility unrestricted state. + + Args: + enabled: Whether unrestricted mode is enabled. + """ + self.set_approval_mode("yolo" if enabled else "manual") + + def set_status_message( + self, + message: str, + *, + source: StatusMessageSource = "agent", + ) -> None: + """Set the status message with explicit source ownership. + + Each source stores its own message. Hooks take display priority while + they have a non-empty message; clearing hooks restores any stored agent + message instead of blanking the slot. Agent writes never erase an active + hook status, and hook completion never erases a stored agent status. + + Args: + message: Status message to display (empty string to clear). + source: Subsystem that owns this write (`agent` or `hooks`). + """ + self._status_by_source[source] = message + self.status_message = ( + self._status_by_source["hooks"] or self._status_by_source["agent"] + ) + + _approximate: bool = False + """Append "+" to the token count to signal that the displayed value is stale. + + (The actual context is larger because the generation was interrupted before + the model reported final usage.) + """ + _has_token_count: bool = False + """Whether the status bar has displayed a real token count this session.""" + + _tokens_pending: bool = False + """Whether the accurate token count for the current turn is still pending. + + A cost update can arrive mid-turn, and it re-renders the shared token/cost + slot. Without this flag that re-render would replace the `... tokens` + placeholder with the *previous* turn's count -- the stale value the + placeholder exists to hide. + """ + + def watch_tokens(self, new_value: int) -> None: + """Update the combined token and cost display when tokens change.""" + self._render_tokens(new_value, approximate=self._approximate) + + def watch_cost_usd(self, _new_value: float) -> None: + """Update the combined token and cost display when cost changes.""" + self._render_tokens(self.tokens, approximate=self._approximate) + + def _refresh_metrics(self) -> None: + """Re-render the metrics line from the current reactive values.""" + self._render_tokens(self.tokens, approximate=self._approximate) + + def watch_rubric_label(self, new_value: str) -> None: + """Update rubric display when active rubric state changes.""" + try: + display = self.query_one("#rubric-display", Static) + except NoMatches: + return + display.display = bool(new_value) + display.update(new_value) + + _CONTEXT_WARNING_PERCENT = 60.0 + """Context usage at which the percentage turns from calm to caution.""" + + _CONTEXT_CRITICAL_PERCENT = 80.0 + """Context usage at which the percentage turns to alert.""" + + def _percent_color(self, percent: float) -> str: + """Return the color that encodes how full the context window is.""" + colors = theme.get_theme_colors(self) + if percent > self._CONTEXT_CRITICAL_PERCENT: + return colors.error + if percent > self._CONTEXT_WARNING_PERCENT: + return colors.warning + return colors.muted + + def _context_segment(self, count: int, *, approximate: bool = False) -> Content: + """Build the context percentage and absolute-usage segment. + + Returns: + Styled context usage. + """ + pending = self._tokens_pending + suffix = "+" if approximate else "" + if pending: + percent_content = Content("...") + count_text = "..." + elif self.context_limit is None: + percent_content = Content("0%" if count <= 0 else "--") + count_text = f"{_compact_tokens(count)}{suffix}" + else: + percent = min(100.0, max(0.0, count / self.context_limit * 100)) + percent_content = Content.styled( + f"{percent:.0f}%", self._percent_color(percent) + ) + count_text = f"{_compact_tokens(count)}{suffix}" + muted = theme.get_theme_colors(self).muted + return Content.assemble( + Content.styled("Context:", muted), + " ", + percent_content, + " / ", + Content.styled("Tokens:", muted), + f" {count_text}", + ) + + def _cache_segment(self) -> Content: + """Build the active thread's cache segment. + + Returns: + Styled cache usage. + """ + colors = theme.get_theme_colors(self) + hit_rate = Content("") + if self.cache_input_tokens: + cached = min(self.cache_read_tokens, self.cache_input_tokens) + percent = cached / self.cache_input_tokens * 100 + if percent < 60.0: # noqa: PLR2004 # cache alert threshold + color = colors.error + elif percent < 90.0: # noqa: PLR2004 # cache warning threshold + color = colors.warning + else: + color = colors.muted + hit_rate = Content.styled(f"{percent:.0f}% hit", color) + elif not self.cache_read_tokens and not self.cache_write_tokens: + hit_rate = Content.styled("0% hit", colors.muted) + details = ( + f"{_compact_tokens(self.cache_read_tokens)} read" + f" / {_compact_tokens(self.cache_write_tokens)} write" + ) + return Content.assemble( + Content.styled("Cache", colors.muted), + " ", + hit_rate, + f" {get_glyphs().bullet} " if hit_rate.plain else "", + details, + ) + + def _cost_text(self) -> str: + """Format cumulative cost, including the initial zero state. + + Returns: + Formatted cost. + """ + return format_cost(self.cost_usd) + + def _render_tokens(self, count: int, *, approximate: bool = False) -> None: + """Render cache left and context/cost right on the metrics line.""" + try: + cache_display = self.query_one("#cache-display", MetricsLine) + context_display = self.query_one("#tokens-display", MetricsLine) + except NoMatches: + return + + cost = self._cost_text() + context_segments = tuple( + segment + for segment in ( + self._context_segment(count, approximate=approximate), + Content(cost) if cost else Content(""), + ) + if segment.plain + ) + cache = self._cache_segment() + cache_display.segments = (cache,) if cache.plain else () + context_display.segments = context_segments + + def set_rubric_label(self, label: str) -> None: + """Set the rubric status label. + + Args: + label: Label to display, or empty string to hide the badge. + """ + self.rubric_label = label + + def set_tokens(self, count: int, *, approximate: bool = False) -> None: + """Set the token count. + + Forces a display refresh even when the value is unchanged. During + streaming, `show_pending_tokens` replaces the widget text without + changing the reactive token value, so a later update with the same + count still needs to re-render the exact count. + + Args: + count: Current context token count. + approximate: Append "+" to indicate the count is stale. + """ + self._approximate = approximate + self._has_token_count = count > 0 + # The accurate count has arrived, so stop suppressing it. + self._tokens_pending = False + if self.tokens == count: + # Reactive dedup would skip the watcher — call render directly. + self._render_tokens(count, approximate=approximate) + else: + # Reactive assignment triggers watch_tokens, which reads + # self._approximate for the suffix. + self.tokens = count + + def set_context_limit(self, limit: int | None) -> None: + """Set the active model's context limit.""" + self.context_limit = limit if isinstance(limit, int) and limit > 0 else None + self._refresh_metrics() + + def set_cache_tokens( + self, + read_tokens: int, + write_tokens: int, + *, + input_tokens: int = 0, + ) -> None: + """Set cumulative input and cache token counts for the active thread. + + Args: + read_tokens: Input tokens served from the provider cache. + write_tokens: Input tokens written to the provider cache. + input_tokens: Inclusive input-token total used as the hit-rate + denominator. + """ + reads = max(read_tokens, 0) + writes = max(write_tokens, 0) + inputs = max(input_tokens, 0) + self.cache_input_tokens = inputs + self.cache_read_tokens = reads + self.cache_write_tokens = writes + self._refresh_metrics() + + def set_cost(self, cost_usd: float) -> None: + """Set the cumulative thread cost shown beside context tokens. + + Args: + cost_usd: Cumulative estimated cost in US dollars. + """ + if self.cost_usd == cost_usd: + self._refresh_metrics() + else: + self.cost_usd = cost_usd + + def show_pending_tokens(self) -> None: + """Show pending tokens while preserving the cumulative cost.""" + if not self._has_token_count: + return + # Latch the placeholder so a mid-turn cost refresh keeps it instead of + # re-rendering the previous turn's count. + self._tokens_pending = True + self._refresh_metrics() + + def set_model(self, *, provider: str, model: str, effort: str = "") -> None: + """Update the model display text. + + Args: + provider: Model provider name (e.g., `'anthropic'`). + model: Model name (e.g., `'claude-sonnet-4-5'`). + effort: Reasoning effort label to display (per-session override or + provider default), or empty when none applies. + """ + label = self.query_one("#model-display", ModelLabel) + label.provider = provider + label.model = model + label.effort = effort diff --git a/libs/code/deepagents_code/tui/widgets/subagent_panel.py b/libs/code/deepagents_code/tui/widgets/subagent_panel.py new file mode 100644 index 0000000000..00d0153935 --- /dev/null +++ b/libs/code/deepagents_code/tui/widgets/subagent_panel.py @@ -0,0 +1,943 @@ +"""Live panel showing subagents fanned out from within `js_eval` calls. + +When the agent writes code that calls the top-level `task()` global, each +dispatch runs as a subagent *inside* a single `js_eval` tool call which is +invisible to the normal message stream. The QuickJS task bridge emits +lifecycle events on the custom stream. This widget consumes them and renders +a docked, live-updating fan-out panel. + +Trust note: `description`/`subagent_type` and `error` strings originate +from LLM-authored JavaScript executed in the sandbox, so they are untrusted. +We route every rendered string through `sanitize_control_chars` which strips +control/escape/bidi characters and only ever render via `Content.styled` / +`markup=False` `Static` updates, so embedded Textual markup and terminal +escapes cannot influence rendering or panel state. +""" + +from __future__ import annotations + +import contextlib +import logging +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Literal + +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.content import Content +from textual.css.query import NoMatches, TooManyMatches +from textual.reactive import reactive +from textual.widgets import Static + +from deepagents_code.config import get_glyphs +from deepagents_code.formatting import format_duration +from deepagents_code.theme import get_theme_colors +from deepagents_code.tui.widgets.loading import Spinner +from deepagents_code.unicode_security import sanitize_control_chars + +if TYPE_CHECKING: + from textual import events + from textual.app import ComposeResult + from textual.timer import Timer + +logger = logging.getLogger(__name__) + +SubagentStatus = Literal["running", "done", "error", "cancelled"] + +_MODEL_COL = 16 +_TIMING_COL = 6 +_STATUS_COL = 5 +_MIN_TASK_COL = 16 +_SCROLLBAR_RESERVE = 2 +_FALLBACK_WIDTH = 100 +_MIN_BODY_HEIGHT = 3 +_MAX_BODY_HEIGHT = 12 +_AGENTS_CHROME_LINES = 1 +_TICK_INTERVAL = 0.1 +_LABEL_FALLBACK_MAX_CHARS = 60 + + +def _right_block_width() -> int: + """Total width of the right-aligned metadata block (model→time). + + Returns: + The combined character width of the model and time columns. + """ + gap = 2 + return _MODEL_COL + gap + _TIMING_COL + + +@dataclass +class _SubagentRecord: + """One subagent's live state within a phase.""" + + id: str + """Per-dispatch subagent id from the stream event.""" + + label: str + """Sanitized, display-ready task label for the row.""" + + status: SubagentStatus = "running" + """Lifecycle state; starts running and moves to a terminal value once.""" + + started_monotonic: float = field(default_factory=time.monotonic) + """Monotonic timestamp captured when the record was created.""" + + duration_ms: int | None = None + """Measured duration once finished; None while still running.""" + + error: str | None = None + """Failure reason, set only when status is error.""" + + def elapsed_seconds(self) -> float: + """Seconds since this subagent started (live for running rows). + + Returns: + The measured duration once finished, else the live elapsed time. + """ + if self.duration_ms is not None: + return self.duration_ms / 1000 + return max(0.0, time.monotonic() - self.started_monotonic) + + +@dataclass +class _Phase: + """One `js_eval` fan-out batch, keyed by the eval's tool-call id.""" + + eval_id: str + """Parent `js_eval` tool-call id, or empty string when none was provided.""" + + index: int + """1-based display ordinal assigned when the phase is created.""" + + records: dict[str, _SubagentRecord] = field(default_factory=dict) + """Subagent records keyed by id; kept in sync with `order` via `add`.""" + + order: list[str] = field(default_factory=list) + """Record ids in arrival order, defining render sequence.""" + + def add(self, record: _SubagentRecord) -> None: + """Insert or replace a subagent record, preserving arrival order.""" + if record.id not in self.records: + self.order.append(record.id) + self.records[record.id] = record + + def counts(self) -> tuple[int, int]: + """Return (finished, total) subagent counts for this phase.""" + total = len(self.records) + done = sum(1 for r in self.records.values() if r.status != "running") + return done, total + + def any_running(self) -> bool: + """Whether any subagent in this phase is still running. + + Returns: + True if at least one subagent has not finished. + """ + return any(r.status == "running" for r in self.records.values()) + + def any_error(self) -> bool: + """Whether any subagent in this phase ended in error. + + Returns: + True if at least one subagent ended in error. + """ + return any(r.status == "error" for r in self.records.values()) + + def any_cancelled(self) -> bool: + """Whether any subagent in this phase was cancelled. + + Returns: + True if at least one subagent was cancelled. + """ + return any(r.status == "cancelled" for r in self.records.values()) + + def all_terminal(self) -> bool: + """Whether the phase has records and none are still running. + + Returns: + True if the phase has at least one record and all have finished. + """ + return bool(self.records) and not self.any_running() + + def elapsed_seconds(self) -> float: + """Wall-clock elapsed for the phase (frozen once all subagents end). + + Measured from the first subagent's start to the last one's finish, so + the value is continuous: the live "now - first start" simply freezes + when the final subagent ends (rather than collapsing to the longest + single duration). + + Returns: + Live elapsed while running, else first-start to last-finish. + """ + if not self.records: + return 0.0 + earliest = min(r.started_monotonic for r in self.records.values()) + if self.all_terminal(): + latest_end = max( + r.started_monotonic + r.elapsed_seconds() for r in self.records.values() + ) + return max(0.0, latest_end - earliest) + return max(0.0, time.monotonic() - earliest) + + +def _format_timing(seconds: float) -> str: + """Stable-width elapsed string for the table. + + `format_duration` drops the decimal on whole seconds (`4s` vs `4.2s`), + which makes a live-ticking value jump left/right by a character each tick. + Always keep one decimal under a minute so the width stays constant. + + Returns: + e.g. `4.0s` or `4.2s` under a minute, else `format_duration`'s output. + """ + if seconds < 60: # noqa: PLR2004 + return f"{seconds:.1f}s" + return format_duration(seconds) + + +def _sanitize(text: str, *, max_chars: int) -> str: + """Neutralize control/escape/bidi chars and bound length for a one-line label. + + Inputs are LLM/JS-authored and untrusted. This flattens to a single line (newlines + and ANSI escapes become spaces) so a crafted description cannot inject terminal + escapes or extra rows. + + Returns: + A single-line, length-bounded string safe to render as plain text. + """ + return sanitize_control_chars(text, keep_newlines=False, max_length=max_chars) + + +class SubagentPanel(Vertical): + """Docked two-pane panel visualizing `js_eval` subagent fan-out by phase. + + Hidden until the first spawn event. Phases (one per `js_eval`) list on the + left and the selected phase's subagents render as a scrollable table on the + right. Focus the panel and use up/down to revisit finished phases. Expands + while any phase runs, collapses to the header when the turn goes idle, and + re-expands when a new phase starts. + """ + + can_focus = True + can_focus_children = False + + DEFAULT_CSS = """ + SubagentPanel { + height: auto; + background: $surface; + border-top: solid $primary; + display: none; + padding: 1 2; + } + + SubagentPanel.-collapsed { + padding: 0 2; + } + + SubagentPanel.-visible { + display: block; + } + + SubagentPanel:focus { + border-top: solid $accent; + } + + SubagentPanel #subagent-header { + width: 1fr; + height: 1; + text-style: bold; + } + + SubagentPanel #subagent-body { + width: 1fr; + height: auto; + margin-top: 1; + } + + SubagentPanel #subagent-body.-collapsed { + display: none; + } + + SubagentPanel #subagent-phases-scroll { + width: 24; + height: 100%; + border-right: solid $primary-darken-2; + padding-right: 2; + margin-right: 2; + } + + SubagentPanel #subagent-phases-scroll.-hidden { + display: none; + } + + SubagentPanel #subagent-agents-scroll { + width: 1fr; + height: 100%; + } + """ + + expanded: reactive[bool] = reactive(default=True, init=False) + + def __init__(self, **kwargs: Any) -> None: + """Initialize an empty, hidden panel.""" + super().__init__(**kwargs) + self._phases: dict[str, _Phase] = {} + self._phase_order: list[str] = [] + self._active_eval_id: str | None = None + self._selected_eval_id: str | None = None + self._model_label: str | None = None + self._applied_height: int | None = None + self._last_render: dict[str, str] = {} + self._spinner = Spinner() + self._timer: Timer | None = None + + def compose(self) -> ComposeResult: # noqa: PLR6301 — Textual widget method + """Yield the header line and the two-pane body (phases | agents).""" + yield Static("", id="subagent-header", markup=False) + with Horizontal(id="subagent-body"): + with VerticalScroll(id="subagent-phases-scroll"): + yield Static("", id="subagent-phases", markup=False) + with VerticalScroll(id="subagent-agents-scroll"): + yield Static("", id="subagent-agents", markup=False) + + @property + def _active_phase(self) -> _Phase | None: + if self._active_eval_id is None: + return self._phases.get("") + return self._phases.get(self._active_eval_id) + + def _displayed_phase(self) -> _Phase | None: + """The phase whose table is shown — the user's pick, else the active one. + + Returns: + The selected phase if the user navigated to one, else the active + (latest) phase, or None when no phase has started. + """ + if self._selected_eval_id is not None: + phase = self._phases.get(self._selected_eval_id) + if phase is not None: + return phase + return self._active_phase + + def on_subagent_event(self, event: dict[str, Any]) -> None: + """Apply one validated subagent lifecycle event. + + The caller (textual adapter) has already checked `type == "subagent"` + and that this is the main-agent namespace. We defensively re-validate + every field here so malformed payloads can never corrupt panel state. + """ + phase = event.get("phase") + sub_id = event.get("id") + if not isinstance(sub_id, str) or not sub_id: + # Producer/consumer contract drift — leave a breadcrumb rather than + # dropping the event with no trace. + logger.debug("Dropping subagent event with missing/invalid id: %r", event) + return + eval_id = event.get("eval_id") + eval_key = eval_id if isinstance(eval_id, str) else "" + + if phase == "start": + self._handle_start(sub_id, eval_key, event) + elif phase in {"complete", "error"}: + self._handle_finish(sub_id, eval_key, phase, event) + else: + logger.debug( + "Dropping subagent event with unrecognized phase %r (id=%s)", + phase, + sub_id, + ) + return + + self._refresh() + + def _handle_start(self, sub_id: str, eval_key: str, event: dict[str, Any]) -> None: + """Create/replace a running record and (re-)show the panel.""" + phase = self._ensure_phase(eval_key) + self._active_eval_id = eval_key + + record = _SubagentRecord( + id=sub_id, + label=_sanitize(self._row_label(event), max_chars=200), + ) + phase.add(record) + self._show() + self._apply_body_height() + self._ensure_timer() + + def _ensure_phase(self, eval_key: str) -> _Phase: + """Return the phase for `eval_key`, creating and ordering it if new. + + Returns: + The existing or newly created `_Phase` for this eval batch. + """ + phase = self._phases.get(eval_key) + if phase is None: + phase = _Phase(eval_id=eval_key, index=len(self._phase_order) + 1) + self._phases[eval_key] = phase + self._phase_order.append(eval_key) + return phase + + @staticmethod + def _row_label(event: dict[str, Any]) -> str: + """Build the row's task label: `":