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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- 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)
|