diff --git a/lib/crewai/src/crewai/utilities/constants.py b/lib/crewai/src/crewai/utilities/constants.py index b22f98b7f..8cf2f4776 100644 --- a/lib/crewai/src/crewai/utilities/constants.py +++ b/lib/crewai/src/crewai/utilities/constants.py @@ -12,19 +12,27 @@ from pydantic_core import CoreSchema __all__ = [ + "ANTIGRAVITY_ENV_VARS", + "AUGMENT_ENV_VARS", "CC_ENV_VAR", + "CC_ENV_VARS", "CI_ENV_VARS", + "CLINE_ENV_VARS", "CODEX_ENV_VARS", "CODING_AGENT_ENV_MARKERS", "CONTAINER_ENV_VARS", "CREWAI_TRAINED_AGENTS_FILE_ENV", "CURSOR_ENV_VARS", "EMITTER_COLOR", + "GEMINI_CLI_ENV_VARS", + "GENERIC_AGENT_ENV_VARS", "HOSTED_IDE_ENV_VARS", + "JUNIE_ENV_VARS", "KNOWLEDGE_DIRECTORY", "MAX_FILE_NAME_LENGTH", "NOTEBOOK_ENV_VARS", "NOT_SPECIFIED", + "OPENCODE_ENV_VARS", "PAAS_ENV_VARS", "RUNTIME_CONTEXT_ENV_MARKERS", "SERVERLESS_ENV_VARS", @@ -42,6 +50,7 @@ CODEX_ENV_VARS: Final[tuple[str, ...]] = ( "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_THREAD_ID", ) +CC_ENV_VARS: Final[tuple[str, ...]] = (CC_ENV_VAR, "CLAUDE_CODE") CURSOR_ENV_VARS: Final[tuple[str, ...]] = ( "CURSOR_AGENT", "CURSOR_EXTENSION_HOST_ROLE", @@ -49,6 +58,20 @@ CURSOR_ENV_VARS: Final[tuple[str, ...]] = ( "CURSOR_TRACE_ID", "CURSOR_WORKSPACE_LABEL", ) +ANTIGRAVITY_ENV_VARS: Final[tuple[str, ...]] = ( + "ANTIGRAVITY_AGENT", + "ANTIGRAVITY_CLI_ALIAS", +) +AUGMENT_ENV_VARS: Final[tuple[str, ...]] = ("AUGMENT_AGENT",) +CLINE_ENV_VARS: Final[tuple[str, ...]] = ("CLINE_ACTIVE",) +GEMINI_CLI_ENV_VARS: Final[tuple[str, ...]] = ("GEMINI_CLI",) +JUNIE_ENV_VARS: Final[tuple[str, ...]] = ("JUNIE_DATA", "JUNIE_SHIM_PATH") +OPENCODE_ENV_VARS: Final[tuple[str, ...]] = ("OPENCODE", "OPENCODE_CLIENT") + +# Proposed cross-vendor marker (agentsmd/agents.md#136). Checked last and +# reported as "other": it says an assistant is present without naming one, and +# reading its value to find out would put an arbitrary string in telemetry. +GENERIC_AGENT_ENV_VARS: Final[tuple[str, ...]] = ("AI_AGENT",) # Ordered (name, env vars) pairs for identifying the AI coding assistant a # process is running under. Reuses the sets above and keeps the same precedence @@ -69,11 +92,33 @@ CURSOR_ENV_VARS: Final[tuple[str, ...]] = ( # 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. +# 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. +# +# Markers below the first three were taken from the published detection matrix +# at vercel/detect-agent (agents.json), cross-checked against the proposal in +# agentsmd/agents.md#136 and microsoft/vscode#311734. Rule 2 excluded several +# entries those sources list: Goose's ``GOOSE_PROVIDER`` and Copilot's +# ``COPILOT_MODEL`` / ``COPILOT_GITHUB_TOKEN`` are user configuration, and a +# committed ``.env`` carrying one would relabel every ordinary run. Replit's +# ``REPL_ID`` is a hosted environment rather than an assistant, so it stays in +# HOSTED_IDE_ENV_VARS. +# +# The assistants that spawn inside another editor's terminal are ordered ahead +# of Cursor for the same reason Codex is: CURSOR_* is set for every integrated +# terminal, so checking Cursor first would mask anything running inside it. CODING_AGENT_ENV_MARKERS: Final[tuple[tuple[str, tuple[str, ...]], ...]] = ( - ("claude_code", (CC_ENV_VAR,)), + ("claude_code", CC_ENV_VARS), ("codex", CODEX_ENV_VARS), + ("cline", CLINE_ENV_VARS), + ("gemini_cli", GEMINI_CLI_ENV_VARS), + ("augment", AUGMENT_ENV_VARS), + ("opencode", OPENCODE_ENV_VARS), + ("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 diff --git a/lib/crewai/tests/telemetry/test_coding_agent_detection.py b/lib/crewai/tests/telemetry/test_coding_agent_detection.py index 7009d320c..d174ff34e 100644 --- a/lib/crewai/tests/telemetry/test_coding_agent_detection.py +++ b/lib/crewai/tests/telemetry/test_coding_agent_detection.py @@ -12,10 +12,18 @@ from crewai.telemetry.utils import ( detect_runtime_context, ) from crewai.utilities.constants import ( + ANTIGRAVITY_ENV_VARS, + AUGMENT_ENV_VARS, CC_ENV_VAR, + CC_ENV_VARS, + CLINE_ENV_VARS, CODEX_ENV_VARS, CODING_AGENT_ENV_MARKERS, CURSOR_ENV_VARS, + GEMINI_CLI_ENV_VARS, + GENERIC_AGENT_ENV_VARS, + JUNIE_ENV_VARS, + OPENCODE_ENV_VARS, RUNTIME_CONTEXT_ENV_MARKERS, ) @@ -161,15 +169,33 @@ def test_precedence_matches_get_env_context(clean_env): assert detect_coding_agent() == expected, markers -def test_config_style_variables_are_not_used_as_markers(): +@pytest.mark.parametrize( + "config_var", + [ + "AIDER_MODEL", + "COPILOT_GITHUB_TOKEN", + "COPILOT_MODEL", + "GOOSE_PROVIDER", + ], +) +def test_config_style_variables_are_not_used_as_markers(config_var): """Persistent user config must never be treated as a session marker. crewai loads dotenv files on normal runs, so a committed AIDER_MODEL or - similar would mislabel ordinary human executions. + GOOSE_PROVIDER would mislabel ordinary human executions. Published + detection matrices list several of these; they are deliberately excluded + here rather than copied wholesale. """ all_vars = {var for _, env_vars in CODING_AGENT_ENV_MARKERS for var in env_vars} - assert "AIDER_MODEL" not in all_vars + assert config_var not in all_vars + + +def test_hosted_environments_are_not_reported_as_assistants(): + """REPL_ID marks a hosted environment, not an assistant driving the run.""" + all_vars = {var for _, env_vars in CODING_AGENT_ENV_MARKERS for var in env_vars} + + assert "REPL_ID" not in all_vars def test_every_marker_comes_from_a_verified_set(): @@ -180,15 +206,64 @@ def test_every_marker_comes_from_a_verified_set(): Adding an assistant means extending the canonical sets, which keeps both detection paths in sync. """ - verified = {CC_ENV_VAR, *CODEX_ENV_VARS, *CURSOR_ENV_VARS} + verified = { + *ANTIGRAVITY_ENV_VARS, + *AUGMENT_ENV_VARS, + *CC_ENV_VARS, + *CLINE_ENV_VARS, + *CODEX_ENV_VARS, + *CURSOR_ENV_VARS, + *GEMINI_CLI_ENV_VARS, + *GENERIC_AGENT_ENV_VARS, + *JUNIE_ENV_VARS, + *OPENCODE_ENV_VARS, + } declared = {var for _, env_vars in CODING_AGENT_ENV_MARKERS for var in env_vars} assert declared == verified, ( - "markers must come from CC_ENV_VAR / CODEX_ENV_VARS / CURSOR_ENV_VARS; " + "markers must come from the canonical per-assistant sets; " f"unverified names present: {sorted(declared - verified)}" ) +def test_generic_marker_is_the_last_resort(clean_env): + """AI_AGENT says an assistant is present without naming which one. + + A named marker must win, so the generic entry cannot mask a specific one. + """ + clean_env.setenv("AI_AGENT", "1") + assert detect_coding_agent() == "other" + + clean_env.setenv("CLINE_ACTIVE", "true") + assert detect_coding_agent() == "cline" + + +def test_generic_marker_value_is_never_reported(clean_env): + """Its value is an arbitrary vendor string, so it is never read.""" + clean_env.setenv("AI_AGENT", "some-unreleased-tool/2.0") + + assert detect_coding_agent() == "other" + + +def test_terminal_bound_assistants_outrank_cursor(clean_env): + """CURSOR_* is set for every integrated terminal. + + Checking Cursor first would report cursor for anything spawned inside it, + the same trap Codex already had to be ordered around. + """ + clean_env.setenv("CURSOR_TRACE_ID", "t-1") + + for marker, expected in ( + ("CLINE_ACTIVE", "cline"), + ("GEMINI_CLI", "gemini_cli"), + ("AUGMENT_AGENT", "augment"), + ("OPENCODE_CLIENT", "opencode"), + ): + clean_env.setenv(marker, "1") + assert detect_coding_agent() == expected, marker + clean_env.delenv(marker) + + def test_concurrent_attach_registers_the_processor_once(isolated_telemetry, clean_env): """Check-then-act on the provider set must be locked.