mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-08-10 16:32:28 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user