Merge branch 'main' into fix/native-tool-call-responses-api-shape

This commit is contained in:
Rip&Tear
2026-08-10 17:06:41 +08:00
committed by GitHub
1527 changed files with 338017 additions and 174 deletions

View File

@@ -8,7 +8,7 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.15.12",
"crewai-core==1.15.14",
"click>=8.1.7,<9",
"pydantic>=2.11.9,<2.13",
"pydantic-settings>=2.14.2,<3",

View File

@@ -1 +1 @@
__version__ = "1.15.12"
__version__ = "1.15.14"

View File

@@ -1 +1 @@
__version__ = "1.15.12"
__version__ = "1.15.14"

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.15.12"
__version__ = "1.15.14"

View File

@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.15.12",
"crewai==1.15.14",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",
@@ -107,9 +107,12 @@ stagehand = [
"stagehand>=0.4.1",
]
github = [
# <3.1.57 has GHSA-p538-c434-8v24 (arbitrary file truncation) and
# GHSA-3f7w-8rr8-f37f (unguarded git option forwarding).
"gitpython>=3.1.57,<4",
# <3.1.58 has GHSA-p538-c434-8v24 (arbitrary file truncation),
# GHSA-3f7w-8rr8-f37f (unguarded git option forwarding),
# GHSA-9rj7-rf2p-w77r, GHSA-4gmw-gg2m-w46p, GHSA-hh9p-6wh2-4mfc,
# GHSA-wvpp-8hx9-p66j and GHSA-jm78-9fvv-mhgr (further unguarded git
# option forwarding / arbitrary file read); force 3.1.58+.
"gitpython>=3.1.58,<4",
"PyGithub==1.59.1",
]
rag = [

View File

@@ -340,4 +340,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.15.12"
__version__ = "1.15.14"

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.15.12",
"crewai-cli==1.15.12",
"crewai-core==1.15.14",
"crewai-cli==1.15.14",
# Core Dependencies
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
@@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.15.12",
"crewai-tools==1.15.14",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"

View File

@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.15.12"
__version__ = "1.15.14"
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),

View File

@@ -21,6 +21,7 @@ import threading
from typing import TYPE_CHECKING, Any
import weakref
from crewai_core.project import get_project_id
from opentelemetry import trace
from opentelemetry.context import Context
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
@@ -54,6 +55,7 @@ from crewai.telemetry.utils import (
add_crew_attributes,
close_span,
detect_coding_agent,
detect_runtime_context,
)
from crewai.utilities.i18n import I18N_DEFAULT
from crewai.utilities.logger_utils import suppress_warnings
@@ -172,6 +174,7 @@ class Telemetry:
# Weak so instrumented apps' providers are not kept alive by telemetry.
self._common_attributes_providers: weakref.WeakSet[Any] = weakref.WeakSet()
self._common_attributes_lock = threading.Lock()
self._common_attributes: dict[str, str] | None = None
if self._is_telemetry_disabled():
return
@@ -203,6 +206,39 @@ class Telemetry:
raise
self.ready = False
def _common_span_attributes(self) -> dict[str, str]:
"""Build the attributes every span carries, once per process.
Memoized because it is computed per provider and reads the project's
``pyproject.toml``, and because a process cannot change which
assistant, runtime, or project it belongs to partway through.
Returns:
Attributes to stamp on every span. ``project_id`` is omitted for
projects that do not declare one.
"""
if self._common_attributes is not None:
return self._common_attributes
attributes = {
"coding_agent": detect_coding_agent(),
"runtime_context": detect_runtime_context(),
}
try:
# Read-only: minting an id belongs to the CLI commands a user
# invoked, not to a library call during execution.
project_id = get_project_id()
except Exception as e: # Telemetry must never break execution.
logger.debug(f"Failed to read project id: {e}")
project_id = None
if project_id:
attributes["project_id"] = project_id
self._common_attributes = attributes
return attributes
def _attach_common_attributes(self, provider: Any) -> None:
"""Attach process-wide attributes to every span a provider emits.
@@ -230,9 +266,7 @@ class Telemetry:
if provider in self._common_attributes_providers:
return
add_span_processor(
CommonAttributesSpanProcessor(
{"coding_agent": detect_coding_agent()}
)
CommonAttributesSpanProcessor(self._common_span_attributes())
)
self._common_attributes_providers.add(provider)
except Exception as e: # Telemetry must never break execution.

View File

@@ -12,7 +12,11 @@ from typing import TYPE_CHECKING, Any, Final
from opentelemetry.trace import Span, Status, StatusCode
from crewai.utilities.constants import CODING_AGENT_ENV_MARKERS
from crewai.utilities.constants import (
CODING_AGENT_ENV_MARKERS,
GENERIC_AGENT_ENV_VARS,
RUNTIME_CONTEXT_ENV_MARKERS,
)
if TYPE_CHECKING:
@@ -20,23 +24,33 @@ if TYPE_CHECKING:
from crewai.task import Task
# Editors whose integrated terminal implies a human is likely present. Used only
# as a weaker fallback when no explicit coding-agent marker is found.
# Editors whose integrated terminal a process can be launched from. Matched on
# an exact value rather than presence, since these variables name the terminal.
_EDITOR_TERM_MARKERS: Final[tuple[tuple[str, str, str], ...]] = (
("TERM_PROGRAM", "vscode", "vscode_terminal"),
("TERMINAL_EMULATOR", "JetBrains-JediTerm", "jetbrains_terminal"),
)
_FALLBACK_AGENT_NAMES: Final[tuple[str, ...]] = ("non_interactive", "unknown")
# Marks a process running inside a container when no more specific runtime
# marker is present.
_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]
[name for name, _ in CODING_AGENT_ENV_MARKERS] + [_OTHER, _UNKNOWN]
)
# The same guarantee for detect_runtime_context(): a closed set of literals.
KNOWN_RUNTIME_CONTEXTS: Final[frozenset[str]] = frozenset(
[name for name, _ in RUNTIME_CONTEXT_ENV_MARKERS]
+ [name for _, _, name in _EDITOR_TERM_MARKERS]
+ list(_FALLBACK_AGENT_NAMES)
+ ["interactive", "non_interactive", _UNKNOWN]
)
@@ -57,27 +71,65 @@ def detect_coding_agent() -> str:
integrated terminal, so a result names the environment the process is
running *under*, not proof that an agent authored the code.
Answers only *which assistant*. Where the process runs is
:func:`detect_runtime_context`, so an "unknown" here is a genuine gap in
the marker table rather than a run that never had an assistant to find.
Returns:
A normalized assistant name (e.g. "claude_code", "cursor", "codex"),
an editor terminal hint (e.g. "vscode_terminal"), "non_interactive"
when no marker is found and there is no TTY, or "unknown" otherwise.
The result is always a member of KNOWN_CODING_AGENTS.
A normalized assistant name (e.g. "claude_code", "cursor", "codex"), or
"unknown" when no marker matches. The result is always a member of
KNOWN_CODING_AGENTS.
"""
for agent_name, env_vars in CODING_AGENT_ENV_MARKERS:
if any(os.environ.get(env_var) for env_var in env_vars):
return agent_name
for env_var, expected, agent_name in _EDITOR_TERM_MARKERS:
# 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
def detect_runtime_context() -> str:
"""Best-effort detection of where this process is running.
Separate from :func:`detect_coding_agent` because the two answer different
questions. Automated runs -- CI, containers, serverless -- have no
assistant to detect, and reporting them in the assistant field made an
unrecognized assistant indistinguishable from a run that could never have
had one.
Precedence runs most specific first: CI and hosted IDEs typically run
inside containers, so a bare container match only applies once the more
specific markers have been ruled out.
Returns:
One of the runtime names (e.g. "ci", "serverless", "container",
"notebook", "hosted_ide"), an editor terminal (e.g. "vscode_terminal"),
"interactive" or "non_interactive" when only a TTY check applies, or
"unknown" when that check cannot be made. The result is always a member
of KNOWN_RUNTIME_CONTEXTS.
"""
# Presence, not truthiness: a platform that exports an empty CI= is still
# CI, unlike the assistant markers where an empty value means the tool set
# a placeholder rather than claiming the session.
for context_name, env_vars in RUNTIME_CONTEXT_ENV_MARKERS:
if any(env_var in os.environ for env_var in env_vars):
return context_name
for env_var, expected, context_name in _EDITOR_TERM_MARKERS:
if os.environ.get(env_var) == expected:
return agent_name
return context_name
if os.path.exists(_DOCKER_ENV_PATH):
return "container"
try:
if not sys.stdout.isatty():
return "non_interactive"
return "interactive" if sys.stdout.isatty() else "non_interactive"
except (AttributeError, ValueError, OSError):
return "unknown"
return "unknown"
return _UNKNOWN
def add_agent_fingerprint_to_span(

View File

@@ -12,15 +12,30 @@ 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",
"TRAINED_AGENTS_DATA_FILE",
"TRAINING_DATA_FILE",
]
@@ -35,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",
@@ -42,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
@@ -62,13 +92,99 @@ 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: ``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
# 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),
)
# Markers for *where* a process runs, kept separate from which assistant is
# driving it. The two answer different questions: a scheduled container run has
# no assistant to detect, and folding it into the assistant field made "no
# marker found" and "no assistant possible" indistinguishable.
#
# These are published platform contracts rather than per-tool observations, so
# they do not need the case-by-case verification the assistant table requires.
# Presence is all that is checked; no value is ever read.
CI_ENV_VARS: Final[tuple[str, ...]] = (
"APPVEYOR",
"BITBUCKET_BUILD_NUMBER",
"BUILDKITE",
"CI",
"CIRCLECI",
"DRONE",
"GITHUB_ACTIONS",
"GITLAB_CI",
"JENKINS_URL",
"TEAMCITY_VERSION",
"TF_BUILD",
"TRAVIS",
)
SERVERLESS_ENV_VARS: Final[tuple[str, ...]] = (
"AWS_LAMBDA_FUNCTION_NAME",
"FUNCTIONS_EXTENSION_VERSION",
"FUNCTIONS_WORKER_RUNTIME",
"FUNCTION_TARGET",
"K_SERVICE",
"VERCEL",
)
# Managed application platforms, kept apart from serverless: their markers are
# 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",
)
HOSTED_IDE_ENV_VARS: Final[tuple[str, ...]] = (
"CODESPACES",
"GITPOD_WORKSPACE_ID",
"REPL_ID",
)
NOTEBOOK_ENV_VARS: Final[tuple[str, ...]] = (
"COLAB_RELEASE_TAG",
"JPY_PARENT_PID",
)
CONTAINER_ENV_VARS: Final[tuple[str, ...]] = ("KUBERNETES_SERVICE_HOST",)
# Ordered most specific first. CI jobs and hosted IDEs usually run inside
# containers, so a bare container match is only meaningful once the others have
# been ruled out.
RUNTIME_CONTEXT_ENV_MARKERS: Final[tuple[tuple[str, tuple[str, ...]], ...]] = (
("ci", CI_ENV_VARS),
("serverless", SERVERLESS_ENV_VARS),
("paas", PAAS_ENV_VARS),
("hosted_ide", HOSTED_IDE_ENV_VARS),
("notebook", NOTEBOOK_ENV_VARS),
("container", CONTAINER_ENV_VARS),
)
class _NotSpecified:
"""Sentinel class to detect when no value has been explicitly provided.

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

@@ -5,20 +5,47 @@ from unittest.mock import patch
import pytest
from crewai.telemetry.utils import KNOWN_CODING_AGENTS, detect_coding_agent
from crewai.telemetry.utils import (
KNOWN_CODING_AGENTS,
KNOWN_RUNTIME_CONTEXTS,
detect_coding_agent,
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,
)
# Derived from the shared table rather than restated, so adding an assistant
# there cannot leave these tests silently checking a stale marker set.
ALL_MARKERS = tuple(
var for _, env_vars in CODING_AGENT_ENV_MARKERS for var in env_vars
) + ("TERM_PROGRAM", "TERMINAL_EMULATOR")
# Derived from the shared tables rather than restated, so adding an assistant
# or runtime there cannot leave these tests silently checking a stale set.
RUNTIME_MARKERS = tuple(
var for _, env_vars in RUNTIME_CONTEXT_ENV_MARKERS for var in env_vars
)
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")
)
EVERY_RUNTIME_CASE = [
(var, context)
for context, env_vars in RUNTIME_CONTEXT_ENV_MARKERS
for var in env_vars
]
EVERY_MARKER_CASE = [
(var, agent) for agent, env_vars in CODING_AGENT_ENV_MARKERS for var in env_vars
@@ -33,6 +60,18 @@ def clean_env(monkeypatch):
return monkeypatch
@pytest.fixture
def otel_enabled(monkeypatch):
"""Let the SDK build real spans for tests that assert on exported ones.
The suite runs with OTEL_SDK_DISABLED set, which makes TracerProvider hand
out no-op tracers. An export-based assertion then sees zero spans rather
than a failed attribute, so it fails only when it happens to run before a
test whose fixture flips the variable - which random ordering decides.
"""
monkeypatch.setenv("OTEL_SDK_DISABLED", "false")
@pytest.fixture
def isolated_telemetry(monkeypatch):
"""Build a fresh Telemetry without touching the process-wide singleton.
@@ -143,15 +182,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():
@@ -162,15 +219,63 @@ 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,
*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.
@@ -216,16 +321,16 @@ def test_concurrent_attach_registers_the_processor_once(isolated_telemetry, clea
def test_editor_terminal_requires_exact_value(clean_env):
clean_env.setenv("TERM_PROGRAM", "vscode")
assert detect_coding_agent() == "vscode_terminal"
assert detect_runtime_context() == "vscode_terminal"
clean_env.setenv("TERM_PROGRAM", "iTerm.app")
assert detect_coding_agent() != "vscode_terminal"
assert detect_runtime_context() != "vscode_terminal"
def test_explicit_agent_marker_wins_over_editor_terminal(clean_env):
def test_editor_terminal_is_not_reported_as_an_assistant(clean_env):
"""An editor's terminal says where a process runs, not who drove it."""
clean_env.setenv("TERM_PROGRAM", "vscode")
clean_env.setenv("CLAUDECODE", "1")
assert detect_coding_agent() == "claude_code"
assert detect_coding_agent() == "unknown"
def test_empty_marker_value_is_ignored(clean_env):
@@ -233,12 +338,61 @@ def test_empty_marker_value_is_ignored(clean_env):
assert detect_coding_agent() != "claude_code"
@pytest.mark.parametrize(("env_var", "expected"), EVERY_RUNTIME_CASE)
def test_detects_every_runtime_marker(clean_env, env_var, expected):
"""Every runtime marker must map to its context."""
clean_env.setenv(env_var, "1")
assert detect_runtime_context() == expected
def test_runtime_precedence_prefers_the_most_specific(clean_env):
"""CI and hosted IDEs usually run in containers; the specific one wins."""
clean_env.setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1")
assert detect_runtime_context() == "container"
clean_env.setenv("GITHUB_ACTIONS", "true")
assert detect_runtime_context() == "ci"
def test_an_automated_run_still_reports_an_unknown_assistant(clean_env):
"""The split must keep the two fields independent.
A CI run has no assistant to find, which is different from failing to
recognize one - the reason they no longer share a field.
"""
clean_env.setenv("CI", "true")
assert detect_runtime_context() == "ci"
assert detect_coding_agent() == "unknown"
def test_assistant_and_runtime_are_reported_together(clean_env):
"""An assistant inside CI must not mask either signal."""
clean_env.setenv("CI", "true")
clean_env.setenv("CLAUDECODE", "1")
assert detect_coding_agent() == "claude_code"
assert detect_runtime_context() == "ci"
def test_falls_back_to_non_interactive_without_tty(clean_env, monkeypatch):
monkeypatch.setattr("os.path.exists", lambda path: False)
monkeypatch.setattr("sys.stdout", type("S", (), {"isatty": lambda self: False})())
assert detect_coding_agent() == "non_interactive"
assert detect_runtime_context() == "non_interactive"
def test_falls_back_to_unknown_with_tty(clean_env, monkeypatch):
def test_falls_back_to_interactive_with_tty(clean_env, monkeypatch):
monkeypatch.setattr("os.path.exists", lambda path: False)
monkeypatch.setattr("sys.stdout", type("S", (), {"isatty": lambda self: True})())
assert detect_runtime_context() == "interactive"
def test_dockerenv_marks_a_container(clean_env, monkeypatch):
"""The container check is the last resort before the TTY fallback."""
monkeypatch.setattr("os.path.exists", lambda path: path == "/.dockerenv")
assert detect_runtime_context() == "container"
def test_unmatched_assistant_is_unknown(clean_env, monkeypatch):
"""No marker means a gap in the table, reported as unknown."""
monkeypatch.setattr("sys.stdout", type("S", (), {"isatty": lambda self: True})())
assert detect_coding_agent() == "unknown"
@@ -255,8 +409,9 @@ def test_handles_broken_stdout(clean_env, monkeypatch):
def isatty(self):
raise ValueError("detached")
monkeypatch.setattr("os.path.exists", lambda path: False)
monkeypatch.setattr("sys.stdout", BrokenStdout())
assert detect_coding_agent() == "unknown"
assert detect_runtime_context() == "unknown"
def test_result_is_always_a_known_literal(clean_env):
@@ -269,20 +424,23 @@ def test_result_is_always_a_known_literal(clean_env):
for var in ALL_MARKERS:
clean_env.setenv(var, sensitive)
result = detect_coding_agent()
assert result in KNOWN_CODING_AGENTS
assert sensitive not in result
agent = detect_coding_agent()
context = detect_runtime_context()
assert agent in KNOWN_CODING_AGENTS
assert context in KNOWN_RUNTIME_CONTEXTS
assert sensitive not in agent
assert sensitive not in context
clean_env.delenv(var, raising=False)
def test_known_agents_contains_no_pii_shaped_values():
"""Every possible emitted value is a short, opaque identifier."""
for name in KNOWN_CODING_AGENTS:
for name in KNOWN_CODING_AGENTS | KNOWN_RUNTIME_CONTEXTS:
assert name.replace("_", "").isalnum(), name
assert len(name) <= 32, name
def test_coding_agent_lands_on_every_exported_span(clean_env):
def test_coding_agent_lands_on_every_exported_span(clean_env, otel_enabled):
"""End-to-end: the attribute must appear as a *span attribute* on any span.
It cannot be a Resource attribute - the ingestion pipeline preserves only
@@ -319,7 +477,7 @@ def test_coding_agent_lands_on_every_exported_span(clean_env):
assert "coding_agent" not in exported[0].resource.attributes
def test_common_attributes_processor_never_breaks_span_creation(clean_env):
def test_common_attributes_processor_never_breaks_span_creation(clean_env, otel_enabled):
"""A failure applying attributes must not propagate into user execution."""
from crewai.telemetry.telemetry import CommonAttributesSpanProcessor
@@ -348,7 +506,7 @@ def test_coding_agent_span_emits_once(isolated_telemetry, clean_env, monkeypatch
def test_attribute_survives_an_externally_installed_provider(
isolated_telemetry, clean_env
isolated_telemetry, clean_env, otel_enabled
):
"""Spans must keep coding_agent when the app installs its own provider.
@@ -402,3 +560,219 @@ def test_attaching_to_a_provider_without_processors_is_safe(isolated_telemetry):
telemetry = isolated_telemetry()
telemetry._attach_common_attributes(object())
def _common_attributes(monkeypatch, project_id=None):
"""Build the process-wide span attributes with a stubbed project id."""
from crewai.telemetry.telemetry import Telemetry
monkeypatch.setattr(
"crewai.telemetry.telemetry.get_project_id", lambda *a, **k: project_id
)
telemetry = Telemetry.__new__(Telemetry)
telemetry._common_attributes = None
return telemetry._common_span_attributes()
def test_common_attributes_carry_agent_and_runtime(clean_env, monkeypatch):
"""Both fields ride on every span, independently of each other."""
clean_env.setenv("CLAUDECODE", "1")
clean_env.setenv("GITHUB_ACTIONS", "true")
attributes = _common_attributes(monkeypatch)
assert attributes["coding_agent"] == "claude_code"
assert attributes["runtime_context"] == "ci"
def test_common_attributes_include_project_id_when_declared(clean_env, monkeypatch):
attributes = _common_attributes(monkeypatch, project_id="proj-123")
assert attributes["project_id"] == "proj-123"
def test_project_id_is_omitted_when_absent(clean_env, monkeypatch):
"""Projects without an id must not report a placeholder."""
attributes = _common_attributes(monkeypatch, project_id=None)
assert "project_id" not in attributes
def test_project_id_lookup_never_breaks_telemetry(clean_env, monkeypatch):
"""A failed lookup degrades to omitting the attribute."""
from crewai.telemetry.telemetry import Telemetry
def boom(*args, **kwargs):
raise OSError("unreadable")
monkeypatch.setattr("crewai.telemetry.telemetry.get_project_id", boom)
telemetry = Telemetry.__new__(Telemetry)
telemetry._common_attributes = None
attributes = telemetry._common_span_attributes()
assert "project_id" not in attributes
assert "coding_agent" in attributes
def test_common_attributes_are_computed_once(clean_env, monkeypatch):
"""The project file must not be re-read for each provider."""
from crewai.telemetry.telemetry import Telemetry
calls = []
def counting_get_project_id(*args, **kwargs):
calls.append(1)
return "proj-123"
monkeypatch.setattr(
"crewai.telemetry.telemetry.get_project_id", counting_get_project_id
)
telemetry = Telemetry.__new__(Telemetry)
telemetry._common_attributes = None
first = telemetry._common_span_attributes()
second = telemetry._common_span_attributes()
assert first is second
assert len(calls) == 1
def test_all_common_attributes_land_on_exported_spans(clean_env, monkeypatch, otel_enabled):
"""End-to-end: every common attribute survives onto arbitrary spans."""
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
from crewai.telemetry.telemetry import CommonAttributesSpanProcessor
clean_env.setenv("CLAUDECODE", "1")
clean_env.setenv("CI", "true")
attributes = _common_attributes(monkeypatch, project_id="proj-123")
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(CommonAttributesSpanProcessor(attributes))
provider.add_span_processor(SimpleSpanProcessor(exporter))
provider.get_tracer("test").start_span("Feature Usage").end()
provider.force_flush()
exported = dict(exporter.get_finished_spans()[0].attributes)
assert exported["coding_agent"] == "claude_code"
assert exported["runtime_context"] == "ci"
assert exported["project_id"] == "proj-123"
def test_runtime_markers_are_detected_by_presence(clean_env, monkeypatch):
"""An empty value still means the platform set the marker.
Some platforms export a bare `CI=`; truthiness checks would drop those
runs to the TTY fallback and mislabel them as ordinary local executions.
"""
monkeypatch.setattr("os.path.exists", lambda path: False)
clean_env.setenv("CI", "")
assert detect_runtime_context() == "ci"
def test_managed_platforms_are_not_reported_as_serverless(clean_env):
"""Long-lived managed platforms must not claim the serverless label.
DYNO and WEBSITE_INSTANCE_ID mark Heroku dynos and Azure App Service
instances, which are containers rather than per-invocation functions.
"""
clean_env.setenv("DYNO", "web.1")
assert detect_runtime_context() == "paas"
clean_env.delenv("DYNO")
clean_env.setenv("WEBSITE_INSTANCE_ID", "abc123")
assert detect_runtime_context() == "paas"
def test_serverless_markers_still_win_over_paas(clean_env):
clean_env.setenv("DYNO", "web.1")
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

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.15.12"
__version__ = "1.15.14"