diff --git a/lib/crewai/src/crewai/telemetry/utils.py b/lib/crewai/src/crewai/telemetry/utils.py index 823afc5dc..ec3cafa70 100644 --- a/lib/crewai/src/crewai/telemetry/utils.py +++ b/lib/crewai/src/crewai/telemetry/utils.py @@ -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 diff --git a/lib/crewai/src/crewai/utilities/constants.py b/lib/crewai/src/crewai/utilities/constants.py index 8cf2f4776..0f31c86aa 100644 --- a/lib/crewai/src/crewai/utilities/constants.py +++ b/lib/crewai/src/crewai/utilities/constants.py @@ -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", diff --git a/lib/crewai/src/crewai/utilities/env.py b/lib/crewai/src/crewai/utilities/env.py index af77faefc..48c1fc6e4 100644 --- a/lib/crewai/src/crewai/utilities/env.py +++ b/lib/crewai/src/crewai/utilities/env.py @@ -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()) diff --git a/lib/crewai/tests/telemetry/test_coding_agent_detection.py b/lib/crewai/tests/telemetry/test_coding_agent_detection.py index d174ff34e..62bf94719 100644 --- a/lib/crewai/tests/telemetry/test_coding_agent_detection.py +++ b/lib/crewai/tests/telemetry/test_coding_agent_detection.py @@ -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