diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index 8b26b4baf2..216746f840 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -149,6 +149,34 @@ def _is_faas() -> bool: return _is_lambda() or _is_azure_func() or _is_gcp_func() or _is_vercel() +# Environment variables that indicate a coding agent, checked in order. The +# first match determines the value of the client.env.agent metadata field. +# See DRIVERS-3529 and PYTHON-5929. +_AGENT_ENV_VARS = [ + ("CLAUDECODE", "CLAUDECODE"), + ("CURSOR_AGENT", "CURSOR"), + ("GEMINI_CLI", "GEMINI_CLI"), + ("CODEX_SANDBOX", "CODEX_SANDBOX"), + ("AUGMENT_AGENT", "AUGMENT"), + ("OPENCODE_CLIENT", "OPENCODE"), +] + + +def _metadata_agent() -> Optional[str]: + """Detect a coding agent from the environment for client.env.agent. + + A generic AI_AGENT or AGENT environment variable takes precedence and its + value is used verbatim. Otherwise the first matching known agent variable + determines the value.""" + agent = os.getenv("AI_AGENT") or os.getenv("AGENT") + if agent: + return agent + for var, name in _AGENT_ENV_VARS: + if os.getenv(var): + return name + return None + + def _getenv_int(key: str) -> Optional[int]: """Like os.getenv but returns an int, or None if the value is missing/malformed.""" val = os.getenv(key) @@ -165,6 +193,9 @@ def _metadata_env() -> dict[str, Any]: container = get_container_env_info() if container: env["container"] = container + agent = _metadata_agent() + if agent: + env["agent"] = agent # Skip if multiple (or no) envs are matched. if (_is_lambda(), _is_azure_func(), _is_gcp_func(), _is_vercel()).count(True) != 1: return env @@ -205,10 +236,11 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: """Perform metadata truncation.""" if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE: return - # 1. Omit fields from env except env.name. - env_name = metadata.get("env", {}).get("name") - if env_name: - metadata["env"] = {"name": env_name} + # 1. Omit fields from env except env.name and env.agent. + env = metadata.get("env", {}) + trimmed_env = {k: env[k] for k in ("name", "agent") if k in env} + if trimmed_env: + metadata["env"] = trimmed_env if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE: return # 2. Omit fields from os except os.type. @@ -217,6 +249,16 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: metadata["os"] = {"type": os_type} if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE: return + # 2b. Drop env.agent (which may hold an arbitrarily large AI_AGENT/AGENT + # value) before sacrificing env.name by dropping the env document entirely. + if "agent" in trimmed_env: + del trimmed_env["agent"] + if trimmed_env: + metadata["env"] = trimmed_env + else: + metadata.pop("env", None) + if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE: + return # 3. Omit the env document entirely. metadata.pop("env", None) encoded_size = len(bson.encode(metadata)) diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index f26f3fb85b..ac68e3c290 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -2204,6 +2204,32 @@ async def test_handshake_09_container_with_provider(self): }, ) + async def test_handshake_10_agent_known(self): + # A known coding-agent env var maps to its metadata value. + await self._test_handshake({"CLAUDECODE": "1"}, {"agent": "CLAUDECODE"}) + await self._test_handshake({"CURSOR_AGENT": "1"}, {"agent": "CURSOR"}) + await self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "OPENCODE"}) + + async def test_handshake_11_agent_generic(self): + # Generic AI_AGENT/AGENT vars are used verbatim and take precedence. + await self._test_handshake({"AI_AGENT": "myagent"}, {"agent": "myagent"}) + await self._test_handshake({"AGENT": "myagent"}, {"agent": "myagent"}) + await self._test_handshake({"AI_AGENT": "myagent", "CLAUDECODE": "1"}, {"agent": "myagent"}) + + async def test_handshake_12_agent_with_provider(self): + # agent is reported alongside a FaaS provider. + await self._test_handshake( + {"FUNCTIONS_WORKER_RUNTIME": "python", "CLAUDECODE": "1"}, + {"name": "azure.func", "agent": "CLAUDECODE"}, + ) + + async def test_handshake_13_agent_too_long(self): + # A too-long agent value is dropped during truncation before env.name. + await self._test_handshake( + {"FUNCTIONS_WORKER_RUNTIME": "python", "AI_AGENT": "a" * 512}, + {"name": "azure.func"}, + ) + def test_dict_hints(self): self.db.t.find(hint={"x": 1}) diff --git a/test/test_client.py b/test/test_client.py index c801d3a178..f1c9c2d0f8 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -2161,6 +2161,32 @@ def test_handshake_09_container_with_provider(self): }, ) + def test_handshake_10_agent_known(self): + # A known coding-agent env var maps to its metadata value. + self._test_handshake({"CLAUDECODE": "1"}, {"agent": "CLAUDECODE"}) + self._test_handshake({"CURSOR_AGENT": "1"}, {"agent": "CURSOR"}) + self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "OPENCODE"}) + + def test_handshake_11_agent_generic(self): + # Generic AI_AGENT/AGENT vars are used verbatim and take precedence. + self._test_handshake({"AI_AGENT": "myagent"}, {"agent": "myagent"}) + self._test_handshake({"AGENT": "myagent"}, {"agent": "myagent"}) + self._test_handshake({"AI_AGENT": "myagent", "CLAUDECODE": "1"}, {"agent": "myagent"}) + + def test_handshake_12_agent_with_provider(self): + # agent is reported alongside a FaaS provider. + self._test_handshake( + {"FUNCTIONS_WORKER_RUNTIME": "python", "CLAUDECODE": "1"}, + {"name": "azure.func", "agent": "CLAUDECODE"}, + ) + + def test_handshake_13_agent_too_long(self): + # A too-long agent value is dropped during truncation before env.name. + self._test_handshake( + {"FUNCTIONS_WORKER_RUNTIME": "python", "AI_AGENT": "a" * 512}, + {"name": "azure.func"}, + ) + def test_dict_hints(self): self.db.t.find(hint={"x": 1})