zjcode 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,4829 @@
1
+ """Configuration, constants, and model creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+ import keyword
8
+ import logging
9
+ import os
10
+ import re
11
+ import shlex
12
+ import shutil
13
+ import sys
14
+ import threading
15
+ from dataclasses import dataclass, field as dataclass_field
16
+ from enum import StrEnum
17
+ from importlib.metadata import PackageNotFoundError, distribution
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Any, Protocol
20
+ from urllib.parse import unquote, urlparse
21
+
22
+ from deepagents_code._constants import FIREWORKS_PROVIDER_ID_PREFIX
23
+ from deepagents_code._env_vars import (
24
+ DISABLED_PROJECT_MCP_SERVERS,
25
+ ENABLED_PROJECT_MCP_SERVERS,
26
+ HIDE_SPLASH_VERSION,
27
+ is_env_truthy,
28
+ )
29
+ from deepagents_code._git import resolve_git_branch
30
+ from deepagents_code._version import DISTRIBUTION_NAME, __version__
31
+ from deepagents_code.config_manifest import (
32
+ INTERPRETER_ENABLE_DEFAULT,
33
+ INTERPRETER_MAX_PTC_CALLS_DEFAULT,
34
+ INTERPRETER_MAX_RESULT_CHARS_DEFAULT,
35
+ INTERPRETER_MEMORY_LIMIT_MB_DEFAULT,
36
+ INTERPRETER_PTC_ACKNOWLEDGE_UNSAFE_DEFAULT,
37
+ INTERPRETER_PTC_DEFAULT,
38
+ INTERPRETER_TIMEOUT_SECONDS_DEFAULT,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Lazy bootstrap: dotenv loading, LANGSMITH_PROJECT override, and start-path
45
+ # detection are deferred until first access of `settings` (via module
46
+ # `__getattr__`). This avoids disk I/O and path traversal during import for
47
+ # callers that never touch `settings` (e.g. `deepagents --help`).
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ @dataclass
52
+ class _BootstrapState:
53
+ """Mutable state captured by `_ensure_bootstrap()`."""
54
+
55
+ done: bool = False
56
+ """Whether `_ensure_bootstrap()` has executed."""
57
+
58
+ start_path: Path | None = None
59
+ """Working directory captured at bootstrap time for dotenv and discovery."""
60
+
61
+ original_langsmith_project: str | None = None
62
+ """Caller's `LANGSMITH_PROJECT` before the app overrides it for traces."""
63
+
64
+ original_tracing_env: dict[str, str | None] = dataclass_field(default_factory=dict)
65
+ """Caller's tracing-enable env before Deep Agents Code mutates flags."""
66
+
67
+ original_tracing_api_keys: dict[str, str | None] = dataclass_field(
68
+ default_factory=dict
69
+ )
70
+ """Caller's tracing API keys before Deep Agents Code overwrites them.
71
+
72
+ Two bootstrap steps can overwrite the canonical `LANGSMITH_API_KEY` (and
73
+ its `LANGCHAIN_API_KEY` alias): the `DEEPAGENTS_CODE_`-prefixed override and
74
+ the `/auth`-stored key bridged on by `apply_stored_langsmith_auth`. Both run
75
+ after this snapshot is captured. Without saving the originals, shell
76
+ subprocesses inherit the agent's session key and the caller's own value is
77
+ irrecoverable in-process. This mirrors the save/restore pattern used for
78
+ tracing flags (`original_tracing_env`).
79
+ """
80
+
81
+
82
+ _bootstrap_state = _BootstrapState()
83
+ """State captured and mutated by lazy bootstrap."""
84
+
85
+ _bootstrap_lock = threading.Lock()
86
+ """Guards `_ensure_bootstrap()` against concurrent access from the main thread
87
+ and the prewarm worker thread."""
88
+
89
+ _singleton_lock = threading.Lock()
90
+ """Guards lazy singleton construction in `_get_console` / `_get_settings`."""
91
+
92
+ _dotenv_loaded_values: dict[str, str] = {}
93
+ """Environment values injected by our dotenv loader and safe to refresh later."""
94
+
95
+ _orphaned_tracing_disabled_notice: str | None = None
96
+ """One-shot TUI notice populated when bootstrap disables orphaned tracing."""
97
+
98
+ _INHERITED_PYTHONPATH_ENV = "DEEPAGENTS_INHERITED_PYTHONPATH"
99
+ """Carrier var that relays a launch-time `PYTHONPATH` to agent `execute` commands.
100
+
101
+ `PYTHONPATH` is stripped from the server interpreter's environment (see
102
+ `server._SERVER_ENV_DENYLIST`) to keep an untrusted import path off `sys.path`
103
+ during startup. The launch-time value is instead carried in this var and
104
+ re-applied only to the approval-gated shell backend's `execute` subprocesses by
105
+ `agent._apply_inherited_pythonpath`.
106
+ """
107
+
108
+ _DOTENV_DENIED_ENV_KEYS = frozenset(
109
+ {
110
+ "BASH_ENV",
111
+ "BASHOPTS",
112
+ "CDPATH",
113
+ "DYLD_INSERT_LIBRARIES",
114
+ "DYLD_LIBRARY_PATH",
115
+ "ENV",
116
+ "GIT_ASKPASS",
117
+ "GLOBIGNORE",
118
+ "LD_AUDIT",
119
+ "LD_LIBRARY_PATH",
120
+ "LD_PRELOAD",
121
+ "NODE_OPTIONS",
122
+ "PATH",
123
+ "PYTHONEXECUTABLE",
124
+ "PYTHONHOME",
125
+ "PYTHONPATH",
126
+ "PYTHONSTARTUP",
127
+ "SHELLOPTS",
128
+ "SSH_ASKPASS",
129
+ _INHERITED_PYTHONPATH_ENV,
130
+ }
131
+ )
132
+ """Environment keys that project `.env` files must not inject.
133
+
134
+ A project `.env` is untrusted (it travels with a cloned repo), so it must not be
135
+ able to set variables that turn loading the `.env` into code execution in the
136
+ subprocesses Deep Agents Code spawns. The set spans four threat categories;
137
+ every entry is here for one of these reasons, so do not remove one without
138
+ checking which category it belongs to:
139
+
140
+ - Dynamic-linker preload/audit (`DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`,
141
+ `LD_AUDIT`, `LD_LIBRARY_PATH`, `LD_PRELOAD`): force a loader to map an
142
+ attacker-supplied shared object into every spawned binary.
143
+ - Interpreter startup/path (`NODE_OPTIONS`, `PATH`, `PYTHONEXECUTABLE`,
144
+ `PYTHONHOME`, `PYTHONPATH`, `PYTHONSTARTUP`, `_INHERITED_PYTHONPATH_ENV`):
145
+ hijack which interpreter/binary runs or what it imports at startup.
146
+ - Shell startup hooks (`BASH_ENV`, `ENV`, `BASHOPTS`, `SHELLOPTS`, `CDPATH`,
147
+ `GLOBIGNORE`): `BASH_ENV`/`ENV` source a file on every non-interactive shell;
148
+ `SHELLOPTS`/`BASHOPTS` can force `xtrace`/alias expansion; `CDPATH`/
149
+ `GLOBIGNORE` alter path/glob resolution. The agent runs detection and
150
+ `execute` commands through non-interactive shells, so these are live vectors.
151
+ - Askpass hijack (`GIT_ASKPASS`, `SSH_ASKPASS`): point credential prompts at an
152
+ attacker-controlled binary.
153
+
154
+ `_INHERITED_PYTHONPATH_ENV` is denied so a project `.env` cannot smuggle a
155
+ `PYTHONPATH` into agent `execute` commands through the carrier var; the carrier
156
+ is only meant to relay a value the user set in their launch environment.
157
+
158
+ Matching is exact and case-sensitive: the protected consumers (the dynamic
159
+ linker, bash, CPython) read these names only in their canonical case, so a
160
+ lowercase `bash_env` injected into the environment is inert. Any future entry
161
+ that some consumer reads case-insensitively would need a different check.
162
+ """
163
+
164
+ _PROJECT_DOTENV_DENIED_ENV_KEYS = frozenset(
165
+ {
166
+ ENABLED_PROJECT_MCP_SERVERS,
167
+ DISABLED_PROJECT_MCP_SERVERS,
168
+ }
169
+ )
170
+ """Env keys a *project* `.env` must not inject, even though they are otherwise
171
+ safe process-env inputs.
172
+
173
+ These two vars are the env form of the user-level project-MCP allow/deny lists
174
+ (`model_config.load_mcp_server_trust_lists`). Their whole purpose is to be a
175
+ *user-level* decision: naming a project MCP server here pre-approves it from an
176
+ untrusted `.mcp.json` (stdio → local command execution; remote → SSRF and
177
+ `${VAR}` header exfiltration during the discovery preflight). A project `.env`
178
+ travels with a cloned repo, so honoring it would let an attacker commit
179
+ `.mcp.json` + `.env` and self-approve their own servers — exactly the trust
180
+ boundary the feature exists to hold.
181
+
182
+ Unlike `_DOTENV_DENIED_ENV_KEYS` (denied from *any* `.env` because they turn
183
+ `.env` loading into code execution), these are denied only from the *project*
184
+ `.env`: the user's own global `~/.deepagents/.env` and their shell exports are
185
+ legitimate, trusted sources and continue to set them. The loader reads plain
186
+ `os.environ`, so blocking injection here — before the value ever reaches
187
+ `os.environ` — is what keeps that read trustworthy.
188
+ """
189
+
190
+
191
+ def _find_dotenv_from_start_path(start_path: Path) -> Path | None:
192
+ """Find the nearest `.env` file from an explicit start path upward.
193
+
194
+ Args:
195
+ start_path: Directory to start searching from.
196
+
197
+ Returns:
198
+ Path to the nearest `.env` file, or `None` if not found.
199
+ """
200
+ current = start_path.expanduser().resolve()
201
+ for parent in [current, *list(current.parents)]:
202
+ candidate = parent / ".env"
203
+ try:
204
+ if candidate.is_file():
205
+ return candidate
206
+ except OSError:
207
+ logger.warning("Could not inspect .env candidate %s", candidate)
208
+ continue
209
+ return None
210
+
211
+
212
+ # Global user-level .env (~/.deepagents/.env); sentinel when Path.home() fails.
213
+ try:
214
+ _GLOBAL_DOTENV_PATH = Path.home() / ".zjcode" / ".env"
215
+ except RuntimeError:
216
+ _GLOBAL_DOTENV_PATH = Path("/nonexistent/.zjcode/.env")
217
+
218
+
219
+ def _preview_dotenv_environ(*, start_path: Path | None = None) -> dict[str, str]:
220
+ """Return the environment after dotenv loading without mutating `os.environ`.
221
+
222
+ Args:
223
+ start_path: Directory to use for project `.env` discovery.
224
+
225
+ Returns:
226
+ Environment mapping with project and global dotenv values applied using
227
+ the same first-write-wins precedence as `_load_dotenv`.
228
+ """
229
+ import dotenv
230
+
231
+ env = dict(os.environ)
232
+ for key, value in _dotenv_loaded_values.items():
233
+ if env.get(key) == value:
234
+ env.pop(key)
235
+
236
+ def apply_dotenv(dotenv_path: Path | None, *, is_project: bool) -> None:
237
+ if dotenv_path is None:
238
+ return
239
+ try:
240
+ values = dotenv.dotenv_values(dotenv_path=dotenv_path)
241
+ except (OSError, ValueError):
242
+ logger.warning(
243
+ "Could not read dotenv at %s; previewed project env vars may be "
244
+ "incomplete",
245
+ dotenv_path,
246
+ exc_info=True,
247
+ )
248
+ return
249
+ for key, value in values.items():
250
+ if value is None or key in env:
251
+ continue
252
+ if key in _DOTENV_DENIED_ENV_KEYS:
253
+ # Log the key only — the value is attacker-controlled.
254
+ logger.debug("Ignoring denied env key %r from %s", key, dotenv_path)
255
+ continue
256
+ if is_project and key in _PROJECT_DOTENV_DENIED_ENV_KEYS:
257
+ # Mirror `_load_dotenv`: a project `.env` cannot preview-set a
258
+ # user-level MCP trust decision (the global `.env`/shell can).
259
+ logger.debug(
260
+ "Ignoring project-denied env key %r from %s", key, dotenv_path
261
+ )
262
+ continue
263
+ env[key] = value
264
+
265
+ project_dotenv: Path | None = None
266
+ try:
267
+ project_dotenv = (
268
+ _find_dotenv_from_start_path(start_path)
269
+ if start_path is not None
270
+ else _find_dotenv_from_start_path(Path.cwd())
271
+ )
272
+ except OSError:
273
+ logger.warning(
274
+ "Could not inspect project dotenv at %s; previewed project env vars may "
275
+ "be incomplete",
276
+ start_path or "cwd",
277
+ exc_info=True,
278
+ )
279
+ apply_dotenv(project_dotenv, is_project=True)
280
+
281
+ try:
282
+ global_dotenv = _GLOBAL_DOTENV_PATH if _GLOBAL_DOTENV_PATH.is_file() else None
283
+ except OSError:
284
+ logger.warning(
285
+ "Could not inspect global dotenv at %s; previewed global defaults may "
286
+ "be incomplete",
287
+ _GLOBAL_DOTENV_PATH,
288
+ exc_info=True,
289
+ )
290
+ global_dotenv = None
291
+ apply_dotenv(global_dotenv, is_project=False)
292
+
293
+ return env
294
+
295
+
296
+ def _resolve_env_var_from(env: dict[str, str], name: str) -> str | None:
297
+ """Resolve an env var from a mapping using app prefix precedence.
298
+
299
+ Returns:
300
+ The resolved value, or `None` when absent or empty.
301
+ """
302
+ from deepagents_code.model_config import _ENV_PREFIX
303
+
304
+ if not name.startswith(_ENV_PREFIX):
305
+ prefixed = f"{_ENV_PREFIX}{name}"
306
+ if prefixed in env:
307
+ return env[prefixed] or None
308
+ return env.get(name) or None
309
+
310
+
311
+ def _load_dotenv(
312
+ *, start_path: Path | None = None, refresh_loaded: bool = False
313
+ ) -> bool:
314
+ """Load environment variables from project and global `.env` files.
315
+
316
+ Loads in order (first write wins, `override=False`):
317
+
318
+ 1. Project/CWD `.env` — project-specific values
319
+ 2. `~/.deepagents/.env` — global user defaults
320
+
321
+ Both layers use `override=False` (the python-dotenv default) so that
322
+ shell-exported variables always take precedence over dotenv files.
323
+ Because project loads first, the effective precedence is:
324
+
325
+ ```text
326
+ shell env (incl. inline `VAR=x`) > project `.env` > global `.env`
327
+ ```
328
+
329
+ !!! note
330
+
331
+ To scope credentials to the app without colliding with
332
+ identically-named shell exports, use the `DEEPAGENTS_CODE_` env-var
333
+ prefix (see `resolve_env_var` in `deepagents_code.model_config`).
334
+
335
+ Args:
336
+ start_path: Directory to use for project `.env` discovery.
337
+ refresh_loaded: Remove values previously injected by this loader before
338
+ applying the current project/global dotenv stack. Values modified
339
+ after loading are preserved.
340
+
341
+ Returns:
342
+ `True` when at least one dotenv file was loaded, `False` otherwise.
343
+ """
344
+ import dotenv
345
+
346
+ loaded = False
347
+
348
+ if refresh_loaded:
349
+ for key, value in list(_dotenv_loaded_values.items()):
350
+ if os.environ.get(key) == value:
351
+ os.environ.pop(key)
352
+ _dotenv_loaded_values.clear()
353
+
354
+ def apply_dotenv(dotenv_path: Path, *, is_project: bool) -> bool:
355
+ values = dotenv.dotenv_values(dotenv_path=dotenv_path)
356
+ applied = False
357
+ for key, value in values.items():
358
+ if value is None or key in os.environ:
359
+ continue
360
+ if key in _DOTENV_DENIED_ENV_KEYS:
361
+ # Log the key only — the value is attacker-controlled.
362
+ logger.debug("Ignoring denied env key %r from %s", key, dotenv_path)
363
+ continue
364
+ if is_project and key in _PROJECT_DOTENV_DENIED_ENV_KEYS:
365
+ # A committed project `.env` must not set a user-level MCP trust
366
+ # decision; the global `.env` and shell may (is_project=False).
367
+ logger.debug(
368
+ "Ignoring project-denied env key %r from %s", key, dotenv_path
369
+ )
370
+ continue
371
+ os.environ[key] = value
372
+ _dotenv_loaded_values[key] = value
373
+ applied = True
374
+ return applied
375
+
376
+ # 1. Project/CWD .env — loads first so project values are set before the
377
+ # global file, which can only fill in vars not already present.
378
+ dotenv_path: Path | str | None = None
379
+ try:
380
+ if start_path is None:
381
+ found = dotenv.find_dotenv(usecwd=True)
382
+ if found:
383
+ dotenv_path = found
384
+ loaded = apply_dotenv(Path(found), is_project=True) or loaded
385
+ else:
386
+ dotenv_path = _find_dotenv_from_start_path(start_path)
387
+ if dotenv_path is not None:
388
+ loaded = apply_dotenv(dotenv_path, is_project=True) or loaded
389
+ except (OSError, ValueError):
390
+ logger.warning(
391
+ "Could not read project dotenv at %s; project env vars will not be loaded",
392
+ dotenv_path or start_path or "cwd",
393
+ exc_info=True,
394
+ )
395
+
396
+ # 2. Global (~/.deepagents/.env) — fills in any vars not already set by
397
+ # the shell or the project dotenv.
398
+ # try/except wraps both is_file() and load_dotenv() to cover the TOCTOU
399
+ # window where the file can vanish between stat and open.
400
+ try:
401
+ if _GLOBAL_DOTENV_PATH.is_file() and apply_dotenv(
402
+ _GLOBAL_DOTENV_PATH, is_project=False
403
+ ):
404
+ loaded = True
405
+ logger.debug("Loaded global dotenv: %s", _GLOBAL_DOTENV_PATH)
406
+ except (OSError, ValueError):
407
+ logger.warning(
408
+ "Could not read global dotenv at %s; global defaults will not be applied",
409
+ _GLOBAL_DOTENV_PATH,
410
+ exc_info=True,
411
+ )
412
+
413
+ return loaded
414
+
415
+
416
+ _TRACING_ENABLE_ENV_VARS = (
417
+ "LANGSMITH_TRACING_V2",
418
+ "LANGCHAIN_TRACING_V2",
419
+ "LANGSMITH_TRACING",
420
+ "LANGCHAIN_TRACING",
421
+ )
422
+ """Env vars LangChain/LangSmith read to decide whether tracing is enabled."""
423
+
424
+ _TRACING_API_KEY_ENV_VARS = ("LANGSMITH_API_KEY", "LANGCHAIN_API_KEY")
425
+ """Env vars that hold the LangSmith API key used for trace ingestion."""
426
+
427
+ _TRACING_ENDPOINT_ENV_VARS = ("LANGSMITH_ENDPOINT", "LANGCHAIN_ENDPOINT")
428
+ """Env vars that point tracing at a non-default (self-hosted/proxied) endpoint."""
429
+
430
+ LANGSMITH_US_ENDPOINT = "https://api.smith.langchain.com"
431
+ """Canonical LangSmith SaaS endpoint for the US region (the SDK default)."""
432
+
433
+ LANGSMITH_EU_ENDPOINT = "https://eu.api.smith.langchain.com"
434
+ """Canonical LangSmith SaaS endpoint for the EU region."""
435
+
436
+
437
+ def normalize_langsmith_endpoint(value: str) -> str:
438
+ """Resolve a LangSmith endpoint shorthand to its canonical URL.
439
+
440
+ Maps the case-insensitive region aliases `us`/`eu` to the LangSmith SaaS
441
+ endpoints so the CLI `--base-url` flag and the TUI `/auth` prompt share one
442
+ decode. Any other non-empty value is returned stripped and unchanged (a
443
+ self-hosted or proxied URL); empty input returns an empty string.
444
+
445
+ Args:
446
+ value: A region alias, a full endpoint URL, or an empty string.
447
+
448
+ Returns:
449
+ The canonical endpoint URL, the stripped literal value, or `""`.
450
+ """
451
+ cleaned = value.strip()
452
+ if not cleaned:
453
+ return ""
454
+ alias = cleaned.lower()
455
+ if alias == "us":
456
+ return LANGSMITH_US_ENDPOINT
457
+ if alias == "eu":
458
+ return LANGSMITH_EU_ENDPOINT
459
+ return cleaned
460
+
461
+
462
+ def is_http_url(value: str) -> bool:
463
+ """Return whether `value` is a non-empty `http`/`https` URL with a host.
464
+
465
+ Guards the LangSmith endpoint so a stored API key is never paired with a
466
+ non-HTTP, malformed, or schemeless value that could route trace ingestion
467
+ (and the key) somewhere unintended.
468
+
469
+ Args:
470
+ value: Candidate endpoint URL.
471
+
472
+ Returns:
473
+ `True` when `value` parses as an `http`/`https` URL with a network
474
+ location that contains no whitespace.
475
+ """
476
+ try:
477
+ parsed = urlparse(value)
478
+ except ValueError:
479
+ return False
480
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
481
+ return False
482
+ # A real host never contains whitespace, but `urlparse` keeps an internal
483
+ # space in the netloc (e.g. "exa mple.com"). Such a value would be stored,
484
+ # written to `LANGSMITH_ENDPOINT`, and its traces may then be dropped at
485
+ # ingest. Reject it loudly at save time.
486
+ return not any(char.isspace() for char in parsed.netloc)
487
+
488
+
489
+ _TRACING_RUNS_ENDPOINTS_ENV_VARS = (
490
+ "LANGSMITH_RUNS_ENDPOINTS",
491
+ "LANGCHAIN_RUNS_ENDPOINTS",
492
+ )
493
+ """Env vars the LangSmith SDK parses into replica trace ingestion targets."""
494
+
495
+
496
+ class _LangSmithProfileConfig(Protocol):
497
+ """Subset of LangSmith profile client config fields used at bootstrap."""
498
+
499
+ api_url: str | None
500
+ """Base URL for a custom self-hosted or proxied LangSmith endpoint."""
501
+
502
+ api_key: str | None
503
+ """API key from the active LangSmith profile."""
504
+
505
+ oauth_access_token: str | None
506
+ """OAuth access token from the active LangSmith profile."""
507
+
508
+ oauth_refresh_token: str | None
509
+ """OAuth refresh token from the active LangSmith profile."""
510
+
511
+
512
+ def _quiet_sdk_tracing_logging() -> None:
513
+ """Keep LangSmith/LangChain SDK logging from corrupting the TUI.
514
+
515
+ These SDK loggers emit ingestion/auth errors (e.g. repeated 401s) on their
516
+ own loggers. With no handler attached they reach Python's last-resort stderr
517
+ handler and bleed onto the alternate-screen TUI. Route them to the debug log
518
+ when `DEEPAGENTS_CODE_DEBUG` is set, otherwise attach a `NullHandler` so they
519
+ stay off the terminal.
520
+ """
521
+ from deepagents_code._debug import configure_debug_logging
522
+ from deepagents_code._env_vars import DEBUG, is_env_truthy
523
+
524
+ debug_enabled = is_env_truthy(DEBUG)
525
+ for name in ("langsmith", "langchain"):
526
+ sdk_logger = logging.getLogger(name)
527
+ if debug_enabled:
528
+ configure_debug_logging(sdk_logger)
529
+ if not sdk_logger.handlers:
530
+ sdk_logger.addHandler(logging.NullHandler())
531
+
532
+
533
+ def _load_langsmith_profile_config(
534
+ env: dict[str, str] | None = None,
535
+ ) -> _LangSmithProfileConfig | None:
536
+ """Return the active LangSmith profile client config, if available."""
537
+ try:
538
+ client_module = importlib.import_module("langsmith.client")
539
+ except ImportError:
540
+ return None
541
+
542
+ profiles = getattr(client_module, "_profiles", None)
543
+ if profiles is None:
544
+ return None
545
+
546
+ if env is None:
547
+ return profiles.load_profile_client_config()
548
+
549
+ from unittest.mock import patch
550
+
551
+ with patch.dict(os.environ, env, clear=True):
552
+ return profiles.load_profile_client_config()
553
+
554
+
555
+ def _has_langsmith_profile_credentials(env: dict[str, str] | None = None) -> bool:
556
+ """Return whether the LangSmith profile config has usable auth material."""
557
+ config = _load_langsmith_profile_config(env)
558
+ if config is None:
559
+ return False
560
+
561
+ return bool(
562
+ config.api_key or config.oauth_access_token or config.oauth_refresh_token
563
+ )
564
+
565
+
566
+ def _has_langsmith_profile_custom_endpoint(env: dict[str, str] | None = None) -> bool:
567
+ """Return whether the LangSmith profile points at a custom endpoint."""
568
+ config = _load_langsmith_profile_config(env)
569
+ if config is None:
570
+ return False
571
+
572
+ return bool((config.api_url or "").strip())
573
+
574
+
575
+ def _build_orphaned_tracing_disabled_notice() -> str:
576
+ """Return the user-facing notice for disabled orphaned tracing."""
577
+ base = (
578
+ "LangSmith tracing was disabled because tracing is enabled but no "
579
+ "credentials were found."
580
+ )
581
+ if shutil.which("langsmith"):
582
+ return (
583
+ f"{base} Set LANGSMITH_API_KEY or run `langsmith auth login`, "
584
+ "then restart dcode."
585
+ )
586
+ return f"{base} Set LANGSMITH_API_KEY, then restart dcode."
587
+
588
+
589
+ def consume_orphaned_tracing_disabled_notice() -> str | None:
590
+ """Return and clear the pending orphaned-tracing notice, if any."""
591
+ global _orphaned_tracing_disabled_notice # noqa: PLW0603
592
+
593
+ notice = _orphaned_tracing_disabled_notice
594
+ _orphaned_tracing_disabled_notice = None
595
+ return notice
596
+
597
+
598
+ def _tracing_enabled() -> bool:
599
+ """Whether any LangSmith/LangChain tracing flag is truthy in the environment.
600
+
601
+ Reads the canonical tracing-enable vars (`_TRACING_ENABLE_ENV_VARS`) and
602
+ classifies each present value with `classify_env_bool`, mirroring how the
603
+ LangChain/LangSmith SDKs decide whether to start tracing. Shared by
604
+ `_disable_orphaned_tracing` and `_apply_default_langsmith_project` so both
605
+ read the flags identically.
606
+
607
+ Returns:
608
+ `True` if at least one tracing flag is set to a truthy value,
609
+ else `False`.
610
+ """
611
+ from deepagents_code._env_vars import classify_env_bool
612
+
613
+ return any(
614
+ classify_env_bool(os.environ[var])
615
+ for var in _TRACING_ENABLE_ENV_VARS
616
+ if var in os.environ
617
+ )
618
+
619
+
620
+ def _disable_set_tracing_flags() -> list[str]:
621
+ """Set every configured tracing-enable flag to `false`.
622
+
623
+ Returns:
624
+ Env var names that were disabled.
625
+ """
626
+ disabled = [var for var in _TRACING_ENABLE_ENV_VARS if var in os.environ]
627
+ for var in disabled:
628
+ os.environ[var] = "false"
629
+ return disabled
630
+
631
+
632
+ def restore_user_tracing_env(env: dict[str, str]) -> None:
633
+ """Restore caller tracing flags in an environment passed to user code.
634
+
635
+ Args:
636
+ env: Environment mapping prepared for a child/user subprocess.
637
+ """
638
+ for var, value in _bootstrap_state.original_tracing_env.items():
639
+ if value is None:
640
+ env.pop(var, None)
641
+ else:
642
+ env[var] = value
643
+
644
+
645
+ def restore_user_tracing_api_keys(env: dict[str, str]) -> None:
646
+ """Restore caller tracing API keys in an environment passed to user code.
647
+
648
+ Reverts both bootstrap overwrites of the canonical LangSmith key — the
649
+ `DEEPAGENTS_CODE_`-prefixed override and the `/auth`-stored key — so shell
650
+ subprocesses receive the caller's own key rather than the agent's session
651
+ key. See `original_tracing_api_keys` for the rationale; this mirrors
652
+ `restore_user_tracing_env`, which does the same for tracing flags.
653
+
654
+ Args:
655
+ env: Environment mapping prepared for a child/user subprocess.
656
+ """
657
+ for var, value in _bootstrap_state.original_tracing_api_keys.items():
658
+ if value is None:
659
+ env.pop(var, None)
660
+ else:
661
+ env[var] = value
662
+
663
+
664
+ def _disable_orphaned_tracing() -> None:
665
+ """Disable LangSmith tracing when enabled without a usable API key.
666
+
667
+ LangChain enables tracing whenever a tracing flag is truthy, regardless of
668
+ credentials. With no env or profile key the background tracer retries
669
+ ingestion and floods `langsmith.client` 401 errors into the TUI (most visibly
670
+ at the atexit flush). When a tracing flag is set but no credentials are
671
+ resolvable, unset the flags so tracing never starts.
672
+
673
+ A custom endpoint (`LANGSMITH_ENDPOINT`/`LANGCHAIN_ENDPOINT`, or a profile
674
+ `api_url`) or replica endpoints (`LANGSMITH_RUNS_ENDPOINTS`/
675
+ `LANGCHAIN_RUNS_ENDPOINTS`) signal tracing can upload without a top-level
676
+ API key, so those explicitly configured targets are trusted and left alone.
677
+ The SDK loggers are quieted separately by `_quiet_sdk_tracing_logging`, so
678
+ any residual ingest errors stay off the TUI.
679
+ """
680
+ global _orphaned_tracing_disabled_notice # noqa: PLW0603
681
+
682
+ if not _tracing_enabled():
683
+ return
684
+
685
+ env = dict(os.environ)
686
+ has_custom_endpoint = any(
687
+ (env.get(var) or "").strip() for var in _TRACING_ENDPOINT_ENV_VARS
688
+ )
689
+ if (
690
+ has_custom_endpoint
691
+ or _has_langsmith_profile_custom_endpoint()
692
+ or _has_langsmith_runs_endpoints_from(env)
693
+ ):
694
+ return
695
+
696
+ has_key = any(
697
+ (os.environ.get(var) or "").strip() for var in _TRACING_API_KEY_ENV_VARS
698
+ )
699
+ if has_key or _has_langsmith_profile_credentials():
700
+ return
701
+
702
+ disabled = _disable_set_tracing_flags()
703
+ _orphaned_tracing_disabled_notice = _build_orphaned_tracing_disabled_notice()
704
+ logger.warning(
705
+ "LangSmith tracing is enabled (%s) but no API key is set; disabling "
706
+ "tracing to avoid repeated authentication failures. Set LANGSMITH_API_KEY "
707
+ "to enable tracing, or unset the tracing flag to silence this warning.",
708
+ ", ".join(disabled),
709
+ )
710
+
711
+
712
+ def _apply_default_langsmith_project() -> None:
713
+ """Route agent traces to the default project when none is configured.
714
+
715
+ When tracing is active but neither the prefixed override nor a base
716
+ `LANGSMITH_PROJECT` is set, ingestion would land in the SDK's `default`
717
+ project while `get_langsmith_project_name` advertises `deepagents-code`.
718
+ Set the default explicitly so the displayed/looked-up name matches where
719
+ traces are actually ingested (and `/trace` resolves once a run flushes).
720
+ """
721
+ if os.environ.get("LANGSMITH_PROJECT"):
722
+ return
723
+
724
+ if not _tracing_enabled():
725
+ return
726
+
727
+ from deepagents_code.config_manifest import LANGSMITH_PROJECT_DEFAULT
728
+
729
+ os.environ["LANGSMITH_PROJECT"] = LANGSMITH_PROJECT_DEFAULT
730
+
731
+
732
+ def apply_stored_langsmith_auth(*, replace_project: bool = False) -> None:
733
+ """Apply a `/auth`-stored LangSmith key, tracing, and redaction now.
734
+
735
+ Args:
736
+ replace_project: Whether the stored LangSmith project should replace
737
+ the current process `LANGSMITH_PROJECT`. Startup leaves this false
738
+ so an explicit environment value remains authoritative; the `/auth`
739
+ save path sets it true because the saved project is the newest user
740
+ choice for the already-running session.
741
+ """
742
+ from deepagents_code.model_config import apply_stored_service_credentials
743
+
744
+ apply_stored_service_credentials()
745
+ _apply_stored_langsmith_tracing(replace_project=replace_project)
746
+ _disable_orphaned_tracing()
747
+ _apply_default_langsmith_project()
748
+ configure_langsmith_secret_redaction()
749
+
750
+
751
+ def _apply_stored_langsmith_tracing(*, replace_project: bool = False) -> None:
752
+ """Enable tracing (and apply a custom project) for a `/auth`-stored key.
753
+
754
+ Storing a LangSmith key via `/auth` is a deliberate opt-in to tracing, but
755
+ a key alone never starts tracing — the SDK only traces when a tracing-enable
756
+ flag is truthy. So when a key is stored, turn tracing on by default.
757
+
758
+ The opt-out is intentionally non-destructive and session-scoped: an explicit
759
+ falsy tracing flag (most simply `DEEPAGENTS_CODE_LANGSMITH_TRACING=false`,
760
+ which bootstrap bridges to `LANGSMITH_TRACING`) is honored and tracing stays
761
+ off, so the stored key can be paused without deleting it. A custom stored
762
+ project is applied to `LANGSMITH_PROJECT` when the user has not set one,
763
+ unless `replace_project` is set for the immediate `/auth` save path. A stored
764
+ endpoint (e.g. the EU region) is applied to `LANGSMITH_ENDPOINT` with the
765
+ same precedence via `_apply_stored_langsmith_endpoint`.
766
+
767
+ No-op when no LangSmith key is stored, so a key supplied only through the
768
+ environment keeps the prior behavior (tracing stays off unless a flag is
769
+ set).
770
+
771
+ A stored key is trusted by *presence*, not validity: this never pings
772
+ LangSmith (a network round-trip at startup would fight the package's
773
+ startup-perf budget). So a stored-but-invalid key (typo'd, revoked, or for
774
+ the wrong workspace) still force-enables tracing, and its traces are then
775
+ silently dropped at ingest with only SDK-internal 401s — which
776
+ `_quiet_sdk_tracing_logging` routes away from the TUI. `_disable_orphaned_tracing`
777
+ and `consume_orphaned_tracing_disabled_notice` guard only the *absent*-key
778
+ case, not the invalid-key case. If traces never appear, the key is the first
779
+ thing to re-check via `/auth`.
780
+
781
+ The store is read exactly once: a single corrupt-file `RuntimeError` is
782
+ logged and treated as "no stored key" rather than being raised (bootstrap
783
+ must never crash the app) or partially applied.
784
+ """
785
+ from deepagents_code import auth_store
786
+ from deepagents_code._env_vars import classify_env_bool
787
+ from deepagents_code.model_config import LANGSMITH_SERVICE
788
+
789
+ try:
790
+ creds = auth_store.load_credentials()
791
+ except RuntimeError:
792
+ logger.warning(
793
+ "Could not read the stored LangSmith credential; the credential file "
794
+ "may be corrupt. Re-add the key via /auth."
795
+ )
796
+ return
797
+ entry = creds.get(LANGSMITH_SERVICE)
798
+ # No-op unless a LangSmith API key was stored via `/auth`. A key supplied
799
+ # only through the environment never lands here, keeping its prior behavior
800
+ # (tracing stays off unless a flag is set).
801
+ if entry is None or entry["type"] != "api_key" or not entry["key"]:
802
+ return
803
+ if _stored_langsmith_key_is_suppressed(entry["key"]):
804
+ return
805
+
806
+ # The key was bridged onto LANGSMITH_API_KEY by
807
+ # `apply_stored_service_credentials`. Decide whether to enable tracing.
808
+ flags = [
809
+ classify_env_bool(os.environ[var])
810
+ for var in _TRACING_ENABLE_ENV_VARS
811
+ if var in os.environ
812
+ ]
813
+ if any(flag is False for flag in flags):
814
+ # Explicit, deliberate opt-out — keep the key but make the opt-out
815
+ # authoritative over sibling SDK tracing flags.
816
+ _disable_set_tracing_flags()
817
+ return
818
+ if not any(flag is True for flag in flags):
819
+ os.environ["LANGSMITH_TRACING"] = "true"
820
+
821
+ _apply_stored_langsmith_endpoint(
822
+ entry.get("base_url") or None, replace=replace_project
823
+ )
824
+
825
+ project = entry.get("project") or None
826
+ if replace_project:
827
+ if project:
828
+ os.environ["LANGSMITH_PROJECT"] = project
829
+ else:
830
+ os.environ.pop("LANGSMITH_PROJECT", None)
831
+ return
832
+ if project and not os.environ.get("LANGSMITH_PROJECT"):
833
+ os.environ["LANGSMITH_PROJECT"] = project
834
+
835
+
836
+ def _stored_langsmith_key_is_suppressed(stored_key: str) -> bool:
837
+ """Return whether an env override keeps `stored_key` from taking effect."""
838
+ prefixed_names = [f"DEEPAGENTS_CODE_{name}" for name in _TRACING_API_KEY_ENV_VARS]
839
+ prefixed_values = [
840
+ os.environ.get(name) or None for name in prefixed_names if name in os.environ
841
+ ]
842
+ if prefixed_values:
843
+ return any(value != stored_key for value in prefixed_values)
844
+ env_key = os.environ.get("LANGSMITH_API_KEY")
845
+ return bool(env_key and env_key != stored_key)
846
+
847
+
848
+ def _apply_stored_langsmith_endpoint(endpoint: str | None, *, replace: bool) -> None:
849
+ """Apply a `/auth`-stored LangSmith endpoint to `LANGSMITH_ENDPOINT`.
850
+
851
+ Writes a stored endpoint to the canonical `LANGSMITH_ENDPOINT` and clears the
852
+ `LANGCHAIN_ENDPOINT` alternate so the SDK can't read a stale value through it.
853
+ Precedence mirrors the stored project:
854
+
855
+ - `replace` (the immediate `/auth` save): the stored endpoint replaces the
856
+ current value, and a blank endpoint (the US default) clears both names so
857
+ ingestion falls back to the LangSmith SaaS default.
858
+ - Startup (`replace=False`): a non-empty `LANGSMITH_ENDPOINT`/`LANGCHAIN_ENDPOINT`
859
+ already in the environment stays authoritative, so a stored endpoint is
860
+ applied only when neither is set. A stored credential without an endpoint
861
+ never clears an existing env value (self-hosted setups keep working).
862
+
863
+ Like a stored key, a stored endpoint is trusted by *presence*, not
864
+ reachability: this never connects to it. A wrong-but-well-formed endpoint (a
865
+ typo'd or dead host) is applied anyway, and its traces may then be dropped at
866
+ ingest. `is_http_url` rejects the obviously malformed cases at save time, but
867
+ if traces never appear the stored endpoint is worth re-checking via `/auth`
868
+ alongside the key.
869
+
870
+ Args:
871
+ endpoint: The stored endpoint URL, or `None` when none is stored.
872
+ replace: Whether the stored value should overwrite the current
873
+ environment (the immediate `/auth` save path).
874
+ """
875
+ canonical, alternate = _TRACING_ENDPOINT_ENV_VARS
876
+ if replace:
877
+ if endpoint:
878
+ os.environ[canonical] = endpoint
879
+ else:
880
+ os.environ.pop(canonical, None)
881
+ os.environ.pop(alternate, None)
882
+ return
883
+ if not endpoint:
884
+ return
885
+ if any(os.environ.get(var) for var in _TRACING_ENDPOINT_ENV_VARS):
886
+ return
887
+ os.environ[canonical] = endpoint
888
+ # Past the guard above both endpoint vars are falsy, so this only clears an
889
+ # empty-string `LANGCHAIN_ENDPOINT`; it keeps canonical as the one name the
890
+ # SDK reads and mirrors the `replace` branch's alternate-clearing.
891
+ os.environ.pop(alternate, None)
892
+
893
+
894
+ def _ensure_bootstrap() -> None:
895
+ """Run one-time bootstrap: dotenv loading and `LANGSMITH_PROJECT` override.
896
+
897
+ Idempotent and thread-safe — subsequent calls are no-ops. Called
898
+ automatically by `_get_settings()` when `settings` is first accessed.
899
+
900
+ The flag is set in `finally` so that partial failures (e.g. a
901
+ malformed `.env`) still mark bootstrap as done — preventing infinite retry
902
+ loops. Exceptions are caught and logged at ERROR level; the app proceeds
903
+ with the environment as-is.
904
+ """
905
+ if _bootstrap_state.done:
906
+ return
907
+
908
+ with _bootstrap_lock:
909
+ if _bootstrap_state.done: # double-check after acquiring lock
910
+ return
911
+
912
+ try:
913
+ from deepagents_code.project_utils import (
914
+ get_server_project_context as _get_server_project_context,
915
+ )
916
+
917
+ ctx = _get_server_project_context()
918
+ _bootstrap_state.start_path = ctx.user_cwd if ctx else None
919
+ _load_dotenv(start_path=_bootstrap_state.start_path)
920
+
921
+ # `configure_debug_logging` already ran at import, before the `.env`
922
+ # above was loaded. Re-run it so a `DEEPAGENTS_CODE_DEBUG` set only in
923
+ # `.env` installs the file handler now (idempotent for the same path),
924
+ # ensuring later failures are actually written to the debug log.
925
+ from deepagents_code._debug import configure_debug_logging
926
+
927
+ configure_debug_logging(logging.getLogger("deepagents_code"))
928
+
929
+ # Keep LangSmith/LangChain SDK logging off the TUI (route to the
930
+ # debug log when enabled, else swallow via NullHandler).
931
+ _quiet_sdk_tracing_logging()
932
+
933
+ # Capture AFTER dotenv loading so .env-only values are visible,
934
+ # but BEFORE the override below replaces it.
935
+ _bootstrap_state.original_langsmith_project = os.environ.get(
936
+ "LANGSMITH_PROJECT"
937
+ )
938
+ _bootstrap_state.original_tracing_env = {
939
+ var: os.environ.get(var) for var in _TRACING_ENABLE_ENV_VARS
940
+ }
941
+ _bootstrap_state.original_tracing_api_keys = {
942
+ var: os.environ.get(var) for var in _TRACING_API_KEY_ENV_VARS
943
+ }
944
+
945
+ # CRITICAL: Override LANGSMITH_PROJECT to route agent traces to a
946
+ # separate project. LangSmith reads LANGSMITH_PROJECT at invocation
947
+ # time, so we override it here and preserve the user's original
948
+ # value for shell commands.
949
+ from deepagents_code._env_vars import LANGSMITH_PROJECT
950
+
951
+ deepagents_project = os.environ.get(LANGSMITH_PROJECT)
952
+ if deepagents_project:
953
+ os.environ["LANGSMITH_PROJECT"] = deepagents_project
954
+
955
+ # Propagate prefixed LangSmith env vars to canonical names.
956
+ # The app resolves prefixed vars via resolve_env_var(), but the
957
+ # LangSmith SDK reads os.environ directly and has no knowledge
958
+ # of the DEEPAGENTS_CODE_ prefix. Setting canonical vars here
959
+ # bridges that gap.
960
+ from deepagents_code._env_vars import SUPPRESS_ENV_OVERRIDE_WARNING
961
+ from deepagents_code.model_config import _ENV_PREFIX
962
+
963
+ suppress_override_warning = is_env_truthy(SUPPRESS_ENV_OVERRIDE_WARNING)
964
+
965
+ for canonical in (
966
+ "LANGSMITH_API_KEY",
967
+ "LANGCHAIN_API_KEY",
968
+ "LANGSMITH_TRACING",
969
+ "LANGCHAIN_TRACING_V2",
970
+ ):
971
+ prefixed = f"{_ENV_PREFIX}{canonical}"
972
+ if prefixed not in os.environ:
973
+ continue
974
+ prefixed_val = os.environ[prefixed]
975
+ if canonical not in os.environ:
976
+ # Propagate (including empty string for explicit disable).
977
+ os.environ[canonical] = prefixed_val
978
+ elif os.environ[canonical] != prefixed_val:
979
+ os.environ[canonical] = prefixed_val
980
+ if not suppress_override_warning:
981
+ logger.warning(
982
+ "%s and %s are both set to different values. Deep "
983
+ "Agents Code uses %s for this session (the "
984
+ "%s-prefixed value takes precedence). The %s you "
985
+ "exported in your own shell is unaffected. This is "
986
+ "expected. To silence this warning, unset %s or set "
987
+ "%s=1.",
988
+ canonical,
989
+ prefixed,
990
+ prefixed,
991
+ _ENV_PREFIX,
992
+ canonical,
993
+ canonical,
994
+ SUPPRESS_ENV_OVERRIDE_WARNING,
995
+ )
996
+
997
+ # Bridge stored service keys, apply stored LangSmith tracing defaults,
998
+ # disable orphaned tracing, and route active tracing to the displayed
999
+ # project. Keeping this in one helper lets `/auth` save apply the same
1000
+ # state immediately inside an already-running TUI session.
1001
+ apply_stored_langsmith_auth()
1002
+ except Exception:
1003
+ logger.exception(
1004
+ "Bootstrap failed; .env values and LANGSMITH_PROJECT override "
1005
+ "may be missing. The app will proceed with environment as-is.",
1006
+ )
1007
+ finally:
1008
+ _bootstrap_state.done = True
1009
+
1010
+
1011
+ if TYPE_CHECKING:
1012
+ from langchain_core.language_models import BaseChatModel
1013
+ from langchain_core.runnables import RunnableConfig
1014
+ from rich.console import Console
1015
+
1016
+ from deepagents_code._git import RepositoryMetadata
1017
+ from deepagents_code.model_config import ModelConfig
1018
+
1019
+ # Static type stubs for lazy module attributes resolved by __getattr__.
1020
+ # At runtime these are created on first access by _get_settings() /
1021
+ # _get_console() and cached in globals().
1022
+ settings: Settings
1023
+ console: Console
1024
+
1025
+ MODE_PREFIXES: dict[str, str] = {
1026
+ "shell_incognito": "!!",
1027
+ "shell": "!",
1028
+ "command": "/",
1029
+ }
1030
+ """Maps each non-normal mode to its trigger character."""
1031
+
1032
+ MODE_DISPLAY_GLYPHS: dict[str, str] = {
1033
+ "shell_incognito": "$",
1034
+ "shell": "$",
1035
+ "command": "/",
1036
+ }
1037
+ """Maps each non-normal mode to its display glyph shown in the prompt/UI."""
1038
+
1039
+ if MODE_PREFIXES.keys() != MODE_DISPLAY_GLYPHS.keys():
1040
+ _only_prefixes = MODE_PREFIXES.keys() - MODE_DISPLAY_GLYPHS.keys()
1041
+ _only_glyphs = MODE_DISPLAY_GLYPHS.keys() - MODE_PREFIXES.keys()
1042
+ msg = (
1043
+ "MODE_PREFIXES and MODE_DISPLAY_GLYPHS have mismatched keys: "
1044
+ f"only in PREFIXES={_only_prefixes}, only in GLYPHS={_only_glyphs}"
1045
+ )
1046
+ raise ValueError(msg)
1047
+
1048
+ _MODE_PREFIXES_BY_LENGTH: tuple[tuple[str, str], ...] = tuple(
1049
+ sorted(MODE_PREFIXES.items(), key=lambda item: len(item[1]), reverse=True)
1050
+ )
1051
+ """Mode entries ordered longest-prefix-first.
1052
+
1053
+ Pre-sorted at import so `detect_mode_prefix` runs in constant time per
1054
+ keystroke without re-sorting.
1055
+ """
1056
+
1057
+
1058
+ def detect_mode_prefix(text: str) -> tuple[str, str] | None:
1059
+ """Return the longest mode prefix and mode for `text`, if any.
1060
+
1061
+ Longer prefixes win so multi-character triggers like `!!` are matched
1062
+ before their single-character prefixes (`!`).
1063
+
1064
+ Args:
1065
+ text: Input text that may start with a mode trigger.
1066
+
1067
+ Returns:
1068
+ Tuple of `(prefix, mode)` for the longest matching trigger, otherwise
1069
+ `None`.
1070
+ """
1071
+ for mode, prefix in _MODE_PREFIXES_BY_LENGTH:
1072
+ if text.startswith(prefix):
1073
+ return prefix, mode
1074
+ return None
1075
+
1076
+
1077
+ class CharsetMode(StrEnum):
1078
+ """Character set mode for TUI display."""
1079
+
1080
+ UNICODE = "unicode"
1081
+ """Always use Unicode glyphs (e.g. `⏺`, `✓`, `…`)."""
1082
+
1083
+ ASCII = "ascii"
1084
+ """Always use ASCII-safe fallbacks (e.g. `(*)`, `[OK]`, `...`)."""
1085
+
1086
+ AUTO = "auto"
1087
+ """Detect charset support at runtime and pick Unicode or ASCII."""
1088
+
1089
+
1090
+ @dataclass(frozen=True)
1091
+ class Glyphs:
1092
+ """Character glyphs for TUI display."""
1093
+
1094
+ tool_prefix: str # ⏺ vs (*)
1095
+ ellipsis: str # … vs ...
1096
+ checkmark: str # ✓ vs [OK]
1097
+ error: str # ✗ vs [X]
1098
+ circle_empty: str # ○ vs [ ]
1099
+ circle_filled: str # ● vs [*]
1100
+ output_prefix: str # ⎿ vs L
1101
+ spinner_frames: tuple[str, ...] # Braille vs ASCII spinner
1102
+ pause: str # ⏸ vs ||
1103
+ newline: str # ⏎ vs \\n
1104
+ warning: str # ⚠ vs [!]
1105
+ question: str # ? vs [?]
1106
+ hourglass: str # ⏳ vs [~]
1107
+ retry: str # ↻ vs [R]
1108
+ arrow_up: str # up arrow vs ^
1109
+ arrow_down: str # down arrow vs v
1110
+ bullet: str # bullet vs -
1111
+ cursor: str # cursor vs >
1112
+ disclosure_collapsed: str # ▸ vs >
1113
+ disclosure_expanded: str # ▾ vs v
1114
+
1115
+ # Box-drawing characters
1116
+ box_vertical: str # │ vs |
1117
+ box_horizontal: str # ─ vs -
1118
+ box_double_horizontal: str # ═ vs =
1119
+
1120
+ # Diff-specific
1121
+ gutter_bar: str # ▌ vs |
1122
+
1123
+ # Status bar
1124
+ git_branch: str # "↗" vs "git:"
1125
+
1126
+
1127
+ UNICODE_GLYPHS = Glyphs(
1128
+ tool_prefix="⏺",
1129
+ ellipsis="…",
1130
+ checkmark="✓",
1131
+ error="✗",
1132
+ circle_empty="○",
1133
+ circle_filled="●",
1134
+ output_prefix="⎿",
1135
+ spinner_frames=("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"),
1136
+ pause="⏸",
1137
+ newline="⏎",
1138
+ warning="⚠",
1139
+ question="?",
1140
+ hourglass="⏳",
1141
+ retry="↻",
1142
+ arrow_up="↑",
1143
+ arrow_down="↓",
1144
+ bullet="•",
1145
+ cursor="›", # noqa: RUF001 # Intentional Unicode glyph
1146
+ disclosure_collapsed="▸",
1147
+ disclosure_expanded="▾",
1148
+ # Box-drawing characters
1149
+ box_vertical="│",
1150
+ box_horizontal="─",
1151
+ box_double_horizontal="═",
1152
+ gutter_bar="▌",
1153
+ git_branch="↗",
1154
+ )
1155
+ """Glyph set for terminals with full Unicode support."""
1156
+
1157
+ ASCII_GLYPHS = Glyphs(
1158
+ tool_prefix="(*)",
1159
+ ellipsis="...",
1160
+ checkmark="[OK]",
1161
+ error="[X]",
1162
+ circle_empty="[ ]",
1163
+ circle_filled="[*]",
1164
+ output_prefix="L",
1165
+ spinner_frames=("(-)", "(\\)", "(|)", "(/)"),
1166
+ pause="||",
1167
+ newline="\\n",
1168
+ warning="[!]",
1169
+ question="[?]",
1170
+ hourglass="[~]",
1171
+ retry="[R]",
1172
+ arrow_up="^",
1173
+ arrow_down="v",
1174
+ bullet="-",
1175
+ cursor=">",
1176
+ disclosure_collapsed=">",
1177
+ disclosure_expanded="v",
1178
+ # Box-drawing characters
1179
+ box_vertical="|",
1180
+ box_horizontal="-",
1181
+ box_double_horizontal="=",
1182
+ gutter_bar="|",
1183
+ git_branch="git:",
1184
+ )
1185
+ """Glyph set for terminals limited to 7-bit ASCII."""
1186
+
1187
+ _glyphs_cache: Glyphs | None = None
1188
+ """Module-level cache for detected glyphs."""
1189
+
1190
+ _charset_mode_cache: CharsetMode | None = None
1191
+ """Module-level cache for the detected charset mode."""
1192
+
1193
+ _editable_cache: tuple[bool, str | None] | None = None
1194
+ """Module-level cache for editable install info: (is_editable, source_path)."""
1195
+
1196
+ _langsmith_url_cache: tuple[str, str] | None = None
1197
+ """Module-level cache for successful LangSmith project URL lookups."""
1198
+
1199
+ _LANGSMITH_URL_LOOKUP_TIMEOUT_SECONDS = 2.0
1200
+ """Max seconds to wait for LangSmith project URL lookup.
1201
+
1202
+ Kept short so tracing metadata can never stall app flows.
1203
+ """
1204
+
1205
+
1206
+ def _get_deepagents_version() -> str | None:
1207
+ """Resolve the installed Deep Agents SDK version for diagnostics.
1208
+
1209
+ Editable installs can leave package metadata behind the source checkout, so
1210
+ this uses the shared resolver that prefers the editable source version and
1211
+ falls back to metadata when needed.
1212
+
1213
+ Returns:
1214
+ The resolved Deep Agents SDK version, or `None` when unavailable.
1215
+ """
1216
+ # Imported lazily on purpose: `extras_info` pulls in `packaging`, which we
1217
+ # keep off `config`'s module-import path (the startup hot path). Do not
1218
+ # hoist this to the top of the module. The import is also guarded so a
1219
+ # broken/absent `packaging` can never crash best-effort diagnostic metadata.
1220
+ try:
1221
+ from deepagents_code.extras_info import resolve_sdk_version
1222
+
1223
+ sdk_version, status = resolve_sdk_version()
1224
+ except ImportError:
1225
+ logger.warning(
1226
+ "Could not import resolve_sdk_version for SDK version metadata",
1227
+ exc_info=True,
1228
+ )
1229
+ return None
1230
+ return sdk_version if status == "resolved" else None
1231
+
1232
+
1233
+ def _format_lc_version(base_version: str, *, editable: bool) -> str:
1234
+ """Format an `lc_versions` value with editable-install context.
1235
+
1236
+ Args:
1237
+ base_version: The base version string.
1238
+ editable: Whether the distribution is installed in editable mode.
1239
+
1240
+ Returns:
1241
+ The version string, suffixed with ` (editable)` when `editable`.
1242
+ """
1243
+ return f"{base_version} (editable)" if editable else base_version
1244
+
1245
+
1246
+ def _resolve_editable_info() -> tuple[bool, str | None]:
1247
+ """Parse PEP 610 `direct_url.json` once and cache both results.
1248
+
1249
+ Returns:
1250
+ Tuple of (is_editable, contracted_source_path). The path is
1251
+ `~`-contracted when it falls under the user's home directory, or
1252
+ `None` when the install is non-editable or the path is unavailable.
1253
+ """
1254
+ global _editable_cache # noqa: PLW0603 # Module-level cache requires global statement
1255
+ if _editable_cache is not None:
1256
+ return _editable_cache
1257
+
1258
+ editable = False
1259
+ path: str | None = None
1260
+
1261
+ try:
1262
+ dist = distribution(DISTRIBUTION_NAME)
1263
+ raw = dist.read_text("direct_url.json")
1264
+ if raw:
1265
+ data = json.loads(raw)
1266
+ editable = data.get("dir_info", {}).get("editable", False)
1267
+ if editable:
1268
+ url = data.get("url", "")
1269
+ if url.startswith("file://"):
1270
+ path = unquote(urlparse(url).path)
1271
+ home = str(Path.home())
1272
+ if path.startswith(home):
1273
+ path = "~" + path[len(home) :]
1274
+ except (PackageNotFoundError, FileNotFoundError, json.JSONDecodeError, TypeError):
1275
+ logger.debug(
1276
+ "Failed to read editable install info from PEP 610 metadata",
1277
+ exc_info=True,
1278
+ )
1279
+
1280
+ _editable_cache = (editable, path)
1281
+ return _editable_cache
1282
+
1283
+
1284
+ def _is_editable_install() -> bool:
1285
+ """Check if deepagents-code is installed in editable mode.
1286
+
1287
+ Uses PEP 610 `direct_url.json` metadata to detect editable installs.
1288
+
1289
+ Returns:
1290
+ `True` if installed in editable mode, `False` otherwise.
1291
+ """
1292
+ return _resolve_editable_info()[0]
1293
+
1294
+
1295
+ def _get_editable_install_path() -> str | None:
1296
+ """Return the `~`-contracted source directory for an editable install.
1297
+
1298
+ Returns `None` for non-editable installs or when the path cannot be
1299
+ determined.
1300
+ """
1301
+ return _resolve_editable_info()[1]
1302
+
1303
+
1304
+ def _detect_charset_mode() -> CharsetMode:
1305
+ """Auto-detect terminal charset capabilities (cached for the process).
1306
+
1307
+ Returns:
1308
+ The detected CharsetMode based on environment and terminal encoding.
1309
+ """
1310
+ global _charset_mode_cache # noqa: PLW0603 # Module-level cache requires global statement
1311
+ if _charset_mode_cache is not None:
1312
+ return _charset_mode_cache
1313
+ _charset_mode_cache = _compute_charset_mode()
1314
+ return _charset_mode_cache
1315
+
1316
+
1317
+ def _compute_charset_mode() -> CharsetMode:
1318
+ """Compute terminal charset capabilities from environment and encoding.
1319
+
1320
+ Returns:
1321
+ The detected CharsetMode based on environment and terminal encoding.
1322
+ """
1323
+ from deepagents_code.model_config import resolve_env_var
1324
+
1325
+ env_mode = (resolve_env_var("UI_CHARSET_MODE") or "auto").lower()
1326
+ if env_mode == "unicode":
1327
+ return CharsetMode.UNICODE
1328
+ if env_mode == "ascii":
1329
+ return CharsetMode.ASCII
1330
+
1331
+ # Auto: check stdout encoding and LANG
1332
+ encoding = getattr(sys.stdout, "encoding", "") or ""
1333
+ if "utf" in encoding.lower():
1334
+ return CharsetMode.UNICODE
1335
+ lang = os.environ.get("LANG", "") or os.environ.get("LC_ALL", "")
1336
+ if "utf" in lang.lower():
1337
+ return CharsetMode.UNICODE
1338
+ return CharsetMode.ASCII
1339
+
1340
+
1341
+ def get_glyphs() -> Glyphs:
1342
+ """Get the glyph set for the current charset mode.
1343
+
1344
+ Returns:
1345
+ The appropriate Glyphs instance based on charset mode detection.
1346
+ """
1347
+ global _glyphs_cache # noqa: PLW0603 # Module-level cache requires global statement
1348
+ if _glyphs_cache is not None:
1349
+ return _glyphs_cache
1350
+
1351
+ mode = _detect_charset_mode()
1352
+ _glyphs_cache = ASCII_GLYPHS if mode == CharsetMode.ASCII else UNICODE_GLYPHS
1353
+ return _glyphs_cache
1354
+
1355
+
1356
+ def reset_glyphs_cache() -> None:
1357
+ """Reset the glyphs and charset-mode caches (for testing)."""
1358
+ global _glyphs_cache, _charset_mode_cache # noqa: PLW0603 # Module-level caches require global statement
1359
+ _glyphs_cache = None
1360
+ _charset_mode_cache = None
1361
+
1362
+
1363
+ def is_ascii_mode() -> bool:
1364
+ """Check whether the terminal is in ASCII charset mode.
1365
+
1366
+ Convenience wrapper so widgets can branch on charset without importing
1367
+ both `_detect_charset_mode` and `CharsetMode`.
1368
+
1369
+ Returns:
1370
+ `True` when the detected charset mode is ASCII.
1371
+ """
1372
+ return _detect_charset_mode() == CharsetMode.ASCII
1373
+
1374
+
1375
+ def newline_shortcut() -> str:
1376
+ """Return the terminal-appropriate label for the newline keyboard shortcut.
1377
+
1378
+ Prefers `Shift+Enter` when the terminal is known to support the kitty
1379
+ keyboard protocol, either via conservative terminal-identity heuristics
1380
+ or the `DEEPAGENTS_CODE_KITTY_KEYBOARD` override. Falls back to
1381
+ `Option+Enter` on macOS and `Ctrl+J` elsewhere — both survive legacy
1382
+ terminals that strip the shift modifier from `Enter`.
1383
+
1384
+ Returns:
1385
+ A human-readable shortcut string,
1386
+ e.g. `'Shift+Enter'`, `'Option+Enter'`, or `'Ctrl+J'`.
1387
+ """
1388
+ from deepagents_code.terminal_capabilities import supports_kitty_keyboard_protocol
1389
+
1390
+ if supports_kitty_keyboard_protocol():
1391
+ return "Shift+Enter"
1392
+ return "Option+Enter" if sys.platform == "darwin" else "Ctrl+J"
1393
+
1394
+
1395
+ _UNICODE_BANNER = f"""
1396
+ ▀██ ██ ██
1397
+ ▄▄▄▄▄▄ ██ ▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄ ▄▄▄
1398
+ ▀ ▄█▀ ██▀ ██ ▀▀ ▄██ ▄█ ▀█▄ ██ ██ ▀▀ ▄██ ██ ██
1399
+ ▄█▀ ██ ██ ▄█▀ ██ ██ ██ ██ ██ ▄█▀ ██ ██ ██
1400
+ ██▄▄▄▄█ ▄██▄ ██▄ ▀█▄▄▀█▀ ▀█▄▄█▀ ██ ▄██▄ ▀█▄▄▀█▀ ▄██▄ ██▄
1401
+ ▄▄ █▀
1402
+ ▀▀
1403
+ ██████████████████████████████████████████
1404
+ █████████ ███████████████████████████ ██
1405
+ ██ ██ ██████ ██ ████ █████████
1406
+ █ █████ ████ ██ ██ ██ ██ █
1407
+ ███ ██ ██ ██ ██ █ ███ ██ █
1408
+ █████ █ ███ █ ██ █ ███ ██ █
1409
+ █ ██ ███ ███ ███ █ █ █
1410
+ ██████████████████████████████████████████ v{__version__}
1411
+ """
1412
+ _ASCII_BANNER = rf"""
1413
+ _
1414
+ __| | ___ ___ _ __ __ _ __ _ ___ _ __ | |_ ___
1415
+ / _` |/ _ \/ _ \ '_ \ / _` |/ _` |/ _ \ '_ \| __/ __|
1416
+ | (_| | __/ __/ |_) | (_| | (_| | __/ | | | |_\__ \
1417
+ \__,_|\___|\___| .__/ \__,_|\__, |\___|_| |_|\__|___/
1418
+ |_| |___/
1419
+ ============================================== v{__version__}
1420
+ """
1421
+
1422
+
1423
+ def get_banner() -> str:
1424
+ """Get the appropriate banner for the current charset mode.
1425
+
1426
+ Returns:
1427
+ The text art banner string (Unicode or ASCII based on charset mode).
1428
+
1429
+ Includes "(local)" suffix when installed in editable mode.
1430
+ """
1431
+ if _detect_charset_mode() == CharsetMode.ASCII:
1432
+ banner = _ASCII_BANNER
1433
+ else:
1434
+ banner = _UNICODE_BANNER
1435
+
1436
+ if is_env_truthy(HIDE_SPLASH_VERSION):
1437
+ return banner.replace(f"v{__version__}", "")
1438
+
1439
+ if _is_editable_install():
1440
+ banner = banner.replace(f"v{__version__}", f"v{__version__} (local)")
1441
+
1442
+ return banner
1443
+
1444
+
1445
+ MAX_ARG_LENGTH = 150
1446
+ """Character limit for tool argument values in the UI.
1447
+
1448
+ Longer values are truncated with an ellipsis by `truncate_value`
1449
+ in `tool_display`.
1450
+ """
1451
+
1452
+ config: RunnableConfig = {
1453
+ "recursion_limit": 1000,
1454
+ }
1455
+ """Default LangGraph runnable config.
1456
+
1457
+ Sets `recursion_limit` to 1000 to accommodate deeply nested agent graphs without
1458
+ hitting the default LangGraph ceiling.
1459
+ """
1460
+
1461
+ _git_branch_cache: dict[str, str | None] = {}
1462
+ """Per-cwd cache of resolved git branch names.
1463
+
1464
+ Avoids repeated git branch resolution within the same session. Keyed by
1465
+ `str(Path.cwd())`; `None` values indicate the directory is not inside a git
1466
+ repository or that resolution failed.
1467
+ """
1468
+
1469
+
1470
+ def _get_git_branch() -> str | None:
1471
+ """Return the current git branch name, or `None` if not in a repo."""
1472
+ try:
1473
+ cwd = str(Path.cwd())
1474
+ except OSError:
1475
+ logger.debug("Could not determine cwd for git branch lookup", exc_info=True)
1476
+ return None
1477
+ if cwd in _git_branch_cache:
1478
+ return _git_branch_cache[cwd]
1479
+
1480
+ try:
1481
+ branch = resolve_git_branch(cwd) or None
1482
+ except OSError:
1483
+ logger.debug("Could not determine git branch", exc_info=True)
1484
+ branch = None
1485
+
1486
+ _git_branch_cache[cwd] = branch
1487
+ return branch
1488
+
1489
+
1490
+ _repo_metadata_cache: dict[str, RepositoryMetadata | None] = {}
1491
+ """Per-cwd cache of resolved repository metadata."""
1492
+
1493
+
1494
+ def _get_git_commit_sha() -> str | None:
1495
+ """Return the current `HEAD` commit SHA, or `None` if unavailable.
1496
+
1497
+ Resolved fresh on every call (unlike the branch/repo lookups): `HEAD` moves
1498
+ whenever the agent or user commits, checks out, or resets within a session,
1499
+ and each turn's trace must record the commit that was current for that turn.
1500
+ """
1501
+ from deepagents_code._git import resolve_git_commit_sha
1502
+
1503
+ try:
1504
+ cwd = str(Path.cwd())
1505
+ except OSError:
1506
+ logger.debug("Could not determine cwd for git commit lookup", exc_info=True)
1507
+ return None
1508
+
1509
+ try:
1510
+ return resolve_git_commit_sha(cwd) or None
1511
+ except OSError:
1512
+ logger.debug("Could not determine git commit", exc_info=True)
1513
+ return None
1514
+
1515
+
1516
+ def _get_repository_metadata() -> RepositoryMetadata | None:
1517
+ """Return parsed `origin` repository metadata, or `None`."""
1518
+ from deepagents_code._git import parse_repository_metadata, resolve_git_remote_url
1519
+
1520
+ try:
1521
+ cwd = str(Path.cwd())
1522
+ except OSError:
1523
+ logger.debug("Could not determine cwd for git remote lookup", exc_info=True)
1524
+ return None
1525
+ if cwd in _repo_metadata_cache:
1526
+ return _repo_metadata_cache[cwd]
1527
+
1528
+ repo: RepositoryMetadata | None = None
1529
+ try:
1530
+ remote_url = resolve_git_remote_url(cwd)
1531
+ if remote_url:
1532
+ repo = parse_repository_metadata(remote_url)
1533
+ except OSError:
1534
+ logger.debug("Could not determine git remote", exc_info=True)
1535
+
1536
+ _repo_metadata_cache[cwd] = repo
1537
+ return repo
1538
+
1539
+
1540
+ # coding-agent-v1 contract literals. See `build_coding_agent_metadata`.
1541
+ CODING_AGENT_PURPOSE = "coding"
1542
+ """Fixed `ls_agent_purpose` literal identifying the coding-agent trace class."""
1543
+
1544
+ CODING_AGENT_INTEGRATION = "deepagents-code"
1545
+ """Stable `ls_integration` id for this plugin (unchanged for backward-compat)."""
1546
+
1547
+ CODING_AGENT_RUNTIME = "Deep Agents Code"
1548
+ """User-facing `ls_agent_runtime` name."""
1549
+
1550
+ CODING_AGENT_TRACE_SCHEMA_VERSION = "coding-agent-v1"
1551
+ """Version of the coding-agent trace-metadata contract this build emits."""
1552
+
1553
+
1554
+ def build_coding_agent_metadata(
1555
+ *,
1556
+ thread_id: str,
1557
+ turn_id: str | None,
1558
+ turn_number: int | None,
1559
+ cwd: str,
1560
+ git_branch: str | None,
1561
+ sandbox_type: str | None,
1562
+ user_id: str | None,
1563
+ ) -> dict[str, Any]:
1564
+ """Build the shared coding-agent-v1 trace-metadata block.
1565
+
1566
+ Implements the `coding-agent-v1` contract for Deep Agents Code:
1567
+ one helper that stamps the identity block, plugin/runtime versions, turn
1568
+ markers, and repo/git/cwd attribution. The six identity/version keys and
1569
+ `thread_id` are always present; the optional keys whose value is unknown are
1570
+ omitted (per the contract), so callers can pass `None` for any of them.
1571
+
1572
+ Because Deep Agents Code is itself the runtime — there is no separate CLI
1573
+ package — `ls_integration_version` and `ls_agent_runtime_version` both come
1574
+ from the `deepagents-code` package version (`__version__`). The underlying
1575
+ `deepagents` SDK version is surfaced separately as
1576
+ `dcode_client_deepagents_version` by `build_stream_config`.
1577
+
1578
+ Scope-restricted contract keys are intentionally NOT produced here:
1579
+ `approval_policy` (root/interrupted only) and `ls_subagent_id` /
1580
+ `ls_subagent_type` (subagent only). This metadata propagates trace-wide
1581
+ through the LangGraph stream config (and, for subagents, the per-key config
1582
+ merge of langgraph#7926 / deepagents#3634), so any key placed here lands on
1583
+ every descendant run. Emitting a run-type-scoped key would therefore leak it
1584
+ onto run types outside its contract `appliesTo` set — a hard validator
1585
+ failure — and the LangGraph runtime exposes no clean per-run-type metadata
1586
+ seam to scope them. See `build_stream_config` for the full rationale.
1587
+
1588
+ Args:
1589
+ thread_id: Stable conversation id; also set as top-level `thread_id`.
1590
+ turn_id: Per-turn id (uuid4 / message id), or `None`.
1591
+ turn_number: 1-based per-thread turn index, or `None`.
1592
+ cwd: Current working directory, or empty string when unavailable.
1593
+ git_branch: Current branch name, or `None`.
1594
+ sandbox_type: Sandbox provider name, or `None`/`"none"` when inactive.
1595
+ user_id: Stable pseudonymous user id, or `None`.
1596
+
1597
+ Returns:
1598
+ The contract metadata dict with unknown keys omitted.
1599
+ """
1600
+ metadata: dict[str, Any] = {
1601
+ "ls_agent_purpose": CODING_AGENT_PURPOSE,
1602
+ "ls_integration": CODING_AGENT_INTEGRATION,
1603
+ "ls_agent_runtime": CODING_AGENT_RUNTIME,
1604
+ "thread_id": thread_id,
1605
+ "ls_trace_schema_version": CODING_AGENT_TRACE_SCHEMA_VERSION,
1606
+ "ls_integration_version": __version__,
1607
+ "ls_agent_runtime_version": __version__,
1608
+ }
1609
+
1610
+ if turn_id:
1611
+ metadata["turn_id"] = turn_id
1612
+ if turn_number is not None:
1613
+ metadata["turn_number"] = turn_number
1614
+
1615
+ repo = _get_repository_metadata()
1616
+ if repo is not None:
1617
+ repository_url, repository_provider, repository_name = repo
1618
+ metadata["repository_url"] = repository_url
1619
+ metadata["repository_provider"] = repository_provider
1620
+ metadata["repository_name"] = repository_name
1621
+
1622
+ if git_branch:
1623
+ metadata["git_branch"] = git_branch
1624
+ commit_sha = _get_git_commit_sha()
1625
+ if commit_sha:
1626
+ metadata["git_commit_sha"] = commit_sha
1627
+ if cwd:
1628
+ metadata["cwd"] = cwd
1629
+
1630
+ if user_id:
1631
+ metadata["user_id"] = user_id
1632
+ if sandbox_type and sandbox_type != "none":
1633
+ metadata["sandbox_type"] = sandbox_type
1634
+
1635
+ return metadata
1636
+
1637
+
1638
+ def build_stream_config(
1639
+ thread_id: str,
1640
+ assistant_id: str | None,
1641
+ *,
1642
+ sandbox_type: str | None = None,
1643
+ turn_id: str | None = None,
1644
+ turn_number: int | None = None,
1645
+ ) -> RunnableConfig:
1646
+ """Build the LangGraph stream config dict.
1647
+
1648
+ Stamps the shared `coding-agent-v1` trace-metadata contract via
1649
+ `build_coding_agent_metadata` — identity block, plugin/runtime versions,
1650
+ turn markers, and repo/git/cwd attribution — onto `metadata`. Metadata set
1651
+ here propagates trace-wide to every run in the graph (root, llm, tool, and
1652
+ subagent subgraphs), which is exactly what the contract's "always" and
1653
+ "where-known" keys require, so the helper output is stamped once here.
1654
+
1655
+ Scope-restricted contract keys are deliberately not emitted. `approval_policy`
1656
+ (root/interrupted only) and `ls_subagent_id` / `ls_subagent_type` (subagent
1657
+ only) cannot live in this trace-wide metadata: LangGraph propagates each key
1658
+ to all descendant runs (per-key config merge, langgraph#7926 /
1659
+ deepagents#3634), so they would leak onto run types outside their contract
1660
+ `appliesTo` set and fail validation. This runtime exposes no clean
1661
+ per-run-type metadata seam to scope them, so they are omitted by design
1662
+ rather than leaked. (Subagent runs still inherit the parent/root `thread_id`
1663
+ and all required keys, satisfying the contract's grouping rule.)
1664
+
1665
+ Also injects the dcode version into `metadata["lc_versions"]` so LangSmith
1666
+ traces can be correlated with specific releases. `create_deep_agent` supplies
1667
+ the SDK version through the compiled graph config, and LangChain merges
1668
+ nested metadata dictionaries so both versions survive at stream time.
1669
+
1670
+ Also records `dcode_client_deepagents_version` as a dcode-client diagnostic.
1671
+ This describes the Deep Agents package installed alongside the TUI, which
1672
+ can differ from a remote graph's Deep Agents runtime version.
1673
+
1674
+ Also records `dcode_experimental=True` when `DEEPAGENTS_CODE_EXPERIMENTAL`
1675
+ is enabled, so experimental runs are filterable in trace metadata.
1676
+
1677
+ Args:
1678
+ thread_id: The app session thread identifier. Set both on
1679
+ `configurable.thread_id` and as the top-level `metadata.thread_id`
1680
+ used by the contract for grouping turns.
1681
+ assistant_id: The dcode agent identifier, if any. When set, it is
1682
+ surfaced in trace metadata under `dcode_agent_name` and
1683
+ `agent_name`.
1684
+ sandbox_type: Sandbox provider name for trace metadata, or `None` if no
1685
+ sandbox is active.
1686
+ turn_id: Stable per-turn id for the current user prompt, or `None`.
1687
+ turn_number: 1-based per-thread turn index, or `None`.
1688
+
1689
+ Returns:
1690
+ Config dict with `configurable` and `metadata` keys.
1691
+ """
1692
+ from datetime import UTC, datetime
1693
+
1694
+ try:
1695
+ cwd = str(Path.cwd())
1696
+ except OSError:
1697
+ logger.warning("Could not determine working directory", exc_info=True)
1698
+ cwd = ""
1699
+
1700
+ from deepagents_code._env_vars import EXPERIMENTAL, USER_ID
1701
+
1702
+ metadata: dict[str, Any] = build_coding_agent_metadata(
1703
+ thread_id=thread_id,
1704
+ turn_id=turn_id,
1705
+ turn_number=turn_number,
1706
+ cwd=cwd,
1707
+ git_branch=_get_git_branch(),
1708
+ sandbox_type=sandbox_type,
1709
+ user_id=os.environ.get(USER_ID) or None,
1710
+ )
1711
+
1712
+ # Mark experimental runs so they are filterable in trace metadata.
1713
+ if is_env_truthy(EXPERIMENTAL):
1714
+ metadata["dcode_experimental"] = True
1715
+
1716
+ # Legacy / diagnostic keys preserved for backward-compatibility during the
1717
+ # coding-agent-v1 rollout (not part of the contract).
1718
+ metadata["lc_versions"] = {
1719
+ "deepagents-code": _format_lc_version(
1720
+ __version__, editable=_is_editable_install()
1721
+ )
1722
+ }
1723
+ deepagents_version = _get_deepagents_version()
1724
+ if deepagents_version is not None:
1725
+ metadata["dcode_client_deepagents_version"] = deepagents_version
1726
+ if assistant_id:
1727
+ metadata.update(
1728
+ {
1729
+ "dcode_agent_name": assistant_id,
1730
+ "agent_name": assistant_id,
1731
+ "updated_at": datetime.now(UTC).isoformat(),
1732
+ }
1733
+ )
1734
+
1735
+ return {
1736
+ "configurable": {"thread_id": thread_id},
1737
+ "metadata": metadata,
1738
+ }
1739
+
1740
+
1741
+ class _ShellAllowAll(list): # noqa: FURB189 # sentinel type, not a general-purpose list subclass
1742
+ """Sentinel subclass for unrestricted shell access.
1743
+
1744
+ Using a dedicated type instead of a plain list lets consumers use
1745
+ `isinstance` checks, which survive serialization/copy unlike identity
1746
+ checks (`is`).
1747
+ """
1748
+
1749
+
1750
+ SHELL_ALLOW_ALL: list[str] = _ShellAllowAll(["__ALL__"])
1751
+ """Sentinel value returned by `parse_shell_allow_list` for `--shell-allow-list=all`."""
1752
+
1753
+
1754
+ def parse_shell_allow_list(allow_list_str: str | None) -> list[str] | None:
1755
+ """Parse shell allow-list from string.
1756
+
1757
+ Args:
1758
+ allow_list_str: Comma-separated list of commands, `'recommended'` for
1759
+ safe defaults, or `'all'` to allow any command.
1760
+
1761
+ `'all'` must be the sole value — it is not recognized inside a
1762
+ comma-separated list (unlike `'recommended'`).
1763
+
1764
+ Can also include `'recommended'` in the list to merge with custom
1765
+ commands.
1766
+
1767
+ Returns:
1768
+ List of allowed commands, `SHELL_ALLOW_ALL` if `'all'` was specified,
1769
+ or `None` if no allow-list configured.
1770
+
1771
+ Raises:
1772
+ ValueError: If `'all'` is combined with other commands.
1773
+ """
1774
+ if not allow_list_str:
1775
+ return None
1776
+
1777
+ # Special value 'all' allows any shell command
1778
+ if allow_list_str.strip().lower() == "all":
1779
+ return SHELL_ALLOW_ALL
1780
+
1781
+ # Special value 'recommended' uses our curated safe list
1782
+ if allow_list_str.strip().lower() == "recommended":
1783
+ return list(RECOMMENDED_SAFE_SHELL_COMMANDS)
1784
+
1785
+ # Split by comma and strip whitespace
1786
+ commands = [cmd.strip() for cmd in allow_list_str.split(",") if cmd.strip()]
1787
+
1788
+ # Reject ambiguous input: 'all' mixed with other commands
1789
+ if any(cmd.lower() == "all" for cmd in commands):
1790
+ msg = (
1791
+ "Cannot combine 'all' with other commands in --shell-allow-list. "
1792
+ "Use '--shell-allow-list all' alone to allow any command."
1793
+ )
1794
+ raise ValueError(msg)
1795
+
1796
+ # If "recommended" is in the list, merge with recommended commands
1797
+ result = []
1798
+ for cmd in commands:
1799
+ if cmd.lower() == "recommended":
1800
+ result.extend(RECOMMENDED_SAFE_SHELL_COMMANDS)
1801
+ else:
1802
+ result.append(cmd)
1803
+
1804
+ # Remove duplicates while preserving order
1805
+ seen: set[str] = set()
1806
+ unique: list[str] = []
1807
+ for cmd in result:
1808
+ if cmd not in seen:
1809
+ seen.add(cmd)
1810
+ unique.append(cmd)
1811
+ return unique
1812
+
1813
+
1814
+ INTERPRETER_PTC_SAFE_PRESET: frozenset[str] = frozenset({"read_file", "glob", "grep"})
1815
+ """Strictly read-only PTC allowlist for `interpreter_ptc="safe"`.
1816
+
1817
+ Limited to tools that are **not** in `_add_interrupt_on()` to begin with, so
1818
+ exposing them through PTC does not introduce a new HITL bypass. Network
1819
+ tools (`web_search`, `fetch_url`), subagent dispatch (`task`), shell
1820
+ execution (`execute`), and file writes (`write_file`, `edit_file`, MCP
1821
+ write tools) are deliberately excluded — they are HITL-gated outside the
1822
+ REPL, and PTC bypasses `interrupt_on`, so including them would silently
1823
+ escalate privileges. Users who need network or subagent access from inside
1824
+ the REPL must list those tools explicitly (which signals intent at config
1825
+ time) or use `interpreter_ptc="all"` with the unsafe acknowledgement.
1826
+ """
1827
+
1828
+ INTERPRETER_PTC_ALL_SENTINEL = "all"
1829
+ """Sentinel string for `interpreter_ptc="all"` — resolved at agent-build time
1830
+ from the live tool list. Requires `interpreter_ptc_acknowledge_unsafe=True`
1831
+ when `auto_approve` is `False`."""
1832
+
1833
+ INTERPRETER_PTC_SAFE_SENTINEL = "safe"
1834
+ """Sentinel string for `interpreter_ptc="safe"` — expanded from
1835
+ `INTERPRETER_PTC_SAFE_PRESET`."""
1836
+
1837
+
1838
+ def _parse_interpreter_ptc(
1839
+ raw: Any, # noqa: ANN401 # accepts TOML-shaped value
1840
+ ) -> str | bool | list[str]:
1841
+ """Coerce a raw `interpreter_ptc` value into the canonical shape.
1842
+
1843
+ Args:
1844
+ raw: Value loaded from TOML or supplied by the CLI.
1845
+
1846
+ Returns:
1847
+ `False` for `False`/`None`/`[]`, the string `"safe"`/`"all"` when
1848
+ either sentinel is given, otherwise a validated list of tool names.
1849
+ A list may include the `"safe"` preset (expanded at agent-build time)
1850
+ but never `"all"`.
1851
+
1852
+ Raises:
1853
+ ValueError: If `raw` is a list with empty or non-string entries, a
1854
+ list containing `"all"`, or a string other than `"safe"`/`"all"`.
1855
+ """
1856
+ if raw is None or raw is False:
1857
+ return False
1858
+ if raw is True:
1859
+ msg = (
1860
+ "`interpreter_ptc` cannot be set to True; use 'safe', 'all', or "
1861
+ "an explicit list of tool names."
1862
+ )
1863
+ raise ValueError(msg)
1864
+ if isinstance(raw, str):
1865
+ normalized = raw.strip().lower()
1866
+ if normalized in {INTERPRETER_PTC_SAFE_SENTINEL, INTERPRETER_PTC_ALL_SENTINEL}:
1867
+ return normalized
1868
+ msg = (
1869
+ f"Invalid `interpreter_ptc` string {raw!r}; expected 'safe', 'all', "
1870
+ "or a list of tool names."
1871
+ )
1872
+ raise ValueError(msg)
1873
+ if isinstance(raw, list):
1874
+ if not raw:
1875
+ return False
1876
+ names: list[str] = []
1877
+ for entry in raw:
1878
+ if not isinstance(entry, str) or not entry.strip():
1879
+ msg = (
1880
+ "`interpreter_ptc` list entries must be non-empty strings; "
1881
+ f"got {entry!r}."
1882
+ )
1883
+ raise ValueError(msg)
1884
+ cleaned = entry.strip()
1885
+ if cleaned.lower() == INTERPRETER_PTC_ALL_SENTINEL:
1886
+ msg = (
1887
+ "`interpreter_ptc` list entries cannot include 'all'; use "
1888
+ "'all' as a standalone value or list explicit tool names "
1889
+ "(optionally with the 'safe' preset)."
1890
+ )
1891
+ raise ValueError(msg)
1892
+ names.append(cleaned)
1893
+ return names
1894
+ msg = (
1895
+ f"`interpreter_ptc` must be False, 'safe', 'all', or a list of tool "
1896
+ f"names; got {type(raw).__name__}."
1897
+ )
1898
+ raise ValueError(msg)
1899
+
1900
+
1901
+ def _read_config_toml_retries() -> dict[str, Any] | None:
1902
+ """Read and lightly validate `[retries]` from `~/.deepagents/config.toml`.
1903
+
1904
+ Provider sub-table names are checked against the set of providers the app
1905
+ knows how to authenticate so a mistyped provider (e.g. `[retries.fireorks]`)
1906
+ surfaces a warning rather than being silently dropped. Value validation is
1907
+ deferred to `_resolve_retry_kwargs`, which runs per active provider.
1908
+
1909
+ Returns:
1910
+ The raw `[retries]` mapping, or `None` when the section is absent or the
1911
+ file cannot be read.
1912
+ """
1913
+ import tomllib
1914
+
1915
+ from deepagents_code.model_config import (
1916
+ DEFAULT_CONFIG_PATH,
1917
+ IMPLICIT_AUTH_PROVIDERS,
1918
+ NO_AUTH_REQUIRED_PROVIDERS,
1919
+ PROVIDER_API_KEY_ENV,
1920
+ RETRY_PARAM_BY_PROVIDER,
1921
+ )
1922
+
1923
+ try:
1924
+ with DEFAULT_CONFIG_PATH.open("rb") as f:
1925
+ data = tomllib.load(f)
1926
+ except FileNotFoundError:
1927
+ return None
1928
+ except (PermissionError, OSError, tomllib.TOMLDecodeError):
1929
+ logger.warning(
1930
+ "Could not read retries config from %s",
1931
+ DEFAULT_CONFIG_PATH,
1932
+ exc_info=True,
1933
+ )
1934
+ return None
1935
+
1936
+ section = data.get("retries")
1937
+ if not isinstance(section, dict):
1938
+ return None
1939
+
1940
+ known_providers = (
1941
+ set(PROVIDER_API_KEY_ENV)
1942
+ | set(NO_AUTH_REQUIRED_PROVIDERS)
1943
+ | set(IMPLICIT_AUTH_PROVIDERS)
1944
+ | set(RETRY_PARAM_BY_PROVIDER)
1945
+ )
1946
+ for key, value in section.items():
1947
+ if (
1948
+ isinstance(value, dict)
1949
+ and key not in known_providers
1950
+ and "param" not in value
1951
+ ):
1952
+ logger.warning(
1953
+ "Ignoring [retries.%s] in config.toml; %r is not a known provider",
1954
+ key,
1955
+ key,
1956
+ )
1957
+ return section
1958
+
1959
+
1960
+ def _coerce_max_retries(raw: Any, *, source: str) -> int | None: # noqa: ANN401
1961
+ """Validate a TOML retry count.
1962
+
1963
+ Args:
1964
+ raw: Value loaded from TOML.
1965
+ source: Human-readable config path for warnings.
1966
+
1967
+ Returns:
1968
+ The retry count, or `None` when invalid.
1969
+ """
1970
+ if isinstance(raw, int) and not isinstance(raw, bool) and raw >= 0:
1971
+ return raw
1972
+ logger.warning("Ignoring %s=%r in config.toml (expected int >= 0)", source, raw)
1973
+ return None
1974
+
1975
+
1976
+ def _coerce_retry_param(raw: Any, *, source: str) -> str | None: # noqa: ANN401
1977
+ """Validate a constructor kwarg name for retry configuration.
1978
+
1979
+ Args:
1980
+ raw: Value loaded from TOML.
1981
+ source: Human-readable config path for warnings.
1982
+
1983
+ Returns:
1984
+ The retry parameter name, or `None` when invalid.
1985
+ """
1986
+ if isinstance(raw, str) and raw.isidentifier() and not keyword.iskeyword(raw):
1987
+ return raw
1988
+ logger.warning(
1989
+ "Ignoring %s=%r in config.toml (expected Python identifier string)",
1990
+ source,
1991
+ raw,
1992
+ )
1993
+ return None
1994
+
1995
+
1996
+ def _resolve_retry_kwargs(
1997
+ section: dict[str, Any] | None,
1998
+ provider: str,
1999
+ ) -> dict[str, int]:
2000
+ """Resolve the retry-count kwarg for `provider` from a `[retries]` section.
2001
+
2002
+ A per-provider `[retries.<provider>].max_retries` overrides the global
2003
+ `[retries].max_retries`. Known providers use `RETRY_PARAM_BY_PROVIDER`;
2004
+ arbitrary providers can opt in with `[retries.<provider>].param`.
2005
+ Unknown providers without a configured parameter receive nothing, and
2006
+ unknown or malformed keys are dropped with a warning.
2007
+
2008
+ Args:
2009
+ section: Raw `[retries]` mapping from `config.toml`, or `None`.
2010
+ provider: Provider the kwargs are being resolved for.
2011
+
2012
+ Returns:
2013
+ `{retry_param_name: count}` when a valid retry count resolves, else an
2014
+ empty dict.
2015
+ """
2016
+ if not section:
2017
+ return {}
2018
+
2019
+ from deepagents_code.model_config import RETRY_PARAM_BY_PROVIDER
2020
+
2021
+ for key, value in section.items():
2022
+ if key == "max_retries" or isinstance(value, dict):
2023
+ continue
2024
+ logger.warning("Ignoring [retries].%s=%r in config.toml", key, value)
2025
+
2026
+ retry_param = RETRY_PARAM_BY_PROVIDER.get(provider)
2027
+ resolved: int | None = None
2028
+ if "max_retries" in section:
2029
+ resolved = _coerce_max_retries(
2030
+ section["max_retries"], source="[retries].max_retries"
2031
+ )
2032
+
2033
+ provider_section = section.get(provider)
2034
+ if provider_section is not None and not isinstance(provider_section, dict):
2035
+ logger.warning(
2036
+ "Ignoring [retries].%s=%r in config.toml (expected table)",
2037
+ provider,
2038
+ provider_section,
2039
+ )
2040
+ elif provider_section:
2041
+ for key, value in provider_section.items():
2042
+ if key not in {"max_retries", "param"}:
2043
+ logger.warning(
2044
+ "Ignoring [retries.%s].%s=%r in config.toml",
2045
+ provider,
2046
+ key,
2047
+ value,
2048
+ )
2049
+ if "max_retries" in provider_section:
2050
+ provider_value = _coerce_max_retries(
2051
+ provider_section["max_retries"],
2052
+ source=f"[retries.{provider}].max_retries",
2053
+ )
2054
+ if provider_value is not None:
2055
+ resolved = provider_value
2056
+ if "param" in provider_section:
2057
+ provider_param = _coerce_retry_param(
2058
+ provider_section["param"],
2059
+ source=f"[retries.{provider}].param",
2060
+ )
2061
+ if provider_param is not None:
2062
+ retry_param = provider_param
2063
+
2064
+ if retry_param is None:
2065
+ logger.warning(
2066
+ "Ignoring [retries] config for provider %r; provider does not support "
2067
+ "a registered or configured retry parameter",
2068
+ provider,
2069
+ )
2070
+ return {}
2071
+
2072
+ if resolved is None:
2073
+ return {}
2074
+ return {retry_param: resolved}
2075
+
2076
+
2077
+ CLI_MAX_RETRIES_KEY = "__deepagents_cli_max_retries__"
2078
+ """Internal carrier key for the `--max-retries` CLI flag.
2079
+
2080
+ `cli_main` stashes the flag value under this key in the `model_params` dict it
2081
+ forwards to the run, and `create_model` pops it before constructing the model.
2082
+ This lets the CLI value ride the existing `model_params`/`extra_kwargs` carrier
2083
+ to the one place that authoritatively resolves the provider, where it can be
2084
+ folded under the provider's *resolved* retry-param name (see
2085
+ `_resolve_retry_param_name`) rather than a hardcoded `max_retries`.
2086
+
2087
+ The key is internal-only: it is popped before reaching any model constructor and
2088
+ is never serialized or surfaced to users. It is deliberately unlikely to collide
2089
+ with a real constructor kwarg name.
2090
+ """
2091
+
2092
+
2093
+ def _resolve_retry_param_name(provider: str) -> str:
2094
+ """Resolve the constructor kwarg name that sets `provider`'s retry count.
2095
+
2096
+ Honors a `[retries.<provider>].param` override in `config.toml`, then the
2097
+ registered `RETRY_PARAM_BY_PROVIDER` mapping, and finally falls back to
2098
+ `max_retries` -- the near-universal LangChain chat-model kwarg -- for
2099
+ providers that are neither registered nor configured.
2100
+
2101
+ Args:
2102
+ provider: Provider the retry kwarg name is being resolved for.
2103
+
2104
+ Returns:
2105
+ The constructor kwarg name to use for the retry count.
2106
+ """
2107
+ from deepagents_code.model_config import RETRY_PARAM_BY_PROVIDER
2108
+
2109
+ section = _read_config_toml_retries()
2110
+ if section:
2111
+ provider_section = section.get(provider)
2112
+ if isinstance(provider_section, dict) and "param" in provider_section:
2113
+ configured = _coerce_retry_param(
2114
+ provider_section["param"],
2115
+ source=f"[retries.{provider}].param",
2116
+ )
2117
+ if configured is not None:
2118
+ return configured
2119
+
2120
+ return RETRY_PARAM_BY_PROVIDER.get(provider, "max_retries")
2121
+
2122
+
2123
+ def _read_config_toml_skills_dirs() -> list[str] | None:
2124
+ """Read `[skills].extra_allowed_dirs` from `~/.deepagents/config.toml`.
2125
+
2126
+ Returns:
2127
+ List of path strings, or `None` if the key is absent or the file
2128
+ cannot be read.
2129
+ """
2130
+ import tomllib
2131
+
2132
+ from deepagents_code.model_config import DEFAULT_CONFIG_PATH
2133
+
2134
+ try:
2135
+ with DEFAULT_CONFIG_PATH.open("rb") as f:
2136
+ data = tomllib.load(f)
2137
+ except FileNotFoundError:
2138
+ return None
2139
+ except (PermissionError, OSError, tomllib.TOMLDecodeError):
2140
+ logger.warning(
2141
+ "Could not read skills config from %s",
2142
+ DEFAULT_CONFIG_PATH,
2143
+ exc_info=True,
2144
+ )
2145
+ return None
2146
+
2147
+ skills_section = data.get("skills", {})
2148
+ dirs = skills_section.get("extra_allowed_dirs")
2149
+ if isinstance(dirs, list):
2150
+ return dirs
2151
+ return None
2152
+
2153
+
2154
+ def _parse_extra_skills_dirs(
2155
+ env_raw: str | None,
2156
+ config_toml_dirs: list[str] | None = None,
2157
+ ) -> list[Path] | None:
2158
+ """Merge extra skill directories from env var and config.toml.
2159
+
2160
+ Extra skills directories extend the containment allowlist used by
2161
+ `load_skill_content` to validate that a resolved skill path lives inside a
2162
+ trusted root. They do **not** add new skill discovery locations — skills are
2163
+ still discovered only from the standard directories. This exists so that
2164
+ symlinks inside standard skill directories can legitimately point to targets
2165
+ in user-specified locations without being rejected by the path
2166
+ containment check.
2167
+
2168
+ The env var (`DEEPAGENTS_CODE_EXTRA_SKILLS_DIRS`, colon-separated) takes
2169
+ precedence: when set, `config.toml` values are ignored.
2170
+
2171
+ Args:
2172
+ env_raw: Value of `DEEPAGENTS_CODE_EXTRA_SKILLS_DIRS` (colon-separated), or
2173
+ `None` if unset.
2174
+ config_toml_dirs: List of path strings from
2175
+ `[skills].extra_allowed_dirs` in `~/.deepagents/config.toml`.
2176
+
2177
+ Returns:
2178
+ List of resolved `Path` objects, or `None` if not configured.
2179
+ """
2180
+ # Env var takes precedence when set
2181
+ if env_raw:
2182
+ dirs = [
2183
+ Path(p.strip()).expanduser().resolve()
2184
+ for p in env_raw.split(":")
2185
+ if p.strip()
2186
+ ]
2187
+ return dirs or None
2188
+
2189
+ if config_toml_dirs:
2190
+ dirs = [
2191
+ Path(p).expanduser().resolve()
2192
+ for p in config_toml_dirs
2193
+ if isinstance(p, str) and p.strip()
2194
+ ]
2195
+ return dirs or None
2196
+
2197
+ return None
2198
+
2199
+
2200
+ _RELOADABLE_FIELDS = (
2201
+ "openai_api_key",
2202
+ "anthropic_api_key",
2203
+ "google_api_key",
2204
+ "nvidia_api_key",
2205
+ "tavily_api_key",
2206
+ "google_cloud_project",
2207
+ "deepagents_langchain_project",
2208
+ "project_root",
2209
+ "shell_allow_list",
2210
+ "extra_skills_dirs",
2211
+ )
2212
+ """Fields refreshed on `/reload` and cwd switches.
2213
+
2214
+ Runtime model state (`model_name`, `model_provider`, `model_context_limit`) and
2215
+ the original user LangSmith project are intentionally excluded -- they are set
2216
+ once and should not change across reloads.
2217
+ """
2218
+
2219
+ _API_KEY_FIELDS = frozenset(
2220
+ field for field in _RELOADABLE_FIELDS if field.endswith("_api_key")
2221
+ )
2222
+ """Reloadable fields that hold API keys and must be masked in change reports.
2223
+
2224
+ Derived from `_RELOADABLE_FIELDS` so new `*_api_key` fields are picked up
2225
+ automatically.
2226
+ """
2227
+
2228
+
2229
+ @dataclass
2230
+ class Settings:
2231
+ """Global settings and environment detection for deepagents-code.
2232
+
2233
+ This class is initialized once at startup and provides access to:
2234
+ - Available models and API keys
2235
+ - Current project information
2236
+ - Tool availability (e.g., Tavily)
2237
+ - File system paths
2238
+ """
2239
+
2240
+ openai_api_key: str | None
2241
+ """OpenAI API key if available."""
2242
+
2243
+ anthropic_api_key: str | None
2244
+ """Anthropic API key if available."""
2245
+
2246
+ google_api_key: str | None
2247
+ """Google API key if available."""
2248
+
2249
+ nvidia_api_key: str | None
2250
+ """NVIDIA API key if available."""
2251
+
2252
+ tavily_api_key: str | None
2253
+ """Tavily API key if available."""
2254
+
2255
+ google_cloud_project: str | None
2256
+ """Google Cloud project ID for VertexAI authentication."""
2257
+
2258
+ deepagents_langchain_project: str | None
2259
+ """LangSmith project name for deepagents agent tracing."""
2260
+
2261
+ user_langchain_project: str | None
2262
+ """Original `LANGSMITH_PROJECT` from environment (for user code)."""
2263
+
2264
+ model_name: str | None = None
2265
+ """Currently active model name, set after model creation."""
2266
+
2267
+ model_provider: str | None = None
2268
+ """Provider identifier (e.g., `openai`, `anthropic`, `google_genai`)."""
2269
+
2270
+ model_context_limit: int | None = None
2271
+ """Maximum input token count from the model profile."""
2272
+
2273
+ model_unsupported_modalities: frozenset[str] = frozenset()
2274
+ """Input modalities not indicated as supported by the model profile."""
2275
+
2276
+ project_root: Path | None = None
2277
+ """Current project root directory, or `None` if not in a git project."""
2278
+
2279
+ shell_allow_list: list[str] | None = None
2280
+ """Shell commands that don't require user approval."""
2281
+
2282
+ extra_skills_dirs: list[Path] | None = None
2283
+ """Extra directories added to the skill path containment allowlist.
2284
+
2285
+ These do NOT add new skill discovery locations — skills are still only
2286
+ discovered from the standard directories. They exist so that symlinks inside
2287
+ standard skill directories can point to targets in these additional
2288
+ locations without being rejected by the containment check
2289
+ in `load_skill_content`.
2290
+
2291
+ Set via `DEEPAGENTS_CODE_EXTRA_SKILLS_DIRS` env var (colon-separated) or
2292
+ `[skills].extra_allowed_dirs` in `~/.deepagents/config.toml`.
2293
+ """
2294
+
2295
+ enable_interpreter: bool = INTERPRETER_ENABLE_DEFAULT
2296
+ """Wire `CodeInterpreterMiddleware` from `langchain-quickjs` into the main
2297
+ agent. Local-mode only; raises `ValueError` at agent-build time when a
2298
+ remote sandbox is active. Subagents never receive the interpreter in v1.
2299
+
2300
+ `langchain-quickjs` is installed as a core dependency.
2301
+
2302
+ Defaults are owned by `config_manifest` (the canonical config surface) so
2303
+ they are defined in exactly one place.
2304
+ """
2305
+
2306
+ interpreter_timeout_seconds: float = INTERPRETER_TIMEOUT_SECONDS_DEFAULT
2307
+ """Per-`js_eval`-call wall-clock timeout (seconds) for the QuickJS REPL."""
2308
+
2309
+ interpreter_memory_limit_mb: int = INTERPRETER_MEMORY_LIMIT_MB_DEFAULT
2310
+ """QuickJS heap memory cap (MB), shared across all calls within a session."""
2311
+
2312
+ interpreter_max_ptc_calls: int = INTERPRETER_MAX_PTC_CALLS_DEFAULT
2313
+ """Maximum `tools.*` host-bridge invocations allowed per `js_eval` call.
2314
+
2315
+ PTC calls bypass `interrupt_on`/HITL approval — this budget is the only
2316
+ runtime limiter on bursty tool fan-out from inside the REPL.
2317
+ """
2318
+
2319
+ interpreter_max_result_chars: int = INTERPRETER_MAX_RESULT_CHARS_DEFAULT
2320
+ """Independent cap (chars) on `js_eval` result and stdout blocks before
2321
+ truncation."""
2322
+
2323
+ interpreter_ptc: str | bool | list[str] = INTERPRETER_PTC_DEFAULT
2324
+ """Programmatic tool calling allowlist for `js_eval`.
2325
+
2326
+ Accepted values:
2327
+
2328
+ - `False` or `[]`: pure REPL, no `tools.*` bridge.
2329
+ - `"safe"`: expand to `INTERPRETER_PTC_SAFE_PRESET` (the default).
2330
+ - `"all"`: every tool passed to `create_cli_agent` is exposed. Requires
2331
+ `interpreter_ptc_acknowledge_unsafe=True` when `auto_approve` is `False`.
2332
+ - `list[str]`: explicit tool names. The list may also include the `"safe"`
2333
+ preset (expanded to `INTERPRETER_PTC_SAFE_PRESET`); `"all"` is rejected
2334
+ inside a list. Names are matched against the live tool registry at
2335
+ runtime, so names not present are simply not exposed.
2336
+ """
2337
+
2338
+ interpreter_ptc_acknowledge_unsafe: bool = (
2339
+ INTERPRETER_PTC_ACKNOWLEDGE_UNSAFE_DEFAULT
2340
+ )
2341
+ """Explicit acknowledgement required when `interpreter_ptc="all"` is set
2342
+ without `auto_approve`.
2343
+
2344
+ `"all"` exposes every host tool to `tools.*` calls from inside the REPL,
2345
+ bypassing HITL approval — this flag is a deliberate sanity gate, not a
2346
+ feature toggle.
2347
+ """
2348
+
2349
+ @classmethod
2350
+ def from_environment(cls, *, start_path: Path | None = None) -> Settings:
2351
+ """Create settings by detecting the current environment.
2352
+
2353
+ Args:
2354
+ start_path: Directory to start project detection from (defaults to cwd)
2355
+
2356
+ Returns:
2357
+ Settings instance with detected configuration
2358
+ """
2359
+ # Detect API keys (normalize empty strings to None).
2360
+ from deepagents_code.model_config import resolve_env_var
2361
+
2362
+ openai_key = resolve_env_var("OPENAI_API_KEY")
2363
+ anthropic_key = resolve_env_var("ANTHROPIC_API_KEY")
2364
+ google_key = resolve_env_var("GOOGLE_API_KEY")
2365
+ nvidia_key = resolve_env_var("NVIDIA_API_KEY")
2366
+ tavily_key = resolve_env_var("TAVILY_API_KEY")
2367
+ google_cloud_project = resolve_env_var("GOOGLE_CLOUD_PROJECT")
2368
+
2369
+ # Detect LangSmith configuration
2370
+ # DEEPAGENTS_CODE_LANGSMITH_PROJECT: Project for deepagents agent tracing
2371
+ # user_langchain_project: User's ORIGINAL LANGSMITH_PROJECT (before override)
2372
+ # When accessed via the module-level `settings` singleton,
2373
+ # _ensure_bootstrap() has already run and may have overridden
2374
+ # LANGSMITH_PROJECT. We use the saved original value, not the
2375
+ # current os.environ value. Direct callers should ensure
2376
+ # bootstrap has run if they depend on the override.
2377
+ from deepagents_code._env_vars import (
2378
+ EXTRA_SKILLS_DIRS,
2379
+ LANGSMITH_PROJECT,
2380
+ SHELL_ALLOW_LIST,
2381
+ )
2382
+
2383
+ deepagents_langchain_project = resolve_env_var(LANGSMITH_PROJECT)
2384
+ # Use the saved original, not the current `LANGSMITH_PROJECT` that
2385
+ # bootstrap may have overridden for agent traces.
2386
+ user_langchain_project = _bootstrap_state.original_langsmith_project
2387
+
2388
+ # Detect project
2389
+ from deepagents_code.project_utils import find_project_root
2390
+
2391
+ project_root = find_project_root(start_path)
2392
+
2393
+ # Parse shell command allow-list from environment
2394
+ # Format: comma-separated list of commands (e.g., "ls,cat,grep,pwd")
2395
+
2396
+ shell_allow_list_str = os.environ.get(SHELL_ALLOW_LIST)
2397
+ shell_allow_list = parse_shell_allow_list(shell_allow_list_str)
2398
+
2399
+ # Parse extra skill containment roots from env var or config.toml.
2400
+ # These extend the path allowlist for load_skill_content but do not
2401
+ # add new skill discovery locations.
2402
+ extra_skills_dirs = _parse_extra_skills_dirs(
2403
+ os.environ.get(EXTRA_SKILLS_DIRS),
2404
+ _read_config_toml_skills_dirs(),
2405
+ )
2406
+
2407
+ from deepagents_code.config_manifest import resolve_interpreter_kwargs
2408
+
2409
+ interpreter_kwargs = resolve_interpreter_kwargs()
2410
+
2411
+ return cls(
2412
+ openai_api_key=openai_key,
2413
+ anthropic_api_key=anthropic_key,
2414
+ google_api_key=google_key,
2415
+ nvidia_api_key=nvidia_key,
2416
+ tavily_api_key=tavily_key,
2417
+ google_cloud_project=google_cloud_project,
2418
+ deepagents_langchain_project=deepagents_langchain_project,
2419
+ user_langchain_project=user_langchain_project,
2420
+ project_root=project_root,
2421
+ shell_allow_list=shell_allow_list,
2422
+ extra_skills_dirs=extra_skills_dirs,
2423
+ **interpreter_kwargs,
2424
+ )
2425
+
2426
+ @staticmethod
2427
+ def _reload_values(
2428
+ *,
2429
+ start_path: Path | None,
2430
+ env: dict[str, str],
2431
+ previous: dict[str, object],
2432
+ ) -> dict[str, object]:
2433
+ """Resolve reloadable settings from an environment mapping.
2434
+
2435
+ Returns:
2436
+ Reloadable setting values keyed by field name.
2437
+ """
2438
+ from deepagents_code._env_vars import (
2439
+ EXTRA_SKILLS_DIRS,
2440
+ LANGSMITH_PROJECT,
2441
+ SHELL_ALLOW_LIST,
2442
+ )
2443
+
2444
+ try:
2445
+ shell_allow_list = parse_shell_allow_list(env.get(SHELL_ALLOW_LIST))
2446
+ except ValueError:
2447
+ logger.warning(
2448
+ "Invalid %s during reload; keeping previous value",
2449
+ SHELL_ALLOW_LIST,
2450
+ )
2451
+ shell_allow_list = previous["shell_allow_list"]
2452
+
2453
+ try:
2454
+ from deepagents_code.project_utils import find_project_root
2455
+
2456
+ project_root = find_project_root(start_path)
2457
+ except OSError:
2458
+ logger.warning(
2459
+ "Could not detect project root during reload; keeping previous value"
2460
+ )
2461
+ project_root = previous["project_root"]
2462
+
2463
+ try:
2464
+ extra_skills_dirs = _parse_extra_skills_dirs(
2465
+ env.get(EXTRA_SKILLS_DIRS),
2466
+ _read_config_toml_skills_dirs(),
2467
+ )
2468
+ except (OSError, ValueError):
2469
+ # Path resolution can fail (e.g. broken symlink loop). Keep the
2470
+ # previous value rather than letting the failure escape reload --
2471
+ # callers such as the cwd switch run this after `os.chdir`, where an
2472
+ # uncaught error would strand the process in a half-applied cwd.
2473
+ logger.warning(
2474
+ "Could not resolve %s during reload; keeping previous value",
2475
+ EXTRA_SKILLS_DIRS,
2476
+ exc_info=True,
2477
+ )
2478
+ extra_skills_dirs = previous["extra_skills_dirs"]
2479
+
2480
+ return {
2481
+ "openai_api_key": _resolve_env_var_from(env, "OPENAI_API_KEY"),
2482
+ "anthropic_api_key": _resolve_env_var_from(env, "ANTHROPIC_API_KEY"),
2483
+ "google_api_key": _resolve_env_var_from(env, "GOOGLE_API_KEY"),
2484
+ "nvidia_api_key": _resolve_env_var_from(env, "NVIDIA_API_KEY"),
2485
+ "tavily_api_key": _resolve_env_var_from(env, "TAVILY_API_KEY"),
2486
+ "google_cloud_project": _resolve_env_var_from(env, "GOOGLE_CLOUD_PROJECT"),
2487
+ "deepagents_langchain_project": _resolve_env_var_from(
2488
+ env,
2489
+ LANGSMITH_PROJECT,
2490
+ ),
2491
+ "project_root": project_root,
2492
+ "shell_allow_list": shell_allow_list,
2493
+ "extra_skills_dirs": extra_skills_dirs,
2494
+ }
2495
+
2496
+ @staticmethod
2497
+ def _format_reload_changes(
2498
+ previous: dict[str, object], refreshed: dict[str, object]
2499
+ ) -> list[str]:
2500
+ """Format changed reloadable settings for logs and messages.
2501
+
2502
+ Returns:
2503
+ Human-readable change descriptions.
2504
+ """
2505
+
2506
+ def display(field: str, value: object) -> str:
2507
+ if field in _API_KEY_FIELDS:
2508
+ return "set" if value else "unset"
2509
+ return str(value)
2510
+
2511
+ changes: list[str] = []
2512
+ for field in _RELOADABLE_FIELDS:
2513
+ old_value = previous[field]
2514
+ new_value = refreshed[field]
2515
+ if old_value != new_value:
2516
+ changes.append(
2517
+ f"{field}: {display(field, old_value)} -> "
2518
+ f"{display(field, new_value)}"
2519
+ )
2520
+ return changes
2521
+
2522
+ def preview_reload_from_environment(
2523
+ self, *, start_path: Path | None = None
2524
+ ) -> list[str]:
2525
+ """Preview runtime settings changes without applying them.
2526
+
2527
+ Args:
2528
+ start_path: Directory to start project detection from (defaults to cwd).
2529
+
2530
+ Returns:
2531
+ A list of human-readable change descriptions that would be produced by
2532
+ `reload_from_environment`.
2533
+ """
2534
+ previous = {field: getattr(self, field) for field in _RELOADABLE_FIELDS}
2535
+ env = _preview_dotenv_environ(start_path=start_path)
2536
+ refreshed = self._reload_values(
2537
+ start_path=start_path,
2538
+ env=env,
2539
+ previous=previous,
2540
+ )
2541
+ return self._format_reload_changes(previous, refreshed)
2542
+
2543
+ def reload_from_environment(self, *, start_path: Path | None = None) -> list[str]:
2544
+ """Reload selected settings from environment variables and project files.
2545
+
2546
+ This refreshes only fields that are expected to change at runtime
2547
+ (API keys, Google Cloud project, project root, shell allow-list, and
2548
+ LangSmith tracing project).
2549
+
2550
+ Runtime model state (`model_name`, `model_provider`,
2551
+ `model_context_limit`) and the original user LangSmith project
2552
+ (`user_langchain_project`) are intentionally preserved -- they are
2553
+ not in `_RELOADABLE_FIELDS` and are never touched by this method.
2554
+
2555
+ !!! note
2556
+
2557
+ Shell-exported variables always take precedence. Values previously
2558
+ injected from `.env` files are refreshed so an accepted cwd switch
2559
+ can pick up the resumed project's `.env`.
2560
+
2561
+ Args:
2562
+ start_path: Directory to start project detection from (defaults to cwd).
2563
+
2564
+ Returns:
2565
+ A list of human-readable change descriptions.
2566
+ """
2567
+ _load_dotenv(start_path=start_path, refresh_loaded=True)
2568
+
2569
+ previous = {field: getattr(self, field) for field in _RELOADABLE_FIELDS}
2570
+ refreshed = self._reload_values(
2571
+ start_path=start_path,
2572
+ env=dict(os.environ),
2573
+ previous=previous,
2574
+ )
2575
+
2576
+ for field, value in refreshed.items():
2577
+ setattr(self, field, value)
2578
+
2579
+ # Sync the LANGSMITH_PROJECT env var so LangSmith tracing picks up
2580
+ # the change
2581
+ new_project = refreshed["deepagents_langchain_project"]
2582
+ if new_project:
2583
+ os.environ["LANGSMITH_PROJECT"] = str(new_project)
2584
+ elif previous["deepagents_langchain_project"]:
2585
+ # Override was previously active but new value is unset; restore the
2586
+ # user's original project. With no original, drop the override and
2587
+ # re-apply the default so ingestion keeps matching the name
2588
+ # `get_langsmith_project_name` displays (the default is a no-op when
2589
+ # tracing is off, so a disabled setup is left unset).
2590
+ if _bootstrap_state.original_langsmith_project:
2591
+ os.environ["LANGSMITH_PROJECT"] = (
2592
+ _bootstrap_state.original_langsmith_project
2593
+ )
2594
+ else:
2595
+ os.environ.pop("LANGSMITH_PROJECT", None)
2596
+ _apply_default_langsmith_project()
2597
+
2598
+ return self._format_reload_changes(previous, refreshed)
2599
+
2600
+ @property
2601
+ def has_anthropic(self) -> bool:
2602
+ """Check if Anthropic API key is configured."""
2603
+ return self.anthropic_api_key is not None
2604
+
2605
+ @property
2606
+ def has_google(self) -> bool:
2607
+ """Check if Google API key is configured."""
2608
+ return self.google_api_key is not None
2609
+
2610
+ @property
2611
+ def has_vertex_ai(self) -> bool:
2612
+ """Check if VertexAI is available (Google Cloud project set, no API key).
2613
+
2614
+ VertexAI uses Application Default Credentials (ADC) for authentication,
2615
+ so if GOOGLE_CLOUD_PROJECT is set and GOOGLE_API_KEY is not, we assume
2616
+ VertexAI.
2617
+ """
2618
+ return self.google_cloud_project is not None and self.google_api_key is None
2619
+
2620
+ @property
2621
+ def has_tavily(self) -> bool:
2622
+ """Check if Tavily API key is configured."""
2623
+ return self.tavily_api_key is not None
2624
+
2625
+ @property
2626
+ def user_deepagents_dir(self) -> Path:
2627
+ """Base user-level `.deepagents` directory.
2628
+
2629
+ Returns:
2630
+ Path to `~/.deepagents`
2631
+ """
2632
+ return Path.home() / ".zjcode"
2633
+
2634
+ @staticmethod
2635
+ def get_user_agent_md_path(agent_name: str) -> Path:
2636
+ """Get user-level AGENTS.md path for a specific agent.
2637
+
2638
+ Returns path regardless of whether the file exists.
2639
+
2640
+ Args:
2641
+ agent_name: Name of the agent
2642
+
2643
+ Returns:
2644
+ Path to ~/.deepagents/{agent_name}/AGENTS.md
2645
+ """
2646
+ return Path.home() / ".zjcode" / agent_name / "AGENTS.md"
2647
+
2648
+ def get_project_agent_md_path(self) -> list[Path]:
2649
+ """Get project-level AGENTS.md paths.
2650
+
2651
+ Checks both `{project_root}/.deepagents/AGENTS.md` and
2652
+ `{project_root}/AGENTS.md`, returning all that exist. If both are
2653
+ present, both are loaded and their instructions are combined, with
2654
+ `.deepagents/AGENTS.md` first.
2655
+
2656
+ Returns:
2657
+ Existing AGENTS.md paths.
2658
+
2659
+ Empty if neither file exists or not in a project, one entry if
2660
+ only one is present, or two entries if both locations have the
2661
+ file.
2662
+ """
2663
+ if not self.project_root:
2664
+ return []
2665
+ from deepagents_code.project_utils import find_project_agent_md
2666
+
2667
+ return find_project_agent_md(self.project_root)
2668
+
2669
+ @staticmethod
2670
+ def _is_valid_agent_name(agent_name: str) -> bool:
2671
+ """Validate to prevent invalid filesystem paths and security issues.
2672
+
2673
+ Returns:
2674
+ True if the agent name is valid, False otherwise.
2675
+ """
2676
+ if not agent_name or not agent_name.strip():
2677
+ return False
2678
+ # Allow only alphanumeric, hyphens, underscores, and whitespace
2679
+ return bool(re.match(r"^[a-zA-Z0-9_\-\s]+$", agent_name))
2680
+
2681
+ def get_agent_dir(self, agent_name: str) -> Path:
2682
+ """Get the global agent directory path.
2683
+
2684
+ Args:
2685
+ agent_name: Name of the agent
2686
+
2687
+ Returns:
2688
+ Path to ~/.deepagents/{agent_name}
2689
+
2690
+ Raises:
2691
+ ValueError: If the agent name contains invalid characters.
2692
+ """
2693
+ if not self._is_valid_agent_name(agent_name):
2694
+ msg = (
2695
+ f"Invalid agent name: {agent_name!r}. Agent names can only "
2696
+ "contain letters, numbers, hyphens, underscores, and spaces."
2697
+ )
2698
+ raise ValueError(msg)
2699
+ return Path.home() / ".zjcode" / agent_name
2700
+
2701
+ def ensure_agent_dir(self, agent_name: str) -> Path:
2702
+ """Ensure the global agent directory exists and return its path.
2703
+
2704
+ Args:
2705
+ agent_name: Name of the agent
2706
+
2707
+ Returns:
2708
+ Path to ~/.deepagents/{agent_name}
2709
+
2710
+ Raises:
2711
+ ValueError: If the agent name contains invalid characters.
2712
+ """
2713
+ if not self._is_valid_agent_name(agent_name):
2714
+ msg = (
2715
+ f"Invalid agent name: {agent_name!r}. Agent names can only "
2716
+ "contain letters, numbers, hyphens, underscores, and spaces."
2717
+ )
2718
+ raise ValueError(msg)
2719
+ agent_dir = self.get_agent_dir(agent_name)
2720
+ agent_dir.mkdir(parents=True, exist_ok=True)
2721
+ return agent_dir
2722
+
2723
+ def get_user_skills_dir(self, agent_name: str) -> Path:
2724
+ """Get user-level skills directory path for a specific agent.
2725
+
2726
+ Args:
2727
+ agent_name: Name of the agent
2728
+
2729
+ Returns:
2730
+ Path to ~/.deepagents/{agent_name}/skills/
2731
+ """
2732
+ return self.get_agent_dir(agent_name) / "skills"
2733
+
2734
+ def ensure_user_skills_dir(self, agent_name: str) -> Path:
2735
+ """Ensure user-level skills directory exists and return its path.
2736
+
2737
+ Args:
2738
+ agent_name: Name of the agent
2739
+
2740
+ Returns:
2741
+ Path to ~/.deepagents/{agent_name}/skills/
2742
+ """
2743
+ skills_dir = self.get_user_skills_dir(agent_name)
2744
+ skills_dir.mkdir(parents=True, exist_ok=True)
2745
+ return skills_dir
2746
+
2747
+ def get_project_skills_dir(self) -> Path | None:
2748
+ """Get project-level skills directory path.
2749
+
2750
+ Returns:
2751
+ Path to {project_root}/.deepagents/skills/, or None if not in a project
2752
+ """
2753
+ if not self.project_root:
2754
+ return None
2755
+ return self.project_root / ".deepagents" / "skills"
2756
+
2757
+ def ensure_project_skills_dir(self) -> Path | None:
2758
+ """Ensure project-level skills directory exists and return its path.
2759
+
2760
+ Returns:
2761
+ Path to {project_root}/.deepagents/skills/, or None if not in a project
2762
+ """
2763
+ if not self.project_root:
2764
+ return None
2765
+ skills_dir = self.get_project_skills_dir()
2766
+ if skills_dir is None:
2767
+ return None
2768
+ skills_dir.mkdir(parents=True, exist_ok=True)
2769
+ return skills_dir
2770
+
2771
+ def get_user_agents_dir(self, agent_name: str) -> Path:
2772
+ """Get user-level agents directory path for custom subagent definitions.
2773
+
2774
+ Args:
2775
+ agent_name: Name of the agent (e.g., "deepagents")
2776
+
2777
+ Returns:
2778
+ Path to ~/.deepagents/{agent_name}/agents/
2779
+ """
2780
+ return self.get_agent_dir(agent_name) / "agents"
2781
+
2782
+ def get_project_agents_dir(self) -> Path | None:
2783
+ """Get project-level agents directory path for custom subagent definitions.
2784
+
2785
+ Returns:
2786
+ Path to {project_root}/.deepagents/agents/, or None if not in a project
2787
+ """
2788
+ if not self.project_root:
2789
+ return None
2790
+ return self.project_root / ".deepagents" / "agents"
2791
+
2792
+ @property
2793
+ def user_agents_dir(self) -> Path:
2794
+ """Base user-level `.agents` directory (`~/.agents`).
2795
+
2796
+ Returns:
2797
+ Path to `~/.agents`
2798
+ """
2799
+ return Path.home() / ".agents"
2800
+
2801
+ def get_user_agent_skills_dir(self) -> Path:
2802
+ """Get user-level `~/.agents/skills/` directory.
2803
+
2804
+ This is a generic alias path for skills that is tool-agnostic.
2805
+
2806
+ Returns:
2807
+ Path to `~/.agents/skills/`
2808
+ """
2809
+ return self.user_agents_dir / "skills"
2810
+
2811
+ def get_project_agent_skills_dir(self) -> Path | None:
2812
+ """Get project-level `.agents/skills/` directory.
2813
+
2814
+ This is a generic alias path for skills that is tool-agnostic.
2815
+
2816
+ Returns:
2817
+ Path to `{project_root}/.agents/skills/`, or `None` if not in a project
2818
+ """
2819
+ if not self.project_root:
2820
+ return None
2821
+ return self.project_root / ".agents" / "skills"
2822
+
2823
+ @staticmethod
2824
+ def get_user_claude_skills_dir() -> Path:
2825
+ """Get user-level `~/.claude/skills/` directory (experimental).
2826
+
2827
+ Convenience bridge for cross-tool skill sharing with Claude Code.
2828
+ This is experimental and may be removed.
2829
+
2830
+ Returns:
2831
+ Path to `~/.claude/skills/`
2832
+ """
2833
+ return Path.home() / ".claude" / "skills"
2834
+
2835
+ def get_project_claude_skills_dir(self) -> Path | None:
2836
+ """Get project-level `.claude/skills/` directory (experimental).
2837
+
2838
+ Convenience bridge for cross-tool skill sharing with Claude Code.
2839
+ This is experimental and may be removed.
2840
+
2841
+ Returns:
2842
+ Path to `{project_root}/.claude/skills/`, or `None` if not in a project.
2843
+ """
2844
+ if not self.project_root:
2845
+ return None
2846
+ return self.project_root / ".claude" / "skills"
2847
+
2848
+ @staticmethod
2849
+ def get_built_in_skills_dir() -> Path:
2850
+ """Get the directory containing built-in skills that ship with the app.
2851
+
2852
+ Returns:
2853
+ Path to the `built_in_skills/` directory within the package.
2854
+ """
2855
+ return Path(__file__).parent / "built_in_skills"
2856
+
2857
+ def get_extra_skills_dirs(self) -> list[Path]:
2858
+ """Get user-configured extra skill directories.
2859
+
2860
+ Set via `DEEPAGENTS_CODE_EXTRA_SKILLS_DIRS` (colon-separated paths) or
2861
+ `[skills].extra_allowed_dirs` in `~/.deepagents/config.toml`.
2862
+
2863
+ Returns:
2864
+ List of extra skill directory paths, or empty list if not configured.
2865
+ """
2866
+ return self.extra_skills_dirs or []
2867
+
2868
+
2869
+ DANGEROUS_SHELL_PATTERNS = (
2870
+ "$(", # Command substitution
2871
+ "`", # Backtick command substitution
2872
+ "$'", # ANSI-C quoting (can encode dangerous chars via escape sequences)
2873
+ "\n", # Newline (command injection)
2874
+ "\r", # Carriage return (command injection)
2875
+ "\t", # Tab (can be used for injection in some shells)
2876
+ "<(", # Process substitution (input)
2877
+ ">(", # Process substitution (output)
2878
+ "<<<", # Here-string
2879
+ "<<", # Here-doc (can embed commands)
2880
+ ">>", # Append redirect
2881
+ ">", # Output redirect
2882
+ "<", # Input redirect
2883
+ "${", # Variable expansion with braces (can run commands via ${var:-$(cmd)})
2884
+ )
2885
+ """Literal substrings that indicate shell injection risk.
2886
+
2887
+ Used by `contains_dangerous_patterns` to reject commands that embed arbitrary
2888
+ execution via redirects, substitution operators, or control characters — even
2889
+ when the base command is on the allow-list.
2890
+ """
2891
+
2892
+ RECOMMENDED_SAFE_SHELL_COMMANDS = (
2893
+ # Directory listing
2894
+ "ls",
2895
+ "dir",
2896
+ # File content viewing (read-only)
2897
+ "cat",
2898
+ "head",
2899
+ "tail",
2900
+ # Text searching (read-only)
2901
+ "grep",
2902
+ "wc",
2903
+ "strings",
2904
+ # Text processing (read-only, no shell execution)
2905
+ "cut",
2906
+ "tr",
2907
+ "diff",
2908
+ "md5sum",
2909
+ "sha256sum",
2910
+ # Path utilities
2911
+ "pwd",
2912
+ "which",
2913
+ # System info (read-only)
2914
+ "uname",
2915
+ "hostname",
2916
+ "whoami",
2917
+ "id",
2918
+ "groups",
2919
+ "uptime",
2920
+ "nproc",
2921
+ "lscpu",
2922
+ "lsmem",
2923
+ # Process viewing (read-only)
2924
+ "ps",
2925
+ )
2926
+ """Read-only commands auto-approved in non-interactive mode.
2927
+
2928
+ Only includes readers and formatters — shells, editors, interpreters, package
2929
+ managers, network tools, archivers, and anything on GTFOBins/LOOBins is
2930
+ intentionally excluded. File-write and injection vectors are blocked separately
2931
+ by `DANGEROUS_SHELL_PATTERNS`.
2932
+ """
2933
+
2934
+
2935
+ def contains_dangerous_patterns(command: str) -> bool:
2936
+ """Check if a command contains dangerous shell patterns.
2937
+
2938
+ These patterns can be used to bypass allow-list validation by embedding
2939
+ arbitrary commands within seemingly safe commands. The check includes
2940
+ both literal substring patterns (redirects, substitution operators, etc.)
2941
+ and regex patterns for bare variable expansion (`$VAR`) and the background
2942
+ operator (`&`).
2943
+
2944
+ Args:
2945
+ command: The shell command to check.
2946
+
2947
+ Returns:
2948
+ True if dangerous patterns are found, False otherwise.
2949
+ """
2950
+ if any(pattern in command for pattern in DANGEROUS_SHELL_PATTERNS):
2951
+ return True
2952
+
2953
+ # Bare variable expansion ($VAR without braces) can leak sensitive paths.
2954
+ # We already block ${ and $( above; this catches plain $HOME, $IFS, etc.
2955
+ if re.search(r"\$[A-Za-z_]", command):
2956
+ return True
2957
+
2958
+ # Standalone & (background execution) changes the execution model and
2959
+ # should not be allowed. We check for & that is NOT part of &&.
2960
+ return bool(re.search(r"(?<![&])&(?![&])", command))
2961
+
2962
+
2963
+ def is_shell_command_allowed(command: str, allow_list: list[str] | None) -> bool:
2964
+ """Check if a shell command is in the allow-list.
2965
+
2966
+ The allow-list matches against the first token of the command (the executable
2967
+ name). This allows read-only commands like ls, cat, grep, etc. to be
2968
+ auto-approved.
2969
+
2970
+ When `allow_list` is the `SHELL_ALLOW_ALL` sentinel, all non-empty commands
2971
+ are approved unconditionally — dangerous pattern checks are skipped.
2972
+
2973
+ SECURITY: For regular allow-lists, this function rejects commands containing
2974
+ dangerous shell patterns (command substitution, redirects, process
2975
+ substitution, etc.) BEFORE parsing, to prevent injection attacks that could
2976
+ bypass the allow-list.
2977
+
2978
+ Args:
2979
+ command: The full shell command to check.
2980
+ allow_list: List of allowed command names (e.g., `["ls", "cat", "grep"]`),
2981
+ the `SHELL_ALLOW_ALL` sentinel to allow any command, or `None`.
2982
+
2983
+ Returns:
2984
+ `True` if the command is allowed, `False` otherwise.
2985
+ """
2986
+ if not allow_list or not command or not command.strip():
2987
+ return False
2988
+
2989
+ # SHELL_ALLOW_ALL sentinel — skip pattern and token checks
2990
+ if isinstance(allow_list, _ShellAllowAll):
2991
+ return True
2992
+
2993
+ # SECURITY: Check for dangerous patterns BEFORE any parsing
2994
+ # This prevents injection attacks like: ls "$(rm -rf /)"
2995
+ if contains_dangerous_patterns(command):
2996
+ return False
2997
+
2998
+ allow_set = set(allow_list)
2999
+
3000
+ # Extract the first command token
3001
+ # Handle pipes and other shell operators by checking each command in the pipeline
3002
+ # Split by compound operators first (&&, ||), then single-char operators (|, ;).
3003
+ # Note: standalone & (background) is blocked by contains_dangerous_patterns above.
3004
+ segments = re.split(r"&&|\|\||[|;]", command)
3005
+
3006
+ # Track if we found at least one valid command
3007
+ found_command = False
3008
+
3009
+ for raw_segment in segments:
3010
+ segment = raw_segment.strip()
3011
+ if not segment:
3012
+ continue
3013
+
3014
+ try:
3015
+ # Try to parse as shell command to extract the executable name
3016
+ tokens = shlex.split(segment)
3017
+ if tokens:
3018
+ found_command = True
3019
+ cmd_name = tokens[0]
3020
+ # Check if this command is in the allow set
3021
+ if cmd_name not in allow_set:
3022
+ return False
3023
+ except ValueError:
3024
+ # If we can't parse it, be conservative and require approval
3025
+ return False
3026
+
3027
+ # All segments are allowed (and we found at least one command)
3028
+ return found_command
3029
+
3030
+
3031
+ def get_langsmith_project_name() -> str | None:
3032
+ """Resolve the LangSmith project name if tracing is configured.
3033
+
3034
+ Checks for the required API key and tracing environment variables.
3035
+ When both are present, resolves the project name with priority:
3036
+ `settings.deepagents_langchain_project` (from
3037
+ `DEEPAGENTS_CODE_LANGSMITH_PROJECT`), then `LANGSMITH_PROJECT` from the
3038
+ environment (note: this may already have been overridden at bootstrap time
3039
+ to match `DEEPAGENTS_CODE_LANGSMITH_PROJECT`), then `'deepagents-code'`.
3040
+
3041
+ Returns:
3042
+ Project name string when LangSmith tracing is active, None otherwise.
3043
+ """
3044
+ from deepagents_code.config_manifest import LANGSMITH_PROJECT_DEFAULT
3045
+ from deepagents_code.model_config import resolve_env_var
3046
+
3047
+ langsmith_key = resolve_env_var("LANGSMITH_API_KEY") or resolve_env_var(
3048
+ "LANGCHAIN_API_KEY"
3049
+ )
3050
+ langsmith_tracing = resolve_env_var("LANGSMITH_TRACING") or resolve_env_var(
3051
+ "LANGCHAIN_TRACING_V2"
3052
+ )
3053
+ if not (langsmith_key and langsmith_tracing):
3054
+ return None
3055
+
3056
+ return (
3057
+ _get_settings().deepagents_langchain_project
3058
+ or os.environ.get("LANGSMITH_PROJECT")
3059
+ or LANGSMITH_PROJECT_DEFAULT
3060
+ )
3061
+
3062
+
3063
+ def is_langsmith_redaction_enabled() -> bool:
3064
+ """Return whether LangSmith secret redaction is enabled for agent traces."""
3065
+ from deepagents_code.config_manifest import (
3066
+ get_option,
3067
+ load_config_toml,
3068
+ resolve_scalar,
3069
+ )
3070
+
3071
+ option = get_option("tracing.langsmith_redact")
3072
+ if option is None:
3073
+ return True
3074
+ value, _ = resolve_scalar(option, toml_data=load_config_toml())
3075
+ return bool(value)
3076
+
3077
+
3078
+ def is_memory_auto_save_enabled() -> bool:
3079
+ """Return whether the agent should proactively save learnings to memory.
3080
+
3081
+ Resolves the `memory.auto_save` option from env/`config.toml`, defaulting to
3082
+ enabled. When disabled, memory is still loaded into context but the agent is
3083
+ told not to auto-save.
3084
+ """
3085
+ from deepagents_code.config_manifest import (
3086
+ get_option,
3087
+ load_config_toml,
3088
+ resolve_scalar,
3089
+ )
3090
+
3091
+ option = get_option("memory.auto_save")
3092
+ if option is None:
3093
+ return True
3094
+ value, _ = resolve_scalar(option, toml_data=load_config_toml())
3095
+ return bool(value)
3096
+
3097
+
3098
+ def configure_langsmith_secret_redaction() -> bool:
3099
+ """Install the LangSmith SDK secret anonymizer for active agent tracing.
3100
+
3101
+ This is a fail-closed security control: when redaction is requested but the
3102
+ redacting client cannot be installed, tracing is disabled rather than risk
3103
+ uploading unredacted secrets to LangSmith.
3104
+
3105
+ Returns:
3106
+ `True` when a redacting LangSmith client was configured, `False` when
3107
+ tracing is inactive, has no upload target, redaction is disabled, or the
3108
+ redacting client could not be installed (tracing is then disabled).
3109
+ """
3110
+ from deepagents_code._env_vars import LANGSMITH_REDACT
3111
+
3112
+ env = dict(os.environ)
3113
+ # Cheap env-var checks first so the common (tracing-off) startup path skips
3114
+ # the TOML read in `is_langsmith_redaction_enabled`. These are plain env
3115
+ # reads with no failure mode of their own, so they stay outside the
3116
+ # fail-closed boundary: if there is no upload target, there is nothing to
3117
+ # protect.
3118
+ if not (_tracing_enabled_from(env) and _tracing_can_upload_from(env)):
3119
+ return False
3120
+
3121
+ # Everything from here on runs inside the fail-closed boundary: any
3122
+ # unexpected exception (including from the redaction-toggle lookup) disables
3123
+ # tracing rather than escaping and leaving tracing live but unredacted.
3124
+ try:
3125
+ if not is_langsmith_redaction_enabled():
3126
+ logger.warning(
3127
+ "LangSmith tracing is active but secret redaction is disabled "
3128
+ "via %s; secrets may be uploaded to traces unredacted.",
3129
+ LANGSMITH_REDACT,
3130
+ )
3131
+ return False
3132
+
3133
+ from langsmith import Client, configure
3134
+ from langsmith.anonymizer import create_secret_anonymizer
3135
+
3136
+ api_key = _resolve_env_var_from(
3137
+ env,
3138
+ "LANGSMITH_API_KEY",
3139
+ ) or _resolve_env_var_from(env, "LANGCHAIN_API_KEY")
3140
+ api_url = _tracing_endpoint_from(env)
3141
+ kwargs: dict[str, Any] = {"anonymizer": create_secret_anonymizer()}
3142
+ if api_key:
3143
+ kwargs["api_key"] = api_key
3144
+ if api_url:
3145
+ kwargs["api_url"] = api_url
3146
+ # Reinstall the redacting client on every call rather than caching it:
3147
+ # callers such as `/auth` re-authentication may rotate credentials, and
3148
+ # a cached client could leave a stale or non-redacting client in place —
3149
+ # a fail-open risk this control exists to prevent.
3150
+ configure(client=Client(**kwargs))
3151
+ except Exception:
3152
+ logger.exception(
3153
+ "Failed to install LangSmith secret redaction; disabling tracing so "
3154
+ "unredacted secrets are not uploaded.",
3155
+ )
3156
+ _fail_closed_disable_tracing()
3157
+ return False
3158
+
3159
+ logger.info("LangSmith secret redaction enabled for agent traces.")
3160
+ return True
3161
+
3162
+
3163
+ def _fail_closed_disable_tracing() -> None:
3164
+ """Best-effort disable LangSmith tracing after a redaction setup failure.
3165
+
3166
+ The SDK's global tracing switch (`configure(enabled=False)`) is the primary,
3167
+ load-bearing control and is tried first. Clearing the canonical
3168
+ tracing-enable env vars (and their `DEEPAGENTS_CODE_`-prefixed forms) is only
3169
+ a last-resort fallback for the case where even that call fails (e.g. the
3170
+ `langsmith` import is broken): the LangChain tracer checks the global switch
3171
+ first but falls back to these env vars, so removing them helps prevent a
3172
+ newly created tracer from starting an unredacted upload. (It only helps —
3173
+ the SDK's env-var lookup is `lru_cache`d, so a value already read this
3174
+ process may still be served from cache; the global switch is the reliable
3175
+ stop.)
3176
+ """
3177
+ try:
3178
+ from langsmith import configure
3179
+
3180
+ configure(enabled=False)
3181
+ except Exception:
3182
+ logger.exception(
3183
+ "Failed to disable LangSmith tracing via the SDK after a redaction "
3184
+ "setup failure; clearing tracing env vars as a fallback.",
3185
+ )
3186
+ else:
3187
+ return
3188
+
3189
+ from deepagents_code.model_config import _ENV_PREFIX
3190
+
3191
+ for var in _TRACING_ENABLE_ENV_VARS:
3192
+ os.environ.pop(var, None)
3193
+ os.environ.pop(f"{_ENV_PREFIX}{var}", None)
3194
+
3195
+
3196
+ def get_langsmith_replica_projects() -> list[str]:
3197
+ """Extra LangSmith project names to dual-write agent traces to.
3198
+
3199
+ Parses `DEEPAGENTS_CODE_LANGSMITH_REPLICA_PROJECTS` (comma-separated) into a
3200
+ de-duplicated, order-preserving list.
3201
+
3202
+ Returns:
3203
+ Project names, or `[]` when the env var is unset or empty.
3204
+ """
3205
+ return _get_langsmith_replica_projects_from(dict(os.environ))
3206
+
3207
+
3208
+ def _get_langsmith_replica_projects_from(env: dict[str, str]) -> list[str]:
3209
+ """Parse replica project names from an environment snapshot.
3210
+
3211
+ Args:
3212
+ env: Environment mapping to read.
3213
+
3214
+ Returns:
3215
+ Project names, or `[]` when the env var is unset or empty.
3216
+ """
3217
+ from deepagents_code._env_vars import LANGSMITH_REPLICA_PROJECTS
3218
+
3219
+ raw = env.get(LANGSMITH_REPLICA_PROJECTS)
3220
+ if not raw:
3221
+ return []
3222
+ return list(dict.fromkeys(p.strip() for p in raw.split(",") if p.strip()))
3223
+
3224
+
3225
+ def get_langsmith_replica_project() -> str | None:
3226
+ """The single extra LangSmith project to mirror agent runs to, if configured.
3227
+
3228
+ dcode agent runs execute inside the LangGraph server subprocess, so the only
3229
+ way to mirror them to another project is the server's own replica path: the
3230
+ SDK forwards a `langsmith_tracing` project in the run-create request, and the
3231
+ server wraps the run in a `tracing_context` whose write replicas are that
3232
+ project plus the server's primary project. Client-side callbacks and
3233
+ `tracing_context(replicas=...)` cannot reach the run because it is created
3234
+ server-side, not in the app process.
3235
+
3236
+ Implementation detail (subject to change): as of `langgraph-api` 0.10.0 this
3237
+ happens in `langgraph_api.stream` and `langgraph_api.models.run`.
3238
+
3239
+ The server mirrors to exactly one extra project, so when
3240
+ `DEEPAGENTS_CODE_LANGSMITH_REPLICA_PROJECTS` lists several, only the first is
3241
+ used and the rest are dropped with a warning.
3242
+
3243
+ Returns:
3244
+ The first configured replica project name, or `None` when none are set.
3245
+ """
3246
+ extras = get_langsmith_replica_projects()
3247
+ return _get_first_langsmith_replica_project(extras)
3248
+
3249
+
3250
+ def _get_first_langsmith_replica_project(extras: list[str]) -> str | None:
3251
+ """Return the first configured LangSmith replica project, if any.
3252
+
3253
+ Args:
3254
+ extras: Parsed replica project names.
3255
+
3256
+ Returns:
3257
+ The first configured replica project name, or `None` when none are set.
3258
+ """
3259
+ if not extras:
3260
+ return None
3261
+ if len(extras) > 1:
3262
+ logger.warning(
3263
+ "DEEPAGENTS_CODE_LANGSMITH_REPLICA_PROJECTS lists %d projects, but the "
3264
+ "LangGraph server mirrors runs to only one extra project; tracing to "
3265
+ "%r and ignoring %s.",
3266
+ len(extras),
3267
+ extras[0],
3268
+ extras[1:],
3269
+ )
3270
+ return extras[0]
3271
+
3272
+
3273
+ _TRACING_BRIDGED_ENABLE_ENV_VARS = ("LANGSMITH_TRACING", "LANGCHAIN_TRACING_V2")
3274
+ """Tracing flags bootstrap propagates from a `DEEPAGENTS_CODE_` prefix.
3275
+
3276
+ `dcode doctor` runs before `_ensure_bootstrap` bridges these to their canonical
3277
+ names, so it must resolve them prefix-aware (via `resolve_env_var`) to predict
3278
+ the runtime's effective state. The remaining flags in `_TRACING_ENABLE_ENV_VARS`
3279
+ are not bridged, so only their canonical form takes effect.
3280
+ """
3281
+
3282
+
3283
+ def _tracing_enabled_from(env: dict[str, str]) -> bool:
3284
+ """Return whether tracing is (or will be) enabled, prefix-aware.
3285
+
3286
+ Mirrors the runtime: `DEEPAGENTS_CODE_`-prefixed forms of the bridged flags
3287
+ count (bootstrap propagates them), while the non-bridged flags are honored
3288
+ only in their canonical form.
3289
+
3290
+ Args:
3291
+ env: Environment mapping to read.
3292
+ """
3293
+ from deepagents_code._env_vars import classify_env_bool
3294
+
3295
+ for var in _TRACING_BRIDGED_ENABLE_ENV_VARS:
3296
+ raw = _resolve_env_var_from(env, var)
3297
+ if raw is not None and classify_env_bool(raw):
3298
+ return True
3299
+ return any(
3300
+ classify_env_bool(env[var])
3301
+ for var in _TRACING_ENABLE_ENV_VARS
3302
+ if var not in _TRACING_BRIDGED_ENABLE_ENV_VARS and var in env
3303
+ )
3304
+
3305
+
3306
+ def _tracing_explicitly_disabled_from(env: dict[str, str]) -> bool:
3307
+ """Return whether a tracing flag is explicitly set to a recognized off value.
3308
+
3309
+ True only when tracing is not enabled and at least one tracing-enable flag
3310
+ carries a falsy token (`0`/`false`/`no`/`off`). An empty flag usually reads
3311
+ as "not configured" rather than "disabled", except when an empty prefixed
3312
+ bridged flag shadows a canonical truthy flag and therefore disables tracing.
3313
+
3314
+ Args:
3315
+ env: Environment mapping to read.
3316
+ """
3317
+ from deepagents_code._env_vars import classify_env_bool
3318
+ from deepagents_code.model_config import _ENV_PREFIX
3319
+
3320
+ if _tracing_enabled_from(env):
3321
+ return False
3322
+
3323
+ def _is_off(raw: str | None) -> bool:
3324
+ if raw is None or not raw.strip():
3325
+ return False
3326
+ return classify_env_bool(raw) is False
3327
+
3328
+ def _empty_prefixed_shadow_disables(var: str) -> bool:
3329
+ prefixed = f"{_ENV_PREFIX}{var}"
3330
+ if prefixed not in env or env[prefixed].strip():
3331
+ return False
3332
+ canonical = env.get(var)
3333
+ return canonical is not None and classify_env_bool(canonical) is True
3334
+
3335
+ for var in _TRACING_BRIDGED_ENABLE_ENV_VARS:
3336
+ if _is_off(_resolve_env_var_from(env, var)) or _empty_prefixed_shadow_disables(
3337
+ var
3338
+ ):
3339
+ return True
3340
+ return any(
3341
+ _is_off(env.get(var))
3342
+ for var in _TRACING_ENABLE_ENV_VARS
3343
+ if var not in _TRACING_BRIDGED_ENABLE_ENV_VARS
3344
+ )
3345
+
3346
+
3347
+ def _tracing_enabled() -> bool:
3348
+ """Return whether tracing is (or will be) enabled, prefix-aware."""
3349
+ return _tracing_enabled_from(dict(os.environ))
3350
+
3351
+
3352
+ def _tracing_has_credentials_from(env: dict[str, str]) -> bool:
3353
+ """Return whether a LangSmith API key (env or active profile) is available.
3354
+
3355
+ Both API-key vars are bridged from a `DEEPAGENTS_CODE_` prefix at bootstrap,
3356
+ so resolve them prefix-aware to match what the runtime will see.
3357
+
3358
+ Args:
3359
+ env: Environment mapping to read.
3360
+ """
3361
+ has_key = any(_resolve_env_var_from(env, var) for var in _TRACING_API_KEY_ENV_VARS)
3362
+ return has_key or _has_langsmith_profile_credentials(env)
3363
+
3364
+
3365
+ def _langsmith_runs_endpoint_urls_from(env: dict[str, str]) -> tuple[str, ...]:
3366
+ """Return the replica trace ingestion URLs configured via runs-endpoints.
3367
+
3368
+ Mirrors the LangSmith SDK's accepted `LANGSMITH_RUNS_ENDPOINTS` shapes: a
3369
+ JSON list of `{"api_url": "...", "api_key": "..."}` objects, or a JSON
3370
+ object mapping URL to API key. Invalid entries are ignored because the SDK
3371
+ ignores them too.
3372
+
3373
+ Args:
3374
+ env: Environment mapping to read.
3375
+
3376
+ Returns:
3377
+ The configured replica ingestion URLs, in configuration order.
3378
+ """
3379
+ raw = next(
3380
+ (
3381
+ env[var]
3382
+ for var in _TRACING_RUNS_ENDPOINTS_ENV_VARS
3383
+ if (env.get(var) or "").strip()
3384
+ ),
3385
+ None,
3386
+ )
3387
+ if raw is None:
3388
+ return ()
3389
+
3390
+ try:
3391
+ parsed = json.loads(raw)
3392
+ except (TypeError, ValueError):
3393
+ return ()
3394
+
3395
+ if isinstance(parsed, list):
3396
+ return tuple(
3397
+ item["api_url"]
3398
+ for item in parsed
3399
+ if isinstance(item, dict)
3400
+ and isinstance(item.get("api_url"), str)
3401
+ and isinstance(item.get("api_key"), str)
3402
+ )
3403
+ if isinstance(parsed, dict):
3404
+ return tuple(
3405
+ url
3406
+ for url, api_key in parsed.items()
3407
+ if isinstance(url, str) and isinstance(api_key, str)
3408
+ )
3409
+ return ()
3410
+
3411
+
3412
+ def _has_langsmith_runs_endpoints_from(env: dict[str, str]) -> bool:
3413
+ """Return whether replica trace ingestion targets are configured.
3414
+
3415
+ Args:
3416
+ env: Environment mapping to read.
3417
+
3418
+ Returns:
3419
+ `True` when a valid runs-endpoints configuration is present.
3420
+ """
3421
+ return bool(_langsmith_runs_endpoint_urls_from(env))
3422
+
3423
+
3424
+ def _tracing_can_upload_from(env: dict[str, str]) -> bool:
3425
+ """Return whether tracing has credentials or an ingestion endpoint.
3426
+
3427
+ Custom and replica endpoints are supported as keyless ingestion targets, so
3428
+ redaction must be configured whenever tracing could still upload without an
3429
+ API key.
3430
+
3431
+ Args:
3432
+ env: Environment mapping to read.
3433
+
3434
+ Returns:
3435
+ `True` when tracing has credentials or any ingestion endpoint set.
3436
+ """
3437
+ return (
3438
+ _tracing_has_credentials_from(env)
3439
+ or _tracing_endpoint_from(env) is not None
3440
+ or _has_langsmith_runs_endpoints_from(env)
3441
+ )
3442
+
3443
+
3444
+ def _tracing_endpoint_from(env: dict[str, str]) -> str | None:
3445
+ """Return a custom tracing endpoint (env or active profile), if configured.
3446
+
3447
+ The endpoint vars are not bridged from a `DEEPAGENTS_CODE_` prefix and the
3448
+ LangSmith SDK reads them canonically, so only the canonical names (plus the
3449
+ active profile's `api_url`) are consulted here.
3450
+
3451
+ Args:
3452
+ env: Environment mapping to read.
3453
+ """
3454
+ for var in _TRACING_ENDPOINT_ENV_VARS:
3455
+ value = (env.get(var) or "").strip()
3456
+ if value:
3457
+ return value
3458
+ config = _load_langsmith_profile_config(env)
3459
+ if config is not None:
3460
+ api_url = (config.api_url or "").strip()
3461
+ if api_url:
3462
+ return api_url
3463
+ return None
3464
+
3465
+
3466
+ def _resolve_tracing_project_from(env: dict[str, str]) -> tuple[str, bool]:
3467
+ """Resolve the project agent traces would route to, without bootstrap.
3468
+
3469
+ The reported project matches the `tracing.langsmith_project` manifest
3470
+ option's env precedence: the prefixed `DEEPAGENTS_CODE_LANGSMITH_PROJECT`
3471
+ (skipped when empty), then bare `LANGSMITH_PROJECT`, then the default.
3472
+ Unlike `resolve_env_var`, an empty prefixed value does not shadow a real
3473
+ `LANGSMITH_PROJECT`.
3474
+
3475
+ Args:
3476
+ env: Environment mapping to read.
3477
+
3478
+ Returns:
3479
+ The resolved project name and whether it fell back to the default
3480
+ because no project was explicitly configured.
3481
+ """
3482
+ from deepagents_code._env_vars import LANGSMITH_PROJECT
3483
+ from deepagents_code.config_manifest import LANGSMITH_PROJECT_DEFAULT
3484
+
3485
+ for name in (LANGSMITH_PROJECT, "LANGSMITH_PROJECT"):
3486
+ value = env.get(name)
3487
+ if value:
3488
+ return value, False
3489
+ return LANGSMITH_PROJECT_DEFAULT, True
3490
+
3491
+
3492
+ def _tracing_diagnostic_env() -> dict[str, str]:
3493
+ """Return the dotenv-aware environment snapshot for tracing diagnostics.
3494
+
3495
+ Returns:
3496
+ Environment mapping with project/global dotenv values applied using the
3497
+ same precedence as bootstrap, without mutating `os.environ`.
3498
+ """
3499
+ from deepagents_code.project_utils import get_server_project_context
3500
+
3501
+ ctx = get_server_project_context()
3502
+ return _preview_dotenv_environ(start_path=ctx.user_cwd if ctx else None)
3503
+
3504
+
3505
+ @dataclass(frozen=True)
3506
+ class TracingStatus:
3507
+ """Offline snapshot of LangSmith tracing configuration for diagnostics.
3508
+
3509
+ Carries only presence/identity facts — never API keys or other secret
3510
+ values — so it is safe to render in `dcode doctor` output.
3511
+ """
3512
+
3513
+ enabled: bool
3514
+ """Whether a tracing flag is truthy in the environment."""
3515
+
3516
+ explicitly_disabled: bool
3517
+ """Whether a tracing flag is explicitly set to a falsy value (vs. unset)."""
3518
+
3519
+ has_credentials: bool
3520
+ """Whether an API key or profile credential is resolvable."""
3521
+
3522
+ endpoint: str | None
3523
+ """Custom (self-hosted/proxied) endpoint URL, if one is configured."""
3524
+
3525
+ project: str | None
3526
+ """Resolved configured project name, independent of active trace ingestion."""
3527
+
3528
+ project_is_default: bool
3529
+ """Whether `project` is the built-in default rather than an explicit setting."""
3530
+
3531
+ replica_project: str | None
3532
+ """Extra project agent runs are mirrored to, if configured."""
3533
+
3534
+ runs_endpoints: tuple[str, ...] = ()
3535
+ """Replica ingestion URLs from `LANGSMITH_RUNS_ENDPOINTS`, if any."""
3536
+
3537
+ def __post_init__(self) -> None:
3538
+ """Reject the contradictory enabled/explicitly-disabled pair.
3539
+
3540
+ `enabled` and `explicitly_disabled` model a tri-state (enabled /
3541
+ explicitly disabled / not configured), so both being true is
3542
+ meaningless. Fail loud at construction rather than letting the illegal
3543
+ state flow through to the `dcode doctor` renderer.
3544
+
3545
+ Raises:
3546
+ ValueError: If both `enabled` and `explicitly_disabled` are true.
3547
+ """
3548
+ if self.enabled and self.explicitly_disabled:
3549
+ msg = "tracing cannot be both enabled and explicitly disabled"
3550
+ raise ValueError(msg)
3551
+
3552
+
3553
+ def get_tracing_status() -> TracingStatus:
3554
+ """Summarize LangSmith tracing configuration for diagnostics.
3555
+
3556
+ Reads only the local environment and the active LangSmith profile; never
3557
+ contacts the network and never exposes secret values. All fields are
3558
+ resolved prefix-/profile-aware so the report matches what the runtime does
3559
+ after bootstrap, even though `dcode doctor` runs before it.
3560
+
3561
+ Returns:
3562
+ A `TracingStatus` snapshot describing the current tracing setup.
3563
+ """
3564
+ env = _tracing_diagnostic_env()
3565
+ enabled = _tracing_enabled_from(env)
3566
+ has_credentials = _tracing_has_credentials_from(env)
3567
+ endpoint = _tracing_endpoint_from(env)
3568
+ project, project_is_default = _resolve_tracing_project_from(env)
3569
+ return TracingStatus(
3570
+ enabled=enabled,
3571
+ explicitly_disabled=_tracing_explicitly_disabled_from(env),
3572
+ has_credentials=has_credentials,
3573
+ endpoint=endpoint,
3574
+ project=project,
3575
+ project_is_default=project_is_default,
3576
+ replica_project=_get_first_langsmith_replica_project(
3577
+ _get_langsmith_replica_projects_from(env)
3578
+ ),
3579
+ runs_endpoints=_langsmith_runs_endpoint_urls_from(env),
3580
+ )
3581
+
3582
+
3583
+ class LangSmithLookupError(Exception):
3584
+ """Base class for typed LangSmith project URL lookup failures.
3585
+
3586
+ Concrete subclasses (`LangSmithImportError`, `LangSmithLookupTimeoutError`,
3587
+ `LangSmithApiError`) let interactive callers like `/trace` show the user
3588
+ the actual cause instead of collapsing every failure into a generic
3589
+ "could not reach LangSmith" message.
3590
+ """
3591
+
3592
+
3593
+ class LangSmithImportError(LangSmithLookupError):
3594
+ """The `langsmith` package is not installed."""
3595
+
3596
+
3597
+ class LangSmithLookupTimeoutError(LangSmithLookupError):
3598
+ """The LangSmith project URL lookup exceeded its hard timeout."""
3599
+
3600
+
3601
+ class LangSmithApiError(LangSmithLookupError):
3602
+ """The LangSmith SDK call raised — auth, 404, network, etc.
3603
+
3604
+ Wraps the underlying SDK exception in `__cause__`.
3605
+ """
3606
+
3607
+
3608
+ class LangSmithProjectNotFoundError(LangSmithApiError):
3609
+ """The LangSmith project does not exist yet (lookup returned 404).
3610
+
3611
+ Projects are created lazily on the first ingested trace, so this is
3612
+ expected before any run has flushed and should be surfaced as an
3613
+ informational message rather than an error.
3614
+ """
3615
+
3616
+
3617
+ def _is_langsmith_not_found(exc: Exception) -> bool:
3618
+ """Whether a LangSmith SDK error indicates the project does not exist.
3619
+
3620
+ Returns:
3621
+ `True` for a `LangSmithNotFoundError` (404), `False` otherwise.
3622
+ """
3623
+ try:
3624
+ from langsmith.utils import LangSmithNotFoundError
3625
+ except ImportError:
3626
+ return False
3627
+ return isinstance(exc, LangSmithNotFoundError)
3628
+
3629
+
3630
+ def _assemble_langsmith_thread_url(project_url: str, thread_id: str) -> str:
3631
+ """Format a LangSmith thread URL from a project URL prefix.
3632
+
3633
+ Args:
3634
+ project_url: Project URL prefix from `fetch_langsmith_project_url`
3635
+ (e.g. `https://smith.langchain.com/o/<org>/projects/p/<proj>`).
3636
+ thread_id: Thread identifier to append.
3637
+
3638
+ Returns:
3639
+ Full thread URL with the `deepagents-code` utm tag.
3640
+ """
3641
+ return f"{project_url.rstrip('/')}/t/{thread_id}?utm_source=deepagents-code"
3642
+
3643
+
3644
+ def fetch_langsmith_project_url_or_raise(project_name: str) -> str:
3645
+ """Fetch the LangSmith project URL, raising on any failure.
3646
+
3647
+ Successful results are cached at module level so repeated calls do not
3648
+ make additional network requests.
3649
+
3650
+ The network call runs in a daemon thread with a hard timeout of
3651
+ `_LANGSMITH_URL_LOOKUP_TIMEOUT_SECONDS`, so this function blocks the
3652
+ calling thread for at most that duration even if LangSmith is unreachable.
3653
+
3654
+ Args:
3655
+ project_name: LangSmith project name to look up.
3656
+
3657
+ Returns:
3658
+ Project URL string.
3659
+
3660
+ Raises:
3661
+ LangSmithImportError: `langsmith` is not installed.
3662
+ LangSmithLookupTimeoutError: lookup exceeded the hard timeout.
3663
+ LangSmithProjectNotFoundError: the project does not exist yet (404).
3664
+ LangSmithApiError: the SDK call raised (auth, network, etc.);
3665
+ wraps the original exception in `__cause__`.
3666
+ """
3667
+ global _langsmith_url_cache # noqa: PLW0603 # Module-level cache requires global statement
3668
+
3669
+ if _langsmith_url_cache is not None:
3670
+ cached_name, cached_url = _langsmith_url_cache
3671
+ if cached_name == project_name:
3672
+ return cached_url
3673
+ # Different project name — fall through to fetch.
3674
+
3675
+ try:
3676
+ from langsmith import Client
3677
+ except ImportError as exc:
3678
+ logger.debug(
3679
+ "langsmith package not installed; cannot fetch project URL for '%s'",
3680
+ project_name,
3681
+ exc_info=True,
3682
+ )
3683
+ msg = "langsmith package is not installed"
3684
+ raise LangSmithImportError(msg) from exc
3685
+
3686
+ result: str | None = None
3687
+ lookup_error: Exception | None = None
3688
+ done = threading.Event()
3689
+
3690
+ def _lookup_url() -> None:
3691
+ nonlocal result, lookup_error
3692
+ try:
3693
+ from deepagents_code.model_config import resolve_env_var
3694
+
3695
+ # Explicit api_key because Client() reads os.environ directly
3696
+ # and doesn't know about the DEEPAGENTS_CODE_ prefix.
3697
+ api_key = resolve_env_var("LANGSMITH_API_KEY") or resolve_env_var(
3698
+ "LANGCHAIN_API_KEY"
3699
+ )
3700
+ project = Client(api_key=api_key).read_project(project_name=project_name)
3701
+ result = project.url or None
3702
+ except Exception as exc: # noqa: BLE001 # LangSmith SDK error types are not stable
3703
+ lookup_error = exc
3704
+ finally:
3705
+ done.set()
3706
+
3707
+ thread = threading.Thread(target=_lookup_url, daemon=True)
3708
+ thread.start()
3709
+
3710
+ if not done.wait(_LANGSMITH_URL_LOOKUP_TIMEOUT_SECONDS):
3711
+ logger.debug(
3712
+ "Timed out fetching LangSmith project URL for '%s' after %.1fs",
3713
+ project_name,
3714
+ _LANGSMITH_URL_LOOKUP_TIMEOUT_SECONDS,
3715
+ )
3716
+ msg = (
3717
+ f"LangSmith project URL lookup timed out after "
3718
+ f"{_LANGSMITH_URL_LOOKUP_TIMEOUT_SECONDS:.1f}s"
3719
+ )
3720
+ raise LangSmithLookupTimeoutError(msg)
3721
+
3722
+ if lookup_error is not None:
3723
+ logger.debug(
3724
+ "Could not fetch LangSmith project URL for '%s'",
3725
+ project_name,
3726
+ exc_info=(
3727
+ type(lookup_error),
3728
+ lookup_error,
3729
+ lookup_error.__traceback__,
3730
+ ),
3731
+ )
3732
+ msg = str(lookup_error) or repr(lookup_error)
3733
+ if _is_langsmith_not_found(lookup_error):
3734
+ raise LangSmithProjectNotFoundError(msg) from lookup_error
3735
+ raise LangSmithApiError(msg) from lookup_error
3736
+
3737
+ if not result:
3738
+ # SDK returned a project with an empty URL — treat as an API anomaly.
3739
+ msg = f"LangSmith returned no URL for project '{project_name}'"
3740
+ raise LangSmithApiError(msg)
3741
+
3742
+ _langsmith_url_cache = (project_name, result)
3743
+ return result
3744
+
3745
+
3746
+ def fetch_langsmith_project_url(project_name: str) -> str | None:
3747
+ """Fetch the LangSmith project URL, returning None on any failure.
3748
+
3749
+ Thin back-compat wrapper around `fetch_langsmith_project_url_or_raise`
3750
+ for passive callers (status banners, non-interactive output) that just
3751
+ want a URL-or-nothing answer. Interactive callers that need to tell the
3752
+ user *why* the lookup failed should use the raising variant directly.
3753
+
3754
+ Args:
3755
+ project_name: LangSmith project name to look up.
3756
+
3757
+ Returns:
3758
+ Project URL string if found, None otherwise.
3759
+ """
3760
+ try:
3761
+ return fetch_langsmith_project_url_or_raise(project_name)
3762
+ except LangSmithLookupError:
3763
+ return None
3764
+
3765
+
3766
+ def build_langsmith_thread_url(thread_id: str) -> str | None:
3767
+ """Build a full LangSmith thread URL if tracing is configured.
3768
+
3769
+ Combines `get_langsmith_project_name` and `fetch_langsmith_project_url`
3770
+ into a single convenience helper.
3771
+
3772
+ Args:
3773
+ thread_id: Thread identifier to build the URL for.
3774
+
3775
+ Returns:
3776
+ Full thread URL string, or `None` if unavailable (LangSmith is not
3777
+ configured or the project URL cannot be resolved.)
3778
+ """
3779
+ project_name = get_langsmith_project_name()
3780
+ if not project_name:
3781
+ return None
3782
+
3783
+ project_url = fetch_langsmith_project_url(project_name)
3784
+ if not project_url:
3785
+ return None
3786
+
3787
+ return _assemble_langsmith_thread_url(project_url, thread_id)
3788
+
3789
+
3790
+ def reset_langsmith_url_cache() -> None:
3791
+ """Reset the LangSmith URL cache (for testing)."""
3792
+ global _langsmith_url_cache # noqa: PLW0603 # Module-level cache requires global statement
3793
+ _langsmith_url_cache = None
3794
+
3795
+
3796
+ def get_default_coding_instructions() -> str:
3797
+ """Get the default coding agent instructions.
3798
+
3799
+ These are the immutable base instructions that cannot be modified by the agent.
3800
+ Long-term memory (AGENTS.md) is handled separately by the middleware.
3801
+
3802
+ Returns:
3803
+ The default agent instructions as a string.
3804
+ """
3805
+ default_prompt_path = Path(__file__).parent / "default_agent_prompt.md"
3806
+ return default_prompt_path.read_text()
3807
+
3808
+
3809
+ _BEDROCK_REGION_PREFIXES = ("us.", "eu.", "apac.", "us-gov.")
3810
+ """Cross-region inference-profile prefixes that front a vendor namespace.
3811
+
3812
+ E.g. `us.anthropic.claude-3-5-sonnet-20241022-v2:0`. Only stripped when a vendor
3813
+ namespace follows, so a bare name merely starting with `us`/`eu` is untouched.
3814
+ """
3815
+
3816
+
3817
+ def _is_bedrock_model_id(model_lower: str) -> bool:
3818
+ """Return whether *model_lower* is a bare Bedrock model ID.
3819
+
3820
+ Bedrock IDs have the shape `[<region>.]<vendor>.<model>[:<version>]`, e.g.
3821
+ `meta.llama3-70b-instruct-v1:0` or the cross-region inference profile
3822
+ `us.anthropic.claude-3-5-sonnet-20241022-v2:0`. Rather than enumerate AWS's
3823
+ ever-growing vendor list, this keys off the structural signature: an
3824
+ alphanumeric vendor token immediately followed by a dot. Bare direct-API
3825
+ names don't fit -- they either have no dot (`mistral-large`, `command-r`),
3826
+ carry a hyphen before their version dot (`claude-3.5`, `gemini-2.5`), or are
3827
+ already claimed by an earlier prefix check (`gpt-4.1`). Case is folded by the
3828
+ caller, and the explicit `bedrock:<model>` syntax is handled upstream via
3829
+ `provider:model` parsing.
3830
+ """
3831
+ for region in _BEDROCK_REGION_PREFIXES:
3832
+ if model_lower.startswith(region):
3833
+ model_lower = model_lower.removeprefix(region)
3834
+ break
3835
+ vendor, dot, _ = model_lower.partition(".")
3836
+ return bool(dot) and vendor.isalnum()
3837
+
3838
+
3839
+ def detect_provider(model_name: str) -> str | None:
3840
+ """Auto-detect provider from model name.
3841
+
3842
+ Intentionally duplicates a subset of LangChain's
3843
+ `_attempt_infer_model_provider` because we need to resolve the provider
3844
+ **before** calling `init_chat_model` in order to:
3845
+
3846
+ 1. Build provider-specific kwargs (API base URLs, headers, etc.) that are
3847
+ passed *into* `init_chat_model`.
3848
+ 2. Validate credentials early to surface user-friendly errors.
3849
+
3850
+ Args:
3851
+ model_name: Model name to detect provider from.
3852
+
3853
+ Returns:
3854
+ Provider name inferred from the model name (some names, e.g. `claude`
3855
+ and `gemini`, are disambiguated using configured credentials), or
3856
+ `None` if the provider cannot be determined.
3857
+ """
3858
+ model_lower = model_name.lower()
3859
+
3860
+ if model_lower.startswith(("gpt-", "o1", "o3", "o4", "chatgpt", "text-davinci")):
3861
+ return "openai"
3862
+
3863
+ # Bedrock uses dotted, vendor-namespaced IDs. Match them before the bare
3864
+ # `mistral`/`deepseek` prefixes below (which would otherwise swallow
3865
+ # `mistral.`/`deepseek.` IDs) and before the fall-through `None`, so a
3866
+ # `:version` suffix is never misparsed as a `provider:model` separator.
3867
+ if _is_bedrock_model_id(model_lower):
3868
+ return "bedrock"
3869
+
3870
+ if model_lower.startswith("command"):
3871
+ return "cohere"
3872
+
3873
+ if model_lower.startswith(("mistral", "mixtral")):
3874
+ return "mistralai"
3875
+
3876
+ if model_lower.startswith("deepseek"):
3877
+ return "deepseek"
3878
+
3879
+ if model_lower.startswith("grok"):
3880
+ return "xai"
3881
+
3882
+ if model_lower.startswith("sonar"):
3883
+ return "perplexity"
3884
+
3885
+ if model_lower.startswith("claude"):
3886
+ s = _get_settings()
3887
+ if not s.has_anthropic and s.has_vertex_ai:
3888
+ return "google_vertexai"
3889
+ return "anthropic"
3890
+
3891
+ if model_lower.startswith("gemini"):
3892
+ s = _get_settings()
3893
+ if s.has_vertex_ai and not s.has_google:
3894
+ return "google_vertexai"
3895
+ return "google_genai"
3896
+
3897
+ if model_lower.startswith(("nemotron", "nvidia/")):
3898
+ return "nvidia"
3899
+
3900
+ # Fireworks uses fully-qualified IDs like `accounts/fireworks/models/<name>`.
3901
+ # `init_chat_model` can infer the provider from this prefix, but the inferred
3902
+ # name is not exposed on the returned model, so resolving it here keeps the
3903
+ # provider visible to every downstream consumer of `detect_provider` (e.g.
3904
+ # the `/model` confirmation, the status bar, and the early credential check)
3905
+ # instead of leaving the raw ID unprefixed.
3906
+ if model_lower.startswith(FIREWORKS_PROVIDER_ID_PREFIX):
3907
+ return "fireworks"
3908
+
3909
+ return None
3910
+
3911
+
3912
+ def _format_provider_default_model_spec(provider: str, model: object) -> str | None:
3913
+ from deepagents_code.model_config import ModelSpec
3914
+
3915
+ if not isinstance(model, str):
3916
+ return None
3917
+ stripped = model.strip()
3918
+ if not stripped:
3919
+ return None
3920
+ if ModelSpec.try_parse(stripped):
3921
+ return stripped
3922
+ return f"{provider}:{stripped}"
3923
+
3924
+
3925
+ def _get_configured_provider_default_model(config: ModelConfig) -> str | None:
3926
+ from deepagents_code.model_config import get_provider_auth_status
3927
+
3928
+ for provider, provider_config in config.providers.items():
3929
+ if not config.is_provider_enabled(provider):
3930
+ continue
3931
+ spec = _format_provider_default_model_spec(
3932
+ provider,
3933
+ provider_config.get("default_model"),
3934
+ )
3935
+ if spec is None:
3936
+ continue
3937
+ if get_provider_auth_status(provider).as_legacy_bool() is not False:
3938
+ return spec
3939
+ return None
3940
+
3941
+
3942
+ def _model_spec_auth_is_usable(model_spec: str) -> bool:
3943
+ from deepagents_code.model_config import ModelSpec, get_provider_auth_status
3944
+
3945
+ parsed = ModelSpec.try_parse(model_spec)
3946
+ if not parsed:
3947
+ return True
3948
+ return get_provider_auth_status(parsed.provider).as_legacy_bool() is not False
3949
+
3950
+
3951
+ def _get_default_model_spec() -> str:
3952
+ """Get default model specification based on available credentials.
3953
+
3954
+ Checks in order:
3955
+
3956
+ 1. `[models].default` in config file (user's intentional preference).
3957
+ 2. Provider-level `default_model` entries for configured providers.
3958
+ 3. `[models].recent` in config file (last `/model` switch).
3959
+ 4. Auto-detection based on available API credentials.
3960
+
3961
+ Returns:
3962
+ Model specification in `provider:model` format.
3963
+
3964
+ Raises:
3965
+ NoCredentialsConfiguredError: If no credentials are configured for any
3966
+ of the auto-detectable providers. Callers may catch this to defer
3967
+ startup and prompt for credentials interactively.
3968
+ """
3969
+ from deepagents_code.model_config import (
3970
+ ModelConfig,
3971
+ NoCredentialsConfiguredError,
3972
+ get_provider_auth_status,
3973
+ )
3974
+
3975
+ config = ModelConfig.load()
3976
+ if config.default_model:
3977
+ return config.default_model
3978
+
3979
+ provider_default = _get_configured_provider_default_model(config)
3980
+ if provider_default:
3981
+ return provider_default
3982
+
3983
+ if config.recent_model and _model_spec_auth_is_usable(config.recent_model):
3984
+ return config.recent_model
3985
+
3986
+ if get_provider_auth_status("openai").as_legacy_bool() is True:
3987
+ return "openai:gpt-5.5"
3988
+ if get_provider_auth_status("anthropic").as_legacy_bool() is True:
3989
+ return "anthropic:claude-opus-4-7"
3990
+ if get_provider_auth_status("google_genai").as_legacy_bool() is True:
3991
+ return "google_genai:gemini-3.1-pro-preview"
3992
+
3993
+ msg = (
3994
+ "No credentials configured. Please set one of: "
3995
+ "ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY"
3996
+ )
3997
+ raise NoCredentialsConfiguredError(msg)
3998
+
3999
+
4000
+ _OPENROUTER_APP_URL = "https://pypi.org/project/deepagents-code/"
4001
+ """Default `app_url` (maps to `HTTP-Referer`) for OpenRouter attribution.
4002
+
4003
+ See https://openrouter.ai/docs/app-attribution for details.
4004
+ """
4005
+
4006
+ _OPENROUTER_APP_TITLE = "Deep Agents Code"
4007
+ """Default `app_title` (maps to `X-Title`) for OpenRouter attribution."""
4008
+
4009
+ _OPENROUTER_APP_CATEGORIES: list[str] = ["cli-agent"]
4010
+ """Default `app_categories` (maps to `X-OpenRouter-Categories`) for OpenRouter."""
4011
+
4012
+ _cli_openrouter_profile_registered = False
4013
+ """Process-wide guard so the app's OpenRouter profile is registered exactly once."""
4014
+
4015
+
4016
+ def _cli_openrouter_attribution_kwargs() -> dict[str, Any]:
4017
+ """App-specific OpenRouter attribution kwargs.
4018
+
4019
+ Layered on top of the SDK's built-in factory via profile stacking; these
4020
+ values override the SDK defaults but still sit beneath any caller-supplied
4021
+ `kwargs` (i.e. `config.toml`-resolved values), preserving the precedence
4022
+ documented on `apply_provider_profile`.
4023
+
4024
+ Returns:
4025
+ Mapping of `app_url` and `app_title` to spread into `init_chat_model`.
4026
+ """
4027
+ return {
4028
+ "app_url": _OPENROUTER_APP_URL,
4029
+ "app_title": _OPENROUTER_APP_TITLE,
4030
+ }
4031
+
4032
+
4033
+ def _ensure_cli_openrouter_profile_registered() -> None:
4034
+ """Stack the app's OpenRouter attribution onto the SDK's built-in profile.
4035
+
4036
+ Stacking (vs. duplicating the inline `_get_provider_kwargs` path) means the
4037
+ SDK's `pre_init` version check fires exactly once and the app's app-
4038
+ attribution defaults are composed via the same `apply_provider_profile`
4039
+ path used for every other provider. `register_provider_profile` merges on
4040
+ top of the existing built-in registration: the app's `init_kwargs` and
4041
+ factory output win on shared keys, while the built-in's `pre_init` and
4042
+ factory still chain.
4043
+ """
4044
+ global _cli_openrouter_profile_registered # noqa: PLW0603
4045
+ if _cli_openrouter_profile_registered:
4046
+ return
4047
+
4048
+ from deepagents.profiles.provider import ProviderProfile, register_provider_profile
4049
+
4050
+ register_provider_profile(
4051
+ "openrouter",
4052
+ ProviderProfile(
4053
+ init_kwargs={"app_categories": _OPENROUTER_APP_CATEGORIES},
4054
+ init_kwargs_factory=_cli_openrouter_attribution_kwargs,
4055
+ ),
4056
+ )
4057
+ _cli_openrouter_profile_registered = True
4058
+
4059
+
4060
+ def _get_provider_kwargs(
4061
+ provider: str, *, model_name: str | None = None
4062
+ ) -> dict[str, Any]:
4063
+ """Get provider-specific kwargs from the config file.
4064
+
4065
+ Reads `base_url`, `api_key_env`, and the `params` table from the user's
4066
+ `config.toml` for the given provider.
4067
+
4068
+ When `model_name` is provided, per-model overrides from the `params`
4069
+ sub-table are shallow-merged on top.
4070
+
4071
+ Args:
4072
+ provider: Provider name (e.g., openai, anthropic, fireworks, ollama).
4073
+ model_name: Optional model name for per-model overrides.
4074
+
4075
+ Returns:
4076
+ Dictionary of provider-specific kwargs.
4077
+ """
4078
+ from deepagents_code.model_config import ModelConfig
4079
+
4080
+ config = ModelConfig.load()
4081
+ result: dict[str, Any] = config.get_kwargs(provider, model_name=model_name)
4082
+ base_url = config.get_base_url(provider)
4083
+ if base_url:
4084
+ result["base_url"] = base_url
4085
+ from deepagents_code.model_config import (
4086
+ OPTIONAL_AUTH_ENV,
4087
+ PROVIDER_API_KEY_ENV,
4088
+ resolve_env_var,
4089
+ )
4090
+
4091
+ api_key_env = config.get_api_key_env(provider)
4092
+ if not api_key_env:
4093
+ api_key_env = PROVIDER_API_KEY_ENV.get(provider)
4094
+ if api_key_env:
4095
+ logger.debug(
4096
+ "No api_key_env in config.toml for '%s';"
4097
+ " using hardcoded provider env var",
4098
+ provider,
4099
+ )
4100
+ if api_key_env:
4101
+ api_key = resolve_env_var(api_key_env)
4102
+ if api_key:
4103
+ result["api_key"] = api_key
4104
+
4105
+ # `langchain-ollama` has no `api_key` kwarg; hosted Ollama (Cloud or
4106
+ # gateway) needs the bearer token threaded through `client_kwargs.headers`.
4107
+ if provider == "ollama":
4108
+ optional_env = OPTIONAL_AUTH_ENV.get(provider)
4109
+ optional_key = resolve_env_var(optional_env) if optional_env else None
4110
+ if optional_key:
4111
+ client_kwargs = result.get("client_kwargs")
4112
+ if client_kwargs is not None and not isinstance(client_kwargs, dict):
4113
+ logger.warning(
4114
+ "Provider 'ollama' has non-mapping client_kwargs (%s);"
4115
+ " skipping Authorization header injection",
4116
+ type(client_kwargs).__name__,
4117
+ )
4118
+ else:
4119
+ client_kwargs = dict(client_kwargs) if client_kwargs else {}
4120
+ headers = client_kwargs.get("headers")
4121
+ if headers is not None and not isinstance(headers, dict):
4122
+ logger.warning(
4123
+ "Provider 'ollama' has non-mapping client_kwargs.headers"
4124
+ " (%s); skipping Authorization header injection",
4125
+ type(headers).__name__,
4126
+ )
4127
+ else:
4128
+ headers = dict(headers) if headers else {}
4129
+ has_auth_header = any(
4130
+ isinstance(k, str) and k.lower() == "authorization"
4131
+ for k in headers
4132
+ )
4133
+ if not has_auth_header:
4134
+ headers["Authorization"] = f"Bearer {optional_key}"
4135
+ client_kwargs["headers"] = headers
4136
+ result["client_kwargs"] = client_kwargs
4137
+
4138
+ retry_section = _read_config_toml_retries()
4139
+ retry_kwargs = _resolve_retry_kwargs(retry_section, provider)
4140
+ for key, value in retry_kwargs.items():
4141
+ result.setdefault(key, value)
4142
+
4143
+ # For OpenAI-compatible providers (built-in `openai` or any custom provider
4144
+ # whose `class_path` targets `langchain_openai:ChatOpenAI` / a subclass),
4145
+ # default `stream_usage=True` so the final stream chunk carries
4146
+ # `usage_metadata`. Without this the `stream_options.include_usage` flag is
4147
+ # not sent and third-party gateways (Volcengine ark, DeepSeek, SiliconFlow,
4148
+ # …) drop token counts, which makes the per-call info line show only
4149
+ # `model / finish / elapsed`. `setdefault` lets users override via
4150
+ # `params.stream_usage = false` in `config.toml` or `--model-params`.
4151
+ class_path = config.get_class_path(provider) if provider else None
4152
+ if provider == "openai" or (
4153
+ isinstance(class_path, str) and class_path.endswith(":ChatOpenAI")
4154
+ ):
4155
+ result.setdefault("stream_usage", True)
4156
+
4157
+ return result
4158
+
4159
+
4160
+ def _create_model_from_class(
4161
+ class_path: str,
4162
+ model_name: str,
4163
+ provider: str,
4164
+ kwargs: dict[str, Any],
4165
+ ) -> BaseChatModel:
4166
+ """Import and instantiate a custom `BaseChatModel` class.
4167
+
4168
+ Args:
4169
+ class_path: Fully-qualified class in `module.path:ClassName` format.
4170
+ model_name: Model identifier to pass as `model` kwarg.
4171
+ provider: Provider name (for error messages).
4172
+ kwargs: Additional keyword arguments for the constructor.
4173
+
4174
+ Returns:
4175
+ Instantiated `BaseChatModel`.
4176
+
4177
+ Raises:
4178
+ ModelConfigError: If the class cannot be imported, is not a
4179
+ `BaseChatModel` subclass, or fails to instantiate.
4180
+ """
4181
+ from langchain_core.language_models import (
4182
+ BaseChatModel as _BaseChatModel, # Runtime import; module level is typing only
4183
+ )
4184
+
4185
+ from deepagents_code.model_config import ModelConfigError
4186
+
4187
+ if ":" not in class_path:
4188
+ msg = (
4189
+ f"Invalid class_path '{class_path}' for provider '{provider}': "
4190
+ "must be in module.path:ClassName format"
4191
+ )
4192
+ raise ModelConfigError(msg)
4193
+
4194
+ module_path, class_name = class_path.rsplit(":", 1)
4195
+
4196
+ try:
4197
+ module = importlib.import_module(module_path)
4198
+ except ImportError as e:
4199
+ msg = f"Could not import module '{module_path}' for provider '{provider}': {e}"
4200
+ raise ModelConfigError(msg) from e
4201
+
4202
+ cls = getattr(module, class_name, None)
4203
+ if cls is None:
4204
+ msg = (
4205
+ f"Class '{class_name}' not found in module '{module_path}' "
4206
+ f"for provider '{provider}'"
4207
+ )
4208
+ raise ModelConfigError(msg)
4209
+
4210
+ if not (isinstance(cls, type) and issubclass(cls, _BaseChatModel)):
4211
+ msg = (
4212
+ f"'{class_path}' is not a BaseChatModel subclass (got {type(cls).__name__})"
4213
+ )
4214
+ raise ModelConfigError(msg)
4215
+
4216
+ try:
4217
+ return cls(model=model_name, **kwargs)
4218
+ except Exception as e:
4219
+ msg = f"Failed to instantiate '{class_path}' for '{provider}:{model_name}': {e}"
4220
+ raise ModelConfigError(msg) from e
4221
+
4222
+
4223
+ def _create_model_via_init(
4224
+ model_name: str,
4225
+ provider: str,
4226
+ kwargs: dict[str, Any],
4227
+ ) -> BaseChatModel:
4228
+ """Create a model using langchain's `init_chat_model`.
4229
+
4230
+ Args:
4231
+ model_name: Model identifier.
4232
+ provider: Provider name (may be empty for auto-detection).
4233
+ kwargs: Additional keyword arguments.
4234
+
4235
+ Returns:
4236
+ Instantiated `BaseChatModel`.
4237
+
4238
+ Raises:
4239
+ UnknownProviderError: When `provider` is empty and
4240
+ `init_chat_model` also fails to infer one. Carries the
4241
+ model spec and docs URL as attributes so the UI can render
4242
+ a clickable link.
4243
+ MissingProviderPackageError: When the provider's LangChain package
4244
+ is not installed. Carries the `provider` and `package` to install
4245
+ so the UI can render a targeted recovery hint.
4246
+ ModelConfigError: On other import, value, or runtime errors.
4247
+ """
4248
+ from langchain.chat_models import init_chat_model
4249
+
4250
+ from deepagents_code.model_config import (
4251
+ MissingProviderPackageError,
4252
+ ModelConfigError,
4253
+ UnknownProviderError,
4254
+ )
4255
+
4256
+ try:
4257
+ if provider:
4258
+ return init_chat_model(model_name, model_provider=provider, **kwargs)
4259
+ return init_chat_model(model_name, **kwargs)
4260
+ except ImportError as e:
4261
+ import importlib.util
4262
+
4263
+ package_map = {
4264
+ "anthropic": "langchain-anthropic",
4265
+ "openai": "langchain-openai",
4266
+ "google_genai": "langchain-google-genai",
4267
+ "google_vertexai": "langchain-google-vertexai",
4268
+ "nvidia": "langchain-nvidia-ai-endpoints",
4269
+ }
4270
+ package = package_map.get(provider, f"langchain-{provider}")
4271
+ # Convert pip package name to Python module name for import check.
4272
+ module_name = package.replace("-", "_")
4273
+ try:
4274
+ spec_found = importlib.util.find_spec(module_name) is not None
4275
+ except (ImportError, ValueError) as spec_exc:
4276
+ # A broken finder is indistinguishable from "not installed" here;
4277
+ # log so a real corruption doesn't masquerade as the missing-package
4278
+ # hint without leaving a trail.
4279
+ logger.debug(
4280
+ "find_spec failed for %s; treating provider package as missing: %s",
4281
+ module_name,
4282
+ spec_exc,
4283
+ )
4284
+ spec_found = False
4285
+ if spec_found:
4286
+ # Package is installed but an internal import failed — surface
4287
+ # the real error instead of the misleading "missing package" hint.
4288
+ msg = (
4289
+ f"Provider package '{package}' is installed but failed to "
4290
+ f"import for provider '{provider}': {e}"
4291
+ )
4292
+ else:
4293
+ from deepagents_code.extras_info import extra_for_package
4294
+
4295
+ extra = extra_for_package(package)
4296
+ if extra is not None:
4297
+ msg = (
4298
+ f"Missing package for provider '{provider}'. "
4299
+ f"Install: /install {extra}"
4300
+ )
4301
+ else:
4302
+ from deepagents_code.extras_info import ExtrasIntrospectionError
4303
+ from deepagents_code.update_check import (
4304
+ ToolRequirementIntrospectionError,
4305
+ install_package_command,
4306
+ )
4307
+
4308
+ try:
4309
+ install_cmd = install_package_command(package)
4310
+ except (
4311
+ ValueError,
4312
+ ExtrasIntrospectionError,
4313
+ ToolRequirementIntrospectionError,
4314
+ ) as exc:
4315
+ logger.debug(
4316
+ "install_package_command failed; falling back to "
4317
+ "manual hint: %s",
4318
+ exc,
4319
+ )
4320
+ install_hint = f"Install the '{package}' package manually"
4321
+ else:
4322
+ install_hint = f"Install with: {install_cmd}"
4323
+ msg = (
4324
+ f"Missing package for provider '{provider}'. "
4325
+ f"{install_hint}, then retry with `/model`."
4326
+ )
4327
+ raise MissingProviderPackageError(
4328
+ msg, provider=provider, package=package
4329
+ ) from e
4330
+ raise ModelConfigError(msg) from e
4331
+ except (ValueError, TypeError) as e:
4332
+ if not provider:
4333
+ # Both app auto-detection and `init_chat_model`'s own inference
4334
+ # failed; surface a structured error so the UI can render the
4335
+ # docs URL as a clickable link.
4336
+ raise UnknownProviderError(model_spec=model_name) from e
4337
+ spec = f"{provider}:{model_name}"
4338
+ msg = f"Invalid model configuration for '{spec}': {e}"
4339
+ raise ModelConfigError(msg) from e
4340
+ except Exception as e: # provider SDK auth/network errors
4341
+ spec = f"{provider}:{model_name}" if provider else model_name
4342
+ msg = f"Failed to initialize model '{spec}': {e}"
4343
+ raise ModelConfigError(msg) from e
4344
+
4345
+
4346
+ @dataclass(frozen=True)
4347
+ class ModelResult:
4348
+ """Result of creating a chat model, bundling the model with its metadata.
4349
+
4350
+ This separates model creation from settings mutation so callers can decide
4351
+ when to commit the metadata to global settings.
4352
+
4353
+ Attributes:
4354
+ model: The instantiated chat model.
4355
+ model_name: Resolved model name.
4356
+ provider: Resolved provider name.
4357
+ context_limit: Max input tokens from the model profile, or `None`.
4358
+ unsupported_modalities: Input modalities not indicated as supported by
4359
+ the model profile (e.g. `{"audio", "video"}`).
4360
+ """
4361
+
4362
+ model: BaseChatModel
4363
+ model_name: str
4364
+ provider: str
4365
+ context_limit: int | None = None
4366
+ unsupported_modalities: frozenset[str] = frozenset()
4367
+
4368
+ def apply_to_settings(self) -> None:
4369
+ """Commit this result's metadata to global `settings`."""
4370
+ s = _get_settings()
4371
+ s.model_name = self.model_name
4372
+ s.model_provider = self.provider
4373
+ s.model_context_limit = self.context_limit
4374
+ s.model_unsupported_modalities = self.unsupported_modalities
4375
+
4376
+
4377
+ def _apply_profile_overrides(
4378
+ model: BaseChatModel,
4379
+ overrides: dict[str, Any],
4380
+ model_name: str,
4381
+ *,
4382
+ label: str,
4383
+ raise_on_failure: bool = False,
4384
+ ) -> None:
4385
+ """Merge `overrides` into `model.profile`.
4386
+
4387
+ If the model already has a dict profile, overrides are layered on top
4388
+ so existing keys (e.g., `tool_calling`) are preserved unchanged.
4389
+
4390
+ Args:
4391
+ model: The chat model whose profile will be updated.
4392
+ overrides: Key/value pairs to merge into the profile.
4393
+ model_name: Model name used in log/error messages.
4394
+ label: Human-readable source label for messages
4395
+ (e.g., `"config.toml"`, `"CLI --profile-override"`).
4396
+ raise_on_failure: When `True`, raise `ModelConfigError` instead
4397
+ of logging a warning if assignment fails.
4398
+
4399
+ Raises:
4400
+ ModelConfigError: If `raise_on_failure` is `True` and the model
4401
+ rejects profile assignment.
4402
+ """
4403
+ from deepagents_code.model_config import ModelConfigError
4404
+
4405
+ logger.debug("Applying %s profile overrides: %s", label, overrides)
4406
+ profile = getattr(model, "profile", None)
4407
+ merged = {**profile, **overrides} if isinstance(profile, dict) else overrides
4408
+ try:
4409
+ model.profile = merged # ty: ignore[invalid-assignment]
4410
+ except (AttributeError, TypeError, ValueError) as exc:
4411
+ if raise_on_failure:
4412
+ msg = (
4413
+ f"Could not apply {label} to model '{model_name}': {exc}. "
4414
+ f"The model may not support profile assignment."
4415
+ )
4416
+ raise ModelConfigError(msg) from exc
4417
+ logger.warning(
4418
+ "Could not apply %s profile overrides to model '%s': %s. "
4419
+ "Overrides will be ignored.",
4420
+ label,
4421
+ model_name,
4422
+ exc,
4423
+ )
4424
+
4425
+
4426
+ def create_model(
4427
+ model_spec: str | None = None,
4428
+ *,
4429
+ extra_kwargs: dict[str, Any] | None = None,
4430
+ profile_overrides: dict[str, Any] | None = None,
4431
+ ) -> ModelResult:
4432
+ """Create a chat model.
4433
+
4434
+ Uses `init_chat_model` for standard providers, or imports a custom
4435
+ `BaseChatModel` subclass when the provider has a `class_path` in config.
4436
+
4437
+ Supports `provider:model` format (e.g., `'openai:gpt-5.5'`)
4438
+ for explicit provider selection, or bare model names for auto-detection.
4439
+
4440
+ Args:
4441
+ model_spec: Model specification in `provider:model` format (e.g.,
4442
+ `'anthropic:claude-sonnet-4-5'`, `'openai:gpt-5.5'`) or just the model
4443
+ name for auto-detection (e.g., `'claude-sonnet-4-5'`).
4444
+
4445
+ If not provided, uses environment-based defaults.
4446
+ extra_kwargs: Additional kwargs to pass to the model constructor.
4447
+
4448
+ These take highest priority, overriding values from the config file.
4449
+
4450
+ A `CLI_MAX_RETRIES_KEY` entry (set by the `--max-retries` flag) is
4451
+ treated specially: it is popped here and re-applied under the
4452
+ provider's resolved retry-param name with top precedence, rather than
4453
+ being forwarded verbatim to the constructor.
4454
+ profile_overrides: Extra profile fields from `--profile-override`.
4455
+
4456
+ Merged on top of config file profile overrides (dcode wins).
4457
+
4458
+ Returns:
4459
+ A `ModelResult` containing the model and its metadata.
4460
+
4461
+ Raises:
4462
+ ModelConfigError: If provider cannot be determined from the model name
4463
+ or required provider package is not installed.
4464
+ MissingCredentialsError: If no credentials are configured for the
4465
+ resolved provider.
4466
+
4467
+ Examples:
4468
+ >>> model = create_model("anthropic:claude-sonnet-4-5")
4469
+ >>> model = create_model("openai:gpt-5.5")
4470
+ >>> model = create_model("gpt-5.5") # Auto-detects openai
4471
+ >>> model = create_model() # Uses environment defaults
4472
+ """
4473
+ from deepagents_code.model_config import (
4474
+ IMPLICIT_AUTH_PROVIDERS,
4475
+ ModelConfig,
4476
+ ModelConfigError,
4477
+ ModelSpec,
4478
+ apply_stored_credentials,
4479
+ get_credential_env_var,
4480
+ has_provider_credentials,
4481
+ warn_on_split_credential_source,
4482
+ )
4483
+
4484
+ if not model_spec:
4485
+ model_spec = _get_default_model_spec()
4486
+
4487
+ # Parse provider:model syntax. Bedrock model IDs can include a version suffix
4488
+ # such as `:0`, so resolve their distinctive bare-ID prefixes unless the
4489
+ # parsed provider is explicitly configured.
4490
+ provider: str
4491
+ model_name: str
4492
+ config = ModelConfig.load()
4493
+ inferred_provider = detect_provider(model_spec)
4494
+ parsed = ModelSpec.try_parse(model_spec)
4495
+ if parsed and parsed.provider in config.providers:
4496
+ provider, model_name = parsed.provider, parsed.model
4497
+ elif inferred_provider == "bedrock":
4498
+ provider, model_name = inferred_provider, model_spec
4499
+ elif parsed:
4500
+ # Explicit provider:model (e.g., "anthropic:claude-sonnet-4-5")
4501
+ provider, model_name = parsed.provider, parsed.model
4502
+ elif ":" in model_spec:
4503
+ # Contains colon but ModelSpec rejected it (empty provider or model)
4504
+ _, _, after = model_spec.partition(":")
4505
+ if after:
4506
+ # Leading colon (e.g., ":claude-opus-4-6") — treat as bare model name
4507
+ model_name = after
4508
+ provider = detect_provider(model_name) or ""
4509
+ else:
4510
+ msg = (
4511
+ f"Invalid model spec '{model_spec}': model name is required "
4512
+ "(e.g., 'anthropic:claude-sonnet-4-5' or 'claude-sonnet-4-5')"
4513
+ )
4514
+ raise ModelConfigError(msg)
4515
+ else:
4516
+ # Bare model name — auto-detect provider or let init_chat_model infer
4517
+ model_name = model_spec
4518
+ provider = inferred_provider or ""
4519
+
4520
+ # Stored API keys (added via `/auth`) take effect by being copied onto
4521
+ # the env var name LangChain reads. Apply before the credential check so
4522
+ # `has_provider_credentials` and the downstream SDK see the same value.
4523
+ if provider:
4524
+ # Flag a key/endpoint resolved from different env tiers *before*
4525
+ # `apply_stored_credentials` bridges stored values onto plain env vars,
4526
+ # so the check sees the user's raw env intent rather than post-bridge
4527
+ # state. Diagnostic only -- never alters resolution.
4528
+ warn_on_split_credential_source(provider)
4529
+ apply_stored_credentials(provider)
4530
+
4531
+ from deepagents_code.model_config import CODEX_PROVIDER
4532
+
4533
+ # Early credential check — fail fast with an actionable message instead of
4534
+ # letting the provider SDK raise an opaque auth error on first invocation.
4535
+ # Providers that support implicit auth (e.g., Vertex AI ADC) are excluded
4536
+ # because their env-var mapping is not a reliable indicator.
4537
+ if provider and provider not in IMPLICIT_AUTH_PROVIDERS:
4538
+ cred_status = has_provider_credentials(provider)
4539
+ if cred_status is False:
4540
+ from deepagents_code.model_config import MissingCredentialsError
4541
+
4542
+ if provider == CODEX_PROVIDER:
4543
+ # No env var to set; point the user at `/auth` instead.
4544
+ msg = (
4545
+ "Not signed in to ChatGPT. Run `/auth` and select "
4546
+ "openai_codex to sign in with your ChatGPT account."
4547
+ )
4548
+ raise MissingCredentialsError(msg, provider=provider, env_var=None)
4549
+ env_var = get_credential_env_var(provider)
4550
+ display_env = env_var or f"<{provider} API key>"
4551
+ msg = (
4552
+ f"No credentials found for provider '{provider}'. "
4553
+ f"Please set the {display_env} environment variable."
4554
+ )
4555
+ raise MissingCredentialsError(msg, provider=provider, env_var=env_var)
4556
+
4557
+ # Provider-specific kwargs (with per-model overrides)
4558
+ kwargs = _get_provider_kwargs(provider, model_name=model_name)
4559
+
4560
+ # Compose under existing kwargs: profile < config.toml < --model-params
4561
+ # (applied below). The app's OpenRouter profile is stacked on top of the
4562
+ # built-in SDK profile so its `pre_init` (version check) and factory
4563
+ # (app attribution) compose into a single `apply_provider_profile` call.
4564
+ if provider:
4565
+ from deepagents.profiles.provider import apply_provider_profile
4566
+
4567
+ if provider == "openrouter":
4568
+ _ensure_cli_openrouter_profile_registered()
4569
+
4570
+ spec = f"{provider}:{model_name}" if model_name else provider
4571
+ try:
4572
+ kwargs = apply_provider_profile(spec, kwargs)
4573
+ except ModelConfigError:
4574
+ raise
4575
+ except Exception as exc:
4576
+ # `pre_init` and `init_kwargs_factory` callables registered on a
4577
+ # `ProviderProfile` may raise arbitrary exceptions (e.g. an
4578
+ # `ImportError` from the OpenRouter min-version check). Surface
4579
+ # them as `ModelConfigError` so the app's error path renders an
4580
+ # actionable message instead of a raw stack trace.
4581
+ logger.debug(
4582
+ "ProviderProfile resolution for %r failed.", spec, exc_info=True
4583
+ )
4584
+ msg = (
4585
+ f"Failed to apply provider profile for '{spec}': {exc}. "
4586
+ f"Check that the provider package is installed and up to date, "
4587
+ f"or set explicit kwargs via `--model-params`."
4588
+ )
4589
+ raise ModelConfigError(msg) from exc
4590
+
4591
+ # App --model-params take highest priority. Copy defensively before popping
4592
+ # the CLI sentinel so a caller that retains and reuses this dict (e.g. the
4593
+ # app re-creating the model on a runtime `/model` switch) keeps the sentinel
4594
+ # for the next provider's resolution.
4595
+ cli_max_retries: int | None = None
4596
+ if extra_kwargs:
4597
+ extra_kwargs = dict(extra_kwargs)
4598
+ cli_max_retries = extra_kwargs.pop(CLI_MAX_RETRIES_KEY, None)
4599
+ kwargs.update(extra_kwargs)
4600
+
4601
+ # `--max-retries` outranks everything: fold it under the provider's resolved
4602
+ # retry-param name (honoring `[retries.<provider>].param`) so a custom
4603
+ # provider whose kwarg is not `max_retries` is still served. Applied after
4604
+ # the `extra_kwargs` merge so it wins over a `max_retries` in `--model-params`.
4605
+ if cli_max_retries is not None:
4606
+ kwargs[_resolve_retry_param_name(provider)] = cli_max_retries
4607
+
4608
+ # Check if this provider uses a custom BaseChatModel class
4609
+ class_path = config.get_class_path(provider) if provider else None
4610
+
4611
+ if provider == CODEX_PROVIDER:
4612
+ # Codex models are constructed directly via `_ChatOpenAICodex` so the
4613
+ # `token_provider=` kwarg is wired to the on-disk OAuth token store
4614
+ # before any request goes out. `init_chat_model` does not know about
4615
+ # this class and would route through API-key `ChatOpenAI` instead.
4616
+ from deepagents_code.integrations import openai_codex as _codex
4617
+ from deepagents_code.model_config import (
4618
+ MissingCredentialsError,
4619
+ ModelConfigError,
4620
+ )
4621
+
4622
+ # Drop any `api_key` left in kwargs (e.g. from a config-level
4623
+ # `api_key_env` set on the codex provider, or a `--model-params
4624
+ # api_key=...`) so the bearer always comes from the OAuth
4625
+ # `token_provider` rather than a static key.
4626
+ kwargs.pop("api_key", None)
4627
+ try:
4628
+ model = _codex.build_chat_model(model_name, **kwargs)
4629
+ except FileNotFoundError as exc:
4630
+ msg = (
4631
+ "Not signed in to ChatGPT. Run `/auth` and select "
4632
+ "openai_codex to sign in with your ChatGPT account."
4633
+ )
4634
+ raise MissingCredentialsError(msg, provider=provider, env_var=None) from exc
4635
+ except _codex.CodexAuthExpiredError as exc:
4636
+ # A token exists but its refresh token is dead. Route through the
4637
+ # same `MissingCredentialsError` recovery path as a missing token
4638
+ # (which the retry flow re-attempts after `/auth`) instead of the
4639
+ # generic `ModelConfigError` below, which would not offer sign-in.
4640
+ msg = (
4641
+ "ChatGPT session expired. Run `/auth` and select openai_codex "
4642
+ "to sign in again."
4643
+ )
4644
+ raise MissingCredentialsError(msg, provider=provider, env_var=None) from exc
4645
+ except Exception as exc:
4646
+ spec = f"{provider}:{model_name}"
4647
+ msg = f"Failed to initialize Codex model '{spec}': {exc}"
4648
+ raise ModelConfigError(msg) from exc
4649
+ elif class_path:
4650
+ model = _create_model_from_class(class_path, model_name, provider, kwargs)
4651
+ else:
4652
+ model = _create_model_via_init(model_name, provider, kwargs)
4653
+
4654
+ resolved_provider = provider or getattr(model, "_model_provider", provider)
4655
+
4656
+ # Apply profile overrides from config.toml (e.g., max_input_tokens)
4657
+ if provider:
4658
+ config_profile_overrides = config.get_profile_overrides(
4659
+ provider, model_name=model_name
4660
+ )
4661
+ if config_profile_overrides:
4662
+ _apply_profile_overrides(
4663
+ model,
4664
+ config_profile_overrides,
4665
+ model_name,
4666
+ label=f"config.toml (provider '{provider}')",
4667
+ )
4668
+
4669
+ # App --profile-override takes highest priority (on top of config.toml)
4670
+ if profile_overrides:
4671
+ _apply_profile_overrides(
4672
+ model,
4673
+ profile_overrides,
4674
+ model_name,
4675
+ label="CLI --profile-override",
4676
+ raise_on_failure=True,
4677
+ )
4678
+
4679
+ # Extract context limit and modality support from model profile
4680
+ context_limit: int | None = None
4681
+ unsupported_modalities: frozenset[str] = frozenset()
4682
+ profile = getattr(model, "profile", None)
4683
+ if isinstance(profile, dict):
4684
+ if isinstance(profile.get("max_input_tokens"), int):
4685
+ context_limit = profile["max_input_tokens"]
4686
+
4687
+ modality_keys = {
4688
+ "image_inputs": "image",
4689
+ "audio_inputs": "audio",
4690
+ "video_inputs": "video",
4691
+ "pdf_inputs": "pdf",
4692
+ }
4693
+ unsupported_modalities = frozenset(
4694
+ label for key, label in modality_keys.items() if profile.get(key) is False
4695
+ )
4696
+
4697
+ return ModelResult(
4698
+ model=model,
4699
+ model_name=model_name,
4700
+ provider=resolved_provider,
4701
+ context_limit=context_limit,
4702
+ unsupported_modalities=unsupported_modalities,
4703
+ )
4704
+
4705
+
4706
+ def validate_model_capabilities(model: BaseChatModel, model_name: str) -> None:
4707
+ """Validate that the model has required capabilities for `deepagents`.
4708
+
4709
+ Checks the model's profile (if available) to ensure it supports tool calling, which
4710
+ is required for agent functionality. Issues warnings for models without profiles or
4711
+ with limited context windows.
4712
+
4713
+ Args:
4714
+ model: The instantiated model to validate.
4715
+ model_name: Model name for error/warning messages.
4716
+
4717
+ Note:
4718
+ This validation is best-effort. Models without profiles will pass with
4719
+ a warning. Calls `sys.exit(1)` if the model's profile explicitly
4720
+ indicates `tool_calling=False`.
4721
+ """
4722
+ console = _get_console()
4723
+ profile = getattr(model, "profile", None)
4724
+
4725
+ if profile is None:
4726
+ # Model doesn't have profile data - warn but allow
4727
+ console.print(
4728
+ f"[dim][yellow]Note:[/yellow] No capability profile for "
4729
+ f"'{model_name}'. Cannot verify tool calling support.[/dim]"
4730
+ )
4731
+ return
4732
+
4733
+ if not isinstance(profile, dict):
4734
+ return
4735
+
4736
+ # Check required capability: tool_calling
4737
+ tool_calling = profile.get("tool_calling")
4738
+ if tool_calling is False:
4739
+ console.print(
4740
+ f"[bold red]Error:[/bold red] Model '{model_name}' "
4741
+ "does not support tool calling."
4742
+ )
4743
+ console.print(
4744
+ "\nDeep Agents requires tool calling for agent functionality. "
4745
+ "Please choose a model that supports tool calling."
4746
+ )
4747
+ console.print("\nSee MODELS.md for supported models.")
4748
+ sys.exit(1)
4749
+
4750
+ # Warn about potentially limited context (< 8k tokens)
4751
+ max_input_tokens = profile.get("max_input_tokens")
4752
+ if max_input_tokens and max_input_tokens < 8000: # noqa: PLR2004 # Model context window default
4753
+ console.print(
4754
+ f"[dim][yellow]Warning:[/yellow] Model '{model_name}' has limited context "
4755
+ f"({max_input_tokens:,} tokens). Agent performance may be affected.[/dim]"
4756
+ )
4757
+
4758
+
4759
+ def _get_console() -> Console:
4760
+ """Return the lazily-initialized global `Console` instance.
4761
+
4762
+ Defers the `rich.console` import until console output is actually
4763
+ needed. The result is cached in `globals()["console"]`.
4764
+
4765
+ Returns:
4766
+ The global Rich `Console` singleton.
4767
+ """
4768
+ cached = globals().get("console")
4769
+ if cached is not None:
4770
+ return cached
4771
+ with _singleton_lock:
4772
+ cached = globals().get("console")
4773
+ if cached is not None:
4774
+ return cached
4775
+ from rich.console import Console
4776
+
4777
+ inst = Console(highlight=False)
4778
+ globals()["console"] = inst
4779
+ return inst
4780
+
4781
+
4782
+ def _get_settings() -> Settings:
4783
+ """Return the lazily-initialized global `Settings` instance.
4784
+
4785
+ Ensures bootstrap has run before constructing settings. The result is cached
4786
+ in `globals()["settings"]` so subsequent access — including
4787
+ `from config import settings` in other modules — resolves instantly.
4788
+
4789
+ Returns:
4790
+ The global `Settings` singleton.
4791
+ """
4792
+ cached = globals().get("settings")
4793
+ if cached is not None:
4794
+ return cached
4795
+ with _singleton_lock:
4796
+ cached = globals().get("settings")
4797
+ if cached is not None:
4798
+ return cached
4799
+ _ensure_bootstrap()
4800
+ try:
4801
+ inst = Settings.from_environment(start_path=_bootstrap_state.start_path)
4802
+ except Exception:
4803
+ logger.exception(
4804
+ "Failed to initialize settings from environment (start_path=%s)",
4805
+ _bootstrap_state.start_path,
4806
+ )
4807
+ raise
4808
+ globals()["settings"] = inst
4809
+ return inst
4810
+
4811
+
4812
+ def __getattr__(name: str) -> Settings | Console:
4813
+ """Lazy module attributes for `settings` and `console`.
4814
+
4815
+ Defers heavy initialization until first access. Subsequent accesses hit
4816
+ the module-level attribute directly (no `__getattr__` overhead).
4817
+
4818
+ Returns:
4819
+ The requested lazy singleton.
4820
+
4821
+ Raises:
4822
+ AttributeError: If *name* is not a lazily-provided attribute.
4823
+ """
4824
+ if name == "settings":
4825
+ return _get_settings()
4826
+ if name == "console":
4827
+ return _get_console()
4828
+ msg = f"module {__name__!r} has no attribute {name!r}"
4829
+ raise AttributeError(msg)