fix(telemetry): share assistant precedence with the env-context path

Four findings from the Cursor and CodeRabbit reviews, three of them the
same root cause.

get_env_context restated the precedence the shared table already defines,
so every marker added for telemetry was invisible to it: a session
exposing only CLAUDE_CODE reported claude_code on spans while emitting
DefaultEnvEvent, and an assistant running inside a Cursor terminal
reported that assistant on spans while emitting CursorEnvEvent. It now
walks CODING_AGENT_ENV_MARKERS and maps the three assistants that have an
event class of their own, defaulting the rest to DefaultEnvEvent. A test
now asserts the two paths agree for every marker in the table, so they
cannot drift again.

The generic AI_AGENT marker was documented as presence-only but ran
through the truthiness loop with everything else, so an empty value fell
through to unknown. It moves out of the table and is checked by presence
after it, which also keeps the named markers' truthiness intact.

Azure Functions run on the App Service host and inherit
WEBSITE_INSTANCE_ID, so moving that marker to paas would have relabelled
them. The FUNCTIONS_* markers are checked first to keep them serverless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joao Moura
2026-08-07 15:44:58 -07:00
parent 75da3625fc
commit 36070166cf
4 changed files with 116 additions and 22 deletions

View File

@@ -14,6 +14,7 @@ from opentelemetry.trace import Span, Status, StatusCode
from crewai.utilities.constants import (
CODING_AGENT_ENV_MARKERS,
GENERIC_AGENT_ENV_VARS,
RUNTIME_CONTEXT_ENV_MARKERS,
)
@@ -35,13 +36,14 @@ _EDITOR_TERM_MARKERS: Final[tuple[tuple[str, str, str], ...]] = (
_DOCKER_ENV_PATH: Final[str] = "/.dockerenv"
_UNKNOWN: Final[str] = "unknown"
_OTHER: Final[str] = "other"
# The complete set of values detect_coding_agent() can ever return. Every value
# is a literal from CODING_AGENT_ENV_MARKERS or this module, which is what makes
# the function structurally incapable of emitting PII: no environment value,
# path, hostname, or user-supplied string can reach the return value.
KNOWN_CODING_AGENTS: Final[frozenset[str]] = frozenset(
[name for name, _ in CODING_AGENT_ENV_MARKERS] + [_UNKNOWN]
[name for name, _ in CODING_AGENT_ENV_MARKERS] + [_OTHER, _UNKNOWN]
)
# The same guarantee for detect_runtime_context(): a closed set of literals.
@@ -82,6 +84,11 @@ def detect_coding_agent() -> str:
if any(os.environ.get(env_var) for env_var in env_vars):
return agent_name
# Checked last and by presence: the cross-vendor marker establishes that an
# assistant is present without naming one, and an empty value still says so.
if any(env_var in os.environ for env_var in GENERIC_AGENT_ENV_VARS):
return _OTHER
return _UNKNOWN

View File

@@ -92,9 +92,9 @@ GENERIC_AGENT_ENV_VARS: Final[tuple[str, ...]] = ("AI_AGENT",)
# a leftover config value would mislabel ordinary human executions.
#
# Extend the shared sets above rather than adding a parallel tuple here, so both
# detection paths pick the new markers up together. Entries past the first three
# have no ``get_env_context()`` event of their own yet and fall to
# ``DefaultEnvEvent`` there.
# detection paths pick the new markers up together: ``get_env_context()`` walks
# this same table for its precedence and emits ``DefaultEnvEvent`` for the
# assistants that have no event class of their own.
#
# Markers below the first three were taken from the published detection matrix
# at vercel/detect-agent (agents.json), cross-checked against the proposal in
@@ -118,7 +118,6 @@ CODING_AGENT_ENV_MARKERS: Final[tuple[tuple[str, tuple[str, ...]], ...]] = (
("antigravity", ANTIGRAVITY_ENV_VARS),
("junie", JUNIE_ENV_VARS),
("cursor", CURSOR_ENV_VARS),
("other", GENERIC_AGENT_ENV_VARS),
)
# Markers for *where* a process runs, kept separate from which assistant is
@@ -145,6 +144,8 @@ CI_ENV_VARS: Final[tuple[str, ...]] = (
)
SERVERLESS_ENV_VARS: Final[tuple[str, ...]] = (
"AWS_LAMBDA_FUNCTION_NAME",
"FUNCTIONS_EXTENSION_VERSION",
"FUNCTIONS_WORKER_RUNTIME",
"FUNCTION_TARGET",
"K_SERVICE",
"VERCEL",
@@ -153,6 +154,10 @@ SERVERLESS_ENV_VARS: Final[tuple[str, ...]] = (
# set for long-lived containers rather than per-invocation functions, and
# checking them under "serverless" would have claimed every Heroku dyno and
# Azure App Service instance before the container check could see them.
#
# Azure Functions run on the App Service host and inherit WEBSITE_INSTANCE_ID,
# so they would land here despite being serverless. The FUNCTIONS_* markers
# above are checked first to keep them out.
PAAS_ENV_VARS: Final[tuple[str, ...]] = (
"DYNO",
"WEBSITE_INSTANCE_ID",

View File

@@ -8,20 +8,21 @@ from crewai.events.types.env_events import (
CursorEnvEvent,
DefaultEnvEvent,
)
from crewai.utilities.constants import CC_ENV_VAR, CODEX_ENV_VARS, CURSOR_ENV_VARS
from crewai.utilities.constants import CODING_AGENT_ENV_MARKERS
_env_context_emitted: contextvars.ContextVar[bool] = contextvars.ContextVar(
"_env_context_emitted", default=False
)
def _is_codex_env() -> bool:
return any(os.environ.get(var) for var in CODEX_ENV_VARS)
def _is_cursor_env() -> bool:
return any(os.environ.get(var) for var in CURSOR_ENV_VARS)
# Assistants with an event of their own. Anything else in the shared table is
# detected with the same precedence but reported as DefaultEnvEvent, so the two
# detection paths can never disagree about which assistant is present.
_AGENT_EVENTS = {
"claude_code": CCEnvEvent,
"codex": CodexEnvEvent,
"cursor": CursorEnvEvent,
}
def get_env_context() -> None:
@@ -29,11 +30,13 @@ def get_env_context() -> None:
return
_env_context_emitted.set(True)
if os.environ.get(CC_ENV_VAR):
crewai_event_bus.emit(None, CCEnvEvent())
elif _is_codex_env():
crewai_event_bus.emit(None, CodexEnvEvent())
elif _is_cursor_env():
crewai_event_bus.emit(None, CursorEnvEvent())
else:
crewai_event_bus.emit(None, DefaultEnvEvent())
# Walks the shared table rather than restating precedence: hard-coding the
# order here let the two paths drift, so a marker added for telemetry was
# invisible to this one.
for agent_name, env_vars in CODING_AGENT_ENV_MARKERS:
if any(os.environ.get(var) for var in env_vars):
event = _AGENT_EVENTS.get(agent_name, DefaultEnvEvent)
crewai_event_bus.emit(None, event())
return
crewai_event_bus.emit(None, DefaultEnvEvent())

View File

@@ -37,6 +37,7 @@ RUNTIME_MARKERS = tuple(
ALL_MARKERS = (
tuple(var for _, env_vars in CODING_AGENT_ENV_MARKERS for var in env_vars)
+ RUNTIME_MARKERS
+ GENERIC_AGENT_ENV_VARS
+ ("TERM_PROGRAM", "TERMINAL_EMULATOR")
)
@@ -214,7 +215,6 @@ def test_every_marker_comes_from_a_verified_set():
*CODEX_ENV_VARS,
*CURSOR_ENV_VARS,
*GEMINI_CLI_ENV_VARS,
*GENERIC_AGENT_ENV_VARS,
*JUNIE_ENV_VARS,
*OPENCODE_ENV_VARS,
}
@@ -685,3 +685,82 @@ def test_serverless_markers_still_win_over_paas(clean_env):
clean_env.setenv("AWS_LAMBDA_FUNCTION_NAME", "my-fn")
assert detect_runtime_context() == "serverless"
def test_generic_marker_is_detected_by_presence(clean_env):
"""An empty AI_AGENT still says an assistant is present.
The named markers keep truthiness, where an empty value means the tool set
a placeholder rather than claiming the session.
"""
clean_env.setenv("AI_AGENT", "")
assert detect_coding_agent() == "other"
def test_azure_functions_are_not_reported_as_paas(clean_env):
"""Azure Functions run on the App Service host and inherit its marker."""
clean_env.setenv("WEBSITE_INSTANCE_ID", "abc123")
clean_env.setenv("FUNCTIONS_WORKER_RUNTIME", "python")
assert detect_runtime_context() == "serverless"
def test_env_context_precedence_matches_the_shared_table(clean_env):
"""Both detection paths must agree on which assistant is present.
get_env_context previously restated precedence, so a marker added for
telemetry was invisible here and the two disagreed.
"""
from crewai.events.types.env_events import (
CCEnvEvent,
CodexEnvEvent,
CursorEnvEvent,
DefaultEnvEvent,
)
from crewai.utilities import env as env_module
agent_to_event = {
"claude_code": CCEnvEvent,
"codex": CodexEnvEvent,
"cursor": CursorEnvEvent,
}
for agent, env_vars in CODING_AGENT_ENV_MARKERS:
for var in env_vars:
for other in ALL_MARKERS:
clean_env.delenv(other, raising=False)
clean_env.setenv(var, "1")
emitted: list[type] = []
clean_env.setattr(
env_module.crewai_event_bus,
"emit",
lambda _source, event, sink=emitted: sink.append(type(event)),
)
env_module._env_context_emitted.set(False)
env_module.get_env_context()
assert detect_coding_agent() == agent, var
assert emitted[0] is agent_to_event.get(agent, DefaultEnvEvent), var
def test_assistant_inside_cursor_agrees_across_both_paths(clean_env):
"""Cursor sets CURSOR_* in every terminal, including for other assistants."""
from crewai.events.types.env_events import DefaultEnvEvent
from crewai.utilities import env as env_module
clean_env.setenv("CURSOR_TRACE_ID", "t-1")
clean_env.setenv("CLINE_ACTIVE", "true")
emitted: list[type] = []
clean_env.setattr(
env_module.crewai_event_bus,
"emit",
lambda _source, event, sink=emitted: sink.append(type(event)),
)
env_module._env_context_emitted.set(False)
env_module.get_env_context()
assert detect_coding_agent() == "cline"
assert emitted[0] is DefaultEnvEvent