Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9374,13 +9374,42 @@ async def _handle_trace_command(self, command: str) -> None:
)
return
if not project_name:
from deepagents_code.config import (
LangsmithShadowResult,
langsmith_key_shadowed_by_empty_override,
)

await self._mount_message(UserMessage(command))
await self._mount_message(
AppMessage(
try:
shadow = await asyncio.to_thread(
langsmith_key_shadowed_by_empty_override
)
except Exception:
# A best-effort diagnostic must never take down `/trace`; fall
# back to the generic hint if the shadow check itself fails.
logger.exception(
"Failed to check for a shadowed LangSmith key for thread %s",
thread_id,
)
shadow = LangsmithShadowResult()
if shadow.shadowing_var:
message = (
f"A LangSmith key is available, but {shadow.shadowing_var} "
"is set to an empty value and is shadowing it, so tracing is "
f"off. Unset {shadow.shadowing_var} (and make sure LangSmith "
"tracing is enabled) to start tracing."
)
elif shadow.store_unreadable:
message = (
"Your stored LangSmith credential could not be read; the "
"credential file may be corrupt. Re-add the key via `/auth`."
)
else:
message = (
"LangSmith tracing is not configured. "
"Run `/auth` and select LangSmith to enable tracing.",
),
)
"Run `/auth` and select LangSmith to enable tracing."
)
await self._mount_message(AppMessage(message))
return
try:
project_url = await asyncio.to_thread(
Expand Down
89 changes: 89 additions & 0 deletions libs/code/deepagents_code/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3103,6 +3103,95 @@ def get_langsmith_project_name() -> str | None:
)


@dataclass(frozen=True)
class LangsmithShadowResult:
"""Why `/trace` found no LangSmith key, when an empty override is involved.

Distinguishes the three states the caller renders differently: a specific
empty override is suppressing an available key (`shadowing_var`), the
credential store could not be read so a stored key can't be ruled out
(`store_unreadable`), or neither (both fields falsy -- the generic "not
configured" hint applies).
"""

shadowing_var: str | None = None
"""Prefixed env var whose empty value is suppressing an available key."""

store_unreadable: bool = False
"""`True` when the `/auth` credential store raised while being checked."""


def langsmith_key_shadowed_by_empty_override() -> LangsmithShadowResult:
"""Report an empty prefixed override that is suppressing a LangSmith key.

`/trace` shows a generic "not configured" hint whenever no key resolves, but
a common footgun is exporting `DEEPAGENTS_CODE_LANGSMITH_API_KEY=` (empty).
A present-but-empty prefixed variable suppresses a key two ways: per
`resolve_env_var`'s precedence it shadows the canonical env variable
directly, and -- because `apply_stored_service_credentials` skips the `/auth`
bridge onto `LANGSMITH_API_KEY` whenever the prefixed var is present -- it
also keeps a `/auth`-stored key from ever reaching the environment. Either
way tracing silently stays off even though a key is available. Detecting this
lets callers name the offending variable instead of sending the user to
`/auth`.

Only an override that actually gates the *effective* key is reported. If a
key already resolves under the normal `LANGSMITH_API_KEY`-before-
`LANGCHAIN_API_KEY` precedence, tracing is off for some other reason (a
missing tracing flag), no override is to blame, and nothing is reported.
Otherwise each override is checked against the specific key it suppresses, so
the returned name is one that, once unset, actually lets a key resolve: its
canonical variant carries a value, or -- for `LANGSMITH_API_KEY`, the var
`/auth` bridges its stored key onto -- a stored key exists. When several
overrides qualify, the first in `_TRACING_API_KEY_ENV_VARS` order is
returned.

Returns:
A `LangsmithShadowResult`; see its fields for the three outcomes.
"""
from deepagents_code import auth_store
from deepagents_code.model_config import (
LANGSMITH_SERVICE,
resolve_env_var,
resolved_env_var_name,
)

if resolve_env_var("LANGSMITH_API_KEY") or resolve_env_var("LANGCHAIN_API_KEY"):
# A key already resolves (matching `get_langsmith_project_name`'s key
# precedence), so no empty override is what's keeping tracing off, and
# unsetting one would change nothing. Defer to the generic hint.
return LangsmithShadowResult()

store_unreadable = False
for name in _TRACING_API_KEY_ENV_VARS:
resolved = resolved_env_var_name(name)
if resolved == name or os.environ.get(resolved):
# No prefixed override for this key, or the override carries a value:
# either way it is not an empty override suppressing this key.
continue
if (os.environ.get(name) or "").strip():
# The empty override is hiding a value on the canonical variable.
return LangsmithShadowResult(shadowing_var=resolved)
if name == "LANGSMITH_API_KEY":
# `/auth` bridges its stored key onto `LANGSMITH_API_KEY`, so an
# empty override for it also suppresses a stored key.
try:
if auth_store.get_stored_key(LANGSMITH_SERVICE):
return LangsmithShadowResult(shadowing_var=resolved)
except RuntimeError as exc:
# Can't confirm a stored key, but keep scanning: a later
# override may still name a concrete shadow. Only if none does
# do we surface the unreadable store to the caller.
logger.warning(
"Could not read the stored LangSmith credential while "
"checking for an empty-override shadow: %s. The credential "
"file may be corrupt; re-add the key via /auth.",
exc,
)
store_unreadable = True
return LangsmithShadowResult(store_unreadable=store_unreadable)


def is_langsmith_redaction_enabled() -> bool:
"""Return whether LangSmith secret redaction is enabled for agent traces."""
from deepagents_code.config_manifest import (
Expand Down
103 changes: 102 additions & 1 deletion libs/code/tests/unit_tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5460,11 +5460,49 @@ async def test_trace_no_warning_when_message_lookup_fails(self) -> None:

async def test_trace_shows_error_when_not_configured(self) -> None:
"""Should show configuration hint when LangSmith is not set up."""
from deepagents_code.config import LangsmithShadowResult

app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._session_state = TextualSessionState()

with (
patch(
"deepagents_code.config.get_langsmith_project_name",
return_value=None,
),
# Pin the shadow check so this branch is independent of whatever
# tracing vars the test runner happens to have exported.
patch(
"deepagents_code.config.langsmith_key_shadowed_by_empty_override",
return_value=LangsmithShadowResult(),
),
):
await app._handle_trace_command("/trace")
await pilot.pause()

app_msgs = app.query(AppMessage)
rendered = "\n".join(str(w._content) for w in app_msgs)
assert "/auth" in rendered
assert "LANGSMITH_API_KEY" not in rendered

async def test_trace_flags_key_shadowed_by_empty_override(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Should name the empty override and how to fix it, not send to /auth."""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._session_state = TextualSessionState()

# Clear the sibling tracing vars so the real helper reaches the
# canonical-env shadow branch deterministically.
for var in ("LANGCHAIN_API_KEY", "DEEPAGENTS_CODE_LANGCHAIN_API_KEY"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_API_KEY", "")
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")

with patch(
"deepagents_code.config.get_langsmith_project_name",
return_value=None,
Expand All @@ -5474,8 +5512,71 @@ async def test_trace_shows_error_when_not_configured(self) -> None:

app_msgs = app.query(AppMessage)
rendered = "\n".join(str(w._content) for w in app_msgs)
assert "DEEPAGENTS_CODE_LANGSMITH_API_KEY" in rendered
assert "shadowing" in rendered
# The actionable remediation must be present...
assert "Unset DEEPAGENTS_CODE_LANGSMITH_API_KEY" in rendered
# ...and it must not send the user to /auth or mislabel an env key
# as a "stored" key.
assert "/auth" not in rendered
assert "stored key" not in rendered

async def test_trace_flags_unreadable_credential_store(self) -> None:
"""A corrupt store surfaces a corruption hint, not the generic one."""
from deepagents_code.config import LangsmithShadowResult

app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._session_state = TextualSessionState()

with (
patch(
"deepagents_code.config.get_langsmith_project_name",
return_value=None,
),
patch(
"deepagents_code.config.langsmith_key_shadowed_by_empty_override",
return_value=LangsmithShadowResult(store_unreadable=True),
),
):
await app._handle_trace_command("/trace")
await pilot.pause()

app_msgs = app.query(AppMessage)
rendered = "\n".join(str(w._content) for w in app_msgs)
assert "corrupt" in rendered
assert "/auth" in rendered
assert "LANGSMITH_API_KEY" not in rendered

async def test_trace_survives_shadow_check_failure(self) -> None:
"""An unexpected error in the shadow check falls back to the generic hint.

The check is a best-effort diagnostic; a raise from it (e.g. a lazy
import failure) must not crash `/trace` or drop the command echo.
"""
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._session_state = TextualSessionState()

with (
patch(
"deepagents_code.config.get_langsmith_project_name",
return_value=None,
),
patch(
"deepagents_code.config.langsmith_key_shadowed_by_empty_override",
side_effect=RuntimeError("boom"),
),
):
await app._handle_trace_command("/trace")
await pilot.pause()

app_msgs = app.query(AppMessage)
rendered = "\n".join(str(w._content) for w in app_msgs)
assert "/auth" in rendered
user_msgs = app.query(UserMessage)
assert any("/trace" in str(w._content) for w in user_msgs)

async def test_trace_shows_network_error_when_url_fetch_times_out(self) -> None:
"""Should distinguish a network/timeout failure from a config gap.
Expand Down
Loading