mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-09-11 05:39:39 +00:00
Merge branch 'main' into fix/native-tool-call-responses-api-shape
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.15.12"
|
||||
__version__ = "1.15.14"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.15.12"
|
||||
__version__ = "1.15.14"
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.15.12"
|
||||
__version__ = "1.15.14"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -340,4 +340,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.15.12"
|
||||
__version__ = "1.15.14"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.15.12"
|
||||
__version__ = "1.15.14"
|
||||
|
||||
Reference in New Issue
Block a user