From 67a8c1fb8f92149b15cf124aa8175416e4c81899 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 20 Jul 2026 11:12:58 -0400 Subject: [PATCH 01/12] PYTHON-5929 Add coding agent env var to handshake metadata Detect coding agent environment variables (AI_AGENT, AGENT, CLAUDECODE, CURSOR_AGENT, GEMINI_CLI, CODEX_SANDBOX, AUGMENT_AGENT, OPENCODE_CLIENT) and report them in the client.env.agent handshake field. See DRIVERS-3529. --- pymongo/pool_options.py | 40 ++++++++++++++++++++++++++++---- test/asynchronous/test_client.py | 19 +++++++++++++++ test/test_client.py | 19 +++++++++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index 8b26b4baf2..fac6c1019c 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. +_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. diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index f26f3fb85b..61b58ce4fd 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -2204,6 +2204,25 @@ 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"}, + ) + 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..f113c6a7b9 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -2161,6 +2161,25 @@ 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_dict_hints(self): self.db.t.find(hint={"x": 1}) From 45b85d5badcd6204d2dc5c3924ca65e959ad0090 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 20 Jul 2026 11:31:33 -0400 Subject: [PATCH 02/12] PYTHON-5929 Reference PYTHON-5929 in agent env var comment --- pymongo/pool_options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index fac6c1019c..d504cc1e1a 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -151,7 +151,7 @@ def _is_faas() -> bool: # 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. +# See DRIVERS-3529 and PYTHON-5929. _AGENT_ENV_VARS = [ ("CLAUDECODE", "CLAUDECODE"), ("CURSOR_AGENT", "CURSOR"), From 22af204745cfc532c3baa37e081372b2027d08c2 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 20 Jul 2026 12:28:25 -0400 Subject: [PATCH 03/12] PYTHON-5929 Drop env.agent before env.name during metadata truncation --- pymongo/pool_options.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index d504cc1e1a..216746f840 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -249,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)) From faa732af1a80ab38d3488dd914185343ec66e68c Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 20 Jul 2026 12:28:52 -0400 Subject: [PATCH 04/12] PYTHON-5929 Add regression test for agent metadata truncation --- test/asynchronous/test_client.py | 7 +++++++ test/test_client.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index 61b58ce4fd..ac68e3c290 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -2223,6 +2223,13 @@ async def test_handshake_12_agent_with_provider(self): {"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 f113c6a7b9..f1c9c2d0f8 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -2180,6 +2180,13 @@ def test_handshake_12_agent_with_provider(self): {"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}) From ce2cbd7da394c1f1369321a4eb4a17700a9b6c39 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 22 Jul 2026 14:04:01 -0400 Subject: [PATCH 05/12] PYTHON-5929 Address Copilot review: drop empty env and isolate agent test env --- pymongo/pool_options.py | 19 +++++++++++-------- test/asynchronous/test_client.py | 14 ++++++++++++-- test/test_client.py | 14 ++++++++++++-- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index 216746f840..37e3cf215d 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -241,16 +241,13 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: trimmed_env = {k: env[k] for k in ("name", "agent") if k in env} if trimmed_env: metadata["env"] = trimmed_env + else: + metadata.pop("env", None) if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE: return - # 2. Omit fields from os except os.type. - os_type = metadata.get("os", {}).get("type") - if os_type: - 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. + # 1b. Drop env.agent (which may hold an arbitrarily large AI_AGENT/AGENT + # value) before trimming os and before sacrificing env.name, so the more + # valuable os and env.name fields are preserved as long as possible. if "agent" in trimmed_env: del trimmed_env["agent"] if trimmed_env: @@ -259,6 +256,12 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: metadata.pop("env", None) if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE: return + # 2. Omit fields from os except os.type. + os_type = metadata.get("os", {}).get("type") + if os_type: + metadata["os"] = {"type": os_type} + 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 ac68e3c290..d6f4743ba6 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -87,7 +87,13 @@ WriteConcernError, ) from pymongo.monitoring import ServerHeartbeatListener, ServerHeartbeatStartedEvent -from pymongo.pool_options import _MAX_METADATA_SIZE, _METADATA, ENV_VAR_K8S, PoolOptions +from pymongo.pool_options import ( + _AGENT_ENV_VARS, + _MAX_METADATA_SIZE, + _METADATA, + ENV_VAR_K8S, + PoolOptions, +) from pymongo.read_preferences import ReadPreference from pymongo.server_description import ServerDescription from pymongo.server_selectors import readable_server_selector, writable_server_selector @@ -2099,7 +2105,11 @@ def test_sigstop_sigcont(self): self.assertNotIn("ServerHeartbeatFailedEvent", log_output) async def _test_handshake(self, env_vars, expected_env): - with patch.dict("os.environ", env_vars): + # Clear any ambient agent-detection vars (e.g. AI_AGENT/AGENT set by the + # CI runner) so detection is deterministic and only reflects env_vars. + agent_vars = ["AI_AGENT", "AGENT", *(var for var, _ in _AGENT_ENV_VARS)] + cleared = {var: "" for var in agent_vars if var not in env_vars} + with patch.dict("os.environ", {**cleared, **env_vars}): metadata = copy.deepcopy(_METADATA) if has_c(): metadata["driver"]["name"] = "PyMongo|c|async" diff --git a/test/test_client.py b/test/test_client.py index f1c9c2d0f8..103095d6a8 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -76,7 +76,13 @@ WriteConcernError, ) from pymongo.monitoring import ServerHeartbeatListener, ServerHeartbeatStartedEvent -from pymongo.pool_options import _MAX_METADATA_SIZE, _METADATA, ENV_VAR_K8S, PoolOptions +from pymongo.pool_options import ( + _AGENT_ENV_VARS, + _MAX_METADATA_SIZE, + _METADATA, + ENV_VAR_K8S, + PoolOptions, +) from pymongo.read_preferences import ReadPreference from pymongo.server_description import ServerDescription from pymongo.server_selectors import readable_server_selector, writable_server_selector @@ -2056,7 +2062,11 @@ def test_sigstop_sigcont(self): self.assertNotIn("ServerHeartbeatFailedEvent", log_output) def _test_handshake(self, env_vars, expected_env): - with patch.dict("os.environ", env_vars): + # Clear any ambient agent-detection vars (e.g. AI_AGENT/AGENT set by the + # CI runner) so detection is deterministic and only reflects env_vars. + agent_vars = ["AI_AGENT", "AGENT", *(var for var, _ in _AGENT_ENV_VARS)] + cleared = {var: "" for var in agent_vars if var not in env_vars} + with patch.dict("os.environ", {**cleared, **env_vars}): metadata = copy.deepcopy(_METADATA) if has_c(): metadata["driver"]["name"] = "PyMongo|c" From 62d8a88cffaa2838ddfcabed8254867dad6bfe12 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 22 Jul 2026 14:32:08 -0400 Subject: [PATCH 06/12] PYTHON-5929 Assert known-agent env var precedence in handshake test --- test/asynchronous/test_client.py | 7 +++++++ test/test_client.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index d6f4743ba6..2b33e80dd9 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -2220,6 +2220,13 @@ async def test_handshake_10_agent_known(self): await self._test_handshake({"CURSOR_AGENT": "1"}, {"agent": "CURSOR"}) await self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "OPENCODE"}) + async def test_handshake_10b_agent_known_precedence(self): + # When multiple known agent vars are set, the first in _AGENT_ENV_VARS + # order wins, regardless of which comes first in the environment dict. + first_var, first_name = _AGENT_ENV_VARS[0] + last_var, _ = _AGENT_ENV_VARS[-1] + await self._test_handshake({last_var: "1", first_var: "1"}, {"agent": first_name}) + 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"}) diff --git a/test/test_client.py b/test/test_client.py index 103095d6a8..c932df582d 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -2177,6 +2177,13 @@ def test_handshake_10_agent_known(self): self._test_handshake({"CURSOR_AGENT": "1"}, {"agent": "CURSOR"}) self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "OPENCODE"}) + def test_handshake_10b_agent_known_precedence(self): + # When multiple known agent vars are set, the first in _AGENT_ENV_VARS + # order wins, regardless of which comes first in the environment dict. + first_var, first_name = _AGENT_ENV_VARS[0] + last_var, _ = _AGENT_ENV_VARS[-1] + self._test_handshake({last_var: "1", first_var: "1"}, {"agent": first_name}) + 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"}) From 988e670f8e54176fcbb70f87d8c131fe03d649f1 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Tue, 22 Sep 2026 13:20:10 -0400 Subject: [PATCH 07/12] PYTHON-5929 Align agent detection with the updated DRIVERS-3529 spec The spec changed after this branch was written: - Evaluate AI_AGENT last, so a versioned value cannot mask a known agent. - Adopt mongosh's variable list and snake_case agent names, and add CLAUDE_CODE_ENTRYPOINT, CLINE_ACTIVE, TRAE_AI_SHELL_ID, GOOSE_TERMINAL and GOOSE_AGENT. - Drop the generic AGENT variable, which is common outside agents. - Normalize AI_AGENT: trim, lowercase, map 1/true to ai_agent, and truncate to 64 characters. - Treat a whitespace-only value as unset. --- pymongo/pool_options.py | 62 ++++++++++++++++++++------------ test/asynchronous/test_client.py | 62 ++++++++++++++++++++------------ test/test_client.py | 62 ++++++++++++++++++++------------ 3 files changed, 117 insertions(+), 69 deletions(-) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index 37e3cf215d..147d1ce8a4 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -149,32 +149,49 @@ 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. +# Environment variables that indicate a known coding agent, checked in order. +# The first populated variable determines the value of the client.env.agent +# metadata field, regardless of the variable's value. This list and the agent +# names match the detection that mongosh implements. # 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"), + ("CLAUDECODE", "claude_code"), + ("CLAUDE_CODE_ENTRYPOINT", "claude_code"), + ("CURSOR_AGENT", "cursor"), + ("CODEX_SANDBOX", "codex_cli"), + ("CLINE_ACTIVE", "cline"), + ("GEMINI_CLI", "gemini_cli"), + ("AUGMENT_AGENT", "auggie_cli"), + ("OPENCODE_CLIENT", "opencode_client"), + ("TRAE_AI_SHELL_ID", "trae_ai"), + ("GOOSE_TERMINAL", "goose"), + ("GOOSE_AGENT", "goose"), ] +# The generic agent variable, evaluated after every known agent so that a +# known agent is always reported under its fixed name. +_GENERIC_AGENT_ENV_VAR = "AI_AGENT" + +# Maximum length of a normalized AI_AGENT value. +_MAX_AGENT_SIZE = 64 + 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 + The first populated known agent variable determines the value. The generic + AI_AGENT variable is evaluated last: its value is trimmed, lowercased and + truncated, and the boolean values "1" and "true" map to "ai_agent".""" for var, name in _AGENT_ENV_VARS: - if os.getenv(var): + # A variable that is unset, empty or whitespace-only is not populated. + if (os.getenv(var) or "").strip(): return name - return None + agent = (os.getenv(_GENERIC_AGENT_ENV_VAR) or "").strip().lower() + if not agent: + return None + if agent in ("1", "true"): + return "ai_agent" + return agent[:_MAX_AGENT_SIZE] def _getenv_int(key: str) -> Optional[int]: @@ -245,9 +262,8 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: metadata.pop("env", None) if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE: return - # 1b. Drop env.agent (which may hold an arbitrarily large AI_AGENT/AGENT - # value) before trimming os and before sacrificing env.name, so the more - # valuable os and env.name fields are preserved as long as possible. + # 2. Omit env.agent, before trimming os and before sacrificing env.name. + # Drivers have reported env.name since before env.agent existed. if "agent" in trimmed_env: del trimmed_env["agent"] if trimmed_env: @@ -256,18 +272,18 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: metadata.pop("env", None) if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE: return - # 2. Omit fields from os except os.type. + # 3. Omit fields from os except os.type. os_type = metadata.get("os", {}).get("type") if os_type: metadata["os"] = {"type": os_type} if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE: return - # 3. Omit the env document entirely. + # 4. Omit the env document entirely. metadata.pop("env", None) encoded_size = len(bson.encode(metadata)) if encoded_size <= _MAX_METADATA_SIZE: return - # 4. Truncate platform. + # 5. Truncate platform. overflow = encoded_size - _MAX_METADATA_SIZE plat = metadata.get("platform", "") if plat: @@ -279,7 +295,7 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: encoded_size = len(bson.encode(metadata)) if encoded_size <= _MAX_METADATA_SIZE: return - # 5. Truncate driver info. + # 6. Truncate driver info. overflow = encoded_size - _MAX_METADATA_SIZE driver = metadata.get("driver", {}) if driver: diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index 2b33e80dd9..b42d530577 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -89,6 +89,7 @@ from pymongo.monitoring import ServerHeartbeatListener, ServerHeartbeatStartedEvent from pymongo.pool_options import ( _AGENT_ENV_VARS, + _MAX_AGENT_SIZE, _MAX_METADATA_SIZE, _METADATA, ENV_VAR_K8S, @@ -2105,9 +2106,9 @@ def test_sigstop_sigcont(self): self.assertNotIn("ServerHeartbeatFailedEvent", log_output) async def _test_handshake(self, env_vars, expected_env): - # Clear any ambient agent-detection vars (e.g. AI_AGENT/AGENT set by the - # CI runner) so detection is deterministic and only reflects env_vars. - agent_vars = ["AI_AGENT", "AGENT", *(var for var, _ in _AGENT_ENV_VARS)] + # Clear any ambient agent-detection vars (e.g. AI_AGENT or CLAUDECODE set + # by the CI runner) so detection only reflects env_vars. + agent_vars = ["AI_AGENT", *(var for var, _ in _AGENT_ENV_VARS)] cleared = {var: "" for var in agent_vars if var not in env_vars} with patch.dict("os.environ", {**cleared, **env_vars}): metadata = copy.deepcopy(_METADATA) @@ -2215,36 +2216,51 @@ 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"}) + # A known agent env var maps to its fixed name, regardless of value. + await self._test_handshake({"CLAUDECODE": "1"}, {"agent": "claude_code"}) + await self._test_handshake({"CURSOR_AGENT": "some-value-42"}, {"agent": "cursor"}) + await self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "opencode_client"}) async def test_handshake_10b_agent_known_precedence(self): # When multiple known agent vars are set, the first in _AGENT_ENV_VARS # order wins, regardless of which comes first in the environment dict. - first_var, first_name = _AGENT_ENV_VARS[0] - last_var, _ = _AGENT_ENV_VARS[-1] - await self._test_handshake({last_var: "1", first_var: "1"}, {"agent": first_name}) + await self._test_handshake({"GEMINI_CLI": "1", "CURSOR_AGENT": "1"}, {"agent": "cursor"}) - 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_11_agent_known_beats_generic(self): + # A known agent wins over the generic AI_AGENT variable, so a versioned + # AI_AGENT value cannot mask a known agent. + await self._test_handshake( + {"AI_AGENT": "custom-agent", "CLAUDECODE": "1"}, {"agent": "claude_code"} + ) - async def test_handshake_12_agent_with_provider(self): - # agent is reported alongside a FaaS provider. + async def test_handshake_12_agent_generic(self): + # A descriptive AI_AGENT value is used as-is, and the boolean values + # "1" and "true" map to the fixed string "ai_agent". + await self._test_handshake({"AI_AGENT": "custom-agent"}, {"agent": "custom-agent"}) + await self._test_handshake({"AI_AGENT": "1"}, {"agent": "ai_agent"}) + await self._test_handshake({"AI_AGENT": "true"}, {"agent": "ai_agent"}) + + async def test_handshake_13_agent_generic_normalized(self): + # AI_AGENT is trimmed and lowercased. await self._test_handshake( - {"FUNCTIONS_WORKER_RUNTIME": "python", "CLAUDECODE": "1"}, - {"name": "azure.func", "agent": "CLAUDECODE"}, + {"AI_AGENT": " Claude-Code_2-1-238_Agent "}, {"agent": "claude-code_2-1-238_agent"} ) - async def test_handshake_13_agent_too_long(self): - # A too-long agent value is dropped during truncation before env.name. + async def test_handshake_14_agent_generic_truncated(self): + # A long AI_AGENT value is truncated to _MAX_AGENT_SIZE characters. + await self._test_handshake({"AI_AGENT": "a" * 100}, {"agent": "a" * _MAX_AGENT_SIZE}) + + async def test_handshake_15_agent_unset(self): + # An empty or whitespace-only value is treated as unset. + await self._test_handshake({"AI_AGENT": ""}, None) + await self._test_handshake({"AI_AGENT": " "}, None) + await self._test_handshake({"CLAUDECODE": " "}, None) + + async def test_handshake_16_agent_with_provider(self): + # agent is reported alongside a FaaS provider. await self._test_handshake( - {"FUNCTIONS_WORKER_RUNTIME": "python", "AI_AGENT": "a" * 512}, - {"name": "azure.func"}, + {"FUNCTIONS_WORKER_RUNTIME": "python", "CLAUDECODE": "1"}, + {"name": "azure.func", "agent": "claude_code"}, ) def test_dict_hints(self): diff --git a/test/test_client.py b/test/test_client.py index c932df582d..a919e8d278 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -78,6 +78,7 @@ from pymongo.monitoring import ServerHeartbeatListener, ServerHeartbeatStartedEvent from pymongo.pool_options import ( _AGENT_ENV_VARS, + _MAX_AGENT_SIZE, _MAX_METADATA_SIZE, _METADATA, ENV_VAR_K8S, @@ -2062,9 +2063,9 @@ def test_sigstop_sigcont(self): self.assertNotIn("ServerHeartbeatFailedEvent", log_output) def _test_handshake(self, env_vars, expected_env): - # Clear any ambient agent-detection vars (e.g. AI_AGENT/AGENT set by the - # CI runner) so detection is deterministic and only reflects env_vars. - agent_vars = ["AI_AGENT", "AGENT", *(var for var, _ in _AGENT_ENV_VARS)] + # Clear any ambient agent-detection vars (e.g. AI_AGENT or CLAUDECODE set + # by the CI runner) so detection only reflects env_vars. + agent_vars = ["AI_AGENT", *(var for var, _ in _AGENT_ENV_VARS)] cleared = {var: "" for var in agent_vars if var not in env_vars} with patch.dict("os.environ", {**cleared, **env_vars}): metadata = copy.deepcopy(_METADATA) @@ -2172,36 +2173,51 @@ 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"}) + # A known agent env var maps to its fixed name, regardless of value. + self._test_handshake({"CLAUDECODE": "1"}, {"agent": "claude_code"}) + self._test_handshake({"CURSOR_AGENT": "some-value-42"}, {"agent": "cursor"}) + self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "opencode_client"}) def test_handshake_10b_agent_known_precedence(self): # When multiple known agent vars are set, the first in _AGENT_ENV_VARS # order wins, regardless of which comes first in the environment dict. - first_var, first_name = _AGENT_ENV_VARS[0] - last_var, _ = _AGENT_ENV_VARS[-1] - self._test_handshake({last_var: "1", first_var: "1"}, {"agent": first_name}) + self._test_handshake({"GEMINI_CLI": "1", "CURSOR_AGENT": "1"}, {"agent": "cursor"}) - 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_11_agent_known_beats_generic(self): + # A known agent wins over the generic AI_AGENT variable, so a versioned + # AI_AGENT value cannot mask a known agent. + self._test_handshake( + {"AI_AGENT": "custom-agent", "CLAUDECODE": "1"}, {"agent": "claude_code"} + ) - def test_handshake_12_agent_with_provider(self): - # agent is reported alongside a FaaS provider. + def test_handshake_12_agent_generic(self): + # A descriptive AI_AGENT value is used as-is, and the boolean values + # "1" and "true" map to the fixed string "ai_agent". + self._test_handshake({"AI_AGENT": "custom-agent"}, {"agent": "custom-agent"}) + self._test_handshake({"AI_AGENT": "1"}, {"agent": "ai_agent"}) + self._test_handshake({"AI_AGENT": "true"}, {"agent": "ai_agent"}) + + def test_handshake_13_agent_generic_normalized(self): + # AI_AGENT is trimmed and lowercased. self._test_handshake( - {"FUNCTIONS_WORKER_RUNTIME": "python", "CLAUDECODE": "1"}, - {"name": "azure.func", "agent": "CLAUDECODE"}, + {"AI_AGENT": " Claude-Code_2-1-238_Agent "}, {"agent": "claude-code_2-1-238_agent"} ) - def test_handshake_13_agent_too_long(self): - # A too-long agent value is dropped during truncation before env.name. + def test_handshake_14_agent_generic_truncated(self): + # A long AI_AGENT value is truncated to _MAX_AGENT_SIZE characters. + self._test_handshake({"AI_AGENT": "a" * 100}, {"agent": "a" * _MAX_AGENT_SIZE}) + + def test_handshake_15_agent_unset(self): + # An empty or whitespace-only value is treated as unset. + self._test_handshake({"AI_AGENT": ""}, None) + self._test_handshake({"AI_AGENT": " "}, None) + self._test_handshake({"CLAUDECODE": " "}, None) + + def test_handshake_16_agent_with_provider(self): + # agent is reported alongside a FaaS provider. self._test_handshake( - {"FUNCTIONS_WORKER_RUNTIME": "python", "AI_AGENT": "a" * 512}, - {"name": "azure.func"}, + {"FUNCTIONS_WORKER_RUNTIME": "python", "CLAUDECODE": "1"}, + {"name": "azure.func", "agent": "claude_code"}, ) def test_dict_hints(self): From cb105cf5d8a6f88e669e22bab8959712d27c37b5 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 23 Sep 2026 15:45:09 -0400 Subject: [PATCH 08/12] PYTHON-5929 Truncate AI_AGENT by bytes on a character boundary --- pymongo/pool_options.py | 7 +++++-- test/asynchronous/test_client.py | 8 +++++++- test/test_client.py | 8 +++++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index 147d1ce8a4..cb59cc9260 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -172,7 +172,7 @@ def _is_faas() -> bool: # known agent is always reported under its fixed name. _GENERIC_AGENT_ENV_VAR = "AI_AGENT" -# Maximum length of a normalized AI_AGENT value. +# Maximum size in bytes of a normalized AI_AGENT value. _MAX_AGENT_SIZE = 64 @@ -191,7 +191,10 @@ def _metadata_agent() -> Optional[str]: return None if agent in ("1", "true"): return "ai_agent" - return agent[:_MAX_AGENT_SIZE] + # Truncate to the largest valid UTF-8 prefix of _MAX_AGENT_SIZE bytes. + # "ignore" drops a character split by the limit instead of replacing it + # with U+FFFD. + return agent.encode()[:_MAX_AGENT_SIZE].decode(errors="ignore") def _getenv_int(key: str) -> Optional[int]: diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index b42d530577..39a8e1d746 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -2247,9 +2247,15 @@ async def test_handshake_13_agent_generic_normalized(self): ) async def test_handshake_14_agent_generic_truncated(self): - # A long AI_AGENT value is truncated to _MAX_AGENT_SIZE characters. + # A long AI_AGENT value is truncated to _MAX_AGENT_SIZE bytes. await self._test_handshake({"AI_AGENT": "a" * 100}, {"agent": "a" * _MAX_AGENT_SIZE}) + async def test_handshake_14b_agent_generic_truncated_on_boundary(self): + # The byte limit falls inside the two-byte "é", so the whole character + # is dropped. No part of it, and no U+FFFD, may appear. + value = "a" * (_MAX_AGENT_SIZE - 1) + "é" + await self._test_handshake({"AI_AGENT": value}, {"agent": "a" * (_MAX_AGENT_SIZE - 1)}) + async def test_handshake_15_agent_unset(self): # An empty or whitespace-only value is treated as unset. await self._test_handshake({"AI_AGENT": ""}, None) diff --git a/test/test_client.py b/test/test_client.py index a919e8d278..9058d1b90c 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -2204,9 +2204,15 @@ def test_handshake_13_agent_generic_normalized(self): ) def test_handshake_14_agent_generic_truncated(self): - # A long AI_AGENT value is truncated to _MAX_AGENT_SIZE characters. + # A long AI_AGENT value is truncated to _MAX_AGENT_SIZE bytes. self._test_handshake({"AI_AGENT": "a" * 100}, {"agent": "a" * _MAX_AGENT_SIZE}) + def test_handshake_14b_agent_generic_truncated_on_boundary(self): + # The byte limit falls inside the two-byte "é", so the whole character + # is dropped. No part of it, and no U+FFFD, may appear. + value = "a" * (_MAX_AGENT_SIZE - 1) + "é" + self._test_handshake({"AI_AGENT": value}, {"agent": "a" * (_MAX_AGENT_SIZE - 1)}) + def test_handshake_15_agent_unset(self): # An empty or whitespace-only value is treated as unset. self._test_handshake({"AI_AGENT": ""}, None) From 5e80b3d6d91c8c6c187d4d8a5615046487eba7e3 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 23 Sep 2026 15:48:06 -0400 Subject: [PATCH 09/12] PYTHON-5929 Tighten agent detection comments --- pymongo/pool_options.py | 26 +++++++++----------------- test/asynchronous/test_client.py | 23 +++++++++++------------ test/test_client.py | 23 +++++++++++------------ 3 files changed, 31 insertions(+), 41 deletions(-) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index cb59cc9260..78ff24e47d 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -149,10 +149,8 @@ def _is_faas() -> bool: return _is_lambda() or _is_azure_func() or _is_gcp_func() or _is_vercel() -# Environment variables that indicate a known coding agent, checked in order. -# The first populated variable determines the value of the client.env.agent -# metadata field, regardless of the variable's value. This list and the agent -# names match the detection that mongosh implements. +# Known coding agents, checked in order. The first populated variable gives +# client.env.agent, whatever its value. Matches mongosh detection. # See DRIVERS-3529 and PYTHON-5929. _AGENT_ENV_VARS = [ ("CLAUDECODE", "claude_code"), @@ -168,8 +166,7 @@ def _is_faas() -> bool: ("GOOSE_AGENT", "goose"), ] -# The generic agent variable, evaluated after every known agent so that a -# known agent is always reported under its fixed name. +# Generic agent variable, checked last so a known agent keeps its fixed name. _GENERIC_AGENT_ENV_VAR = "AI_AGENT" # Maximum size in bytes of a normalized AI_AGENT value. @@ -177,13 +174,9 @@ def _is_faas() -> bool: def _metadata_agent() -> Optional[str]: - """Detect a coding agent from the environment for client.env.agent. - - The first populated known agent variable determines the value. The generic - AI_AGENT variable is evaluated last: its value is trimmed, lowercased and - truncated, and the boolean values "1" and "true" map to "ai_agent".""" + """Detect a coding agent from the environment for client.env.agent.""" for var, name in _AGENT_ENV_VARS: - # A variable that is unset, empty or whitespace-only is not populated. + # Unset, empty or whitespace-only is not populated. if (os.getenv(var) or "").strip(): return name agent = (os.getenv(_GENERIC_AGENT_ENV_VAR) or "").strip().lower() @@ -191,9 +184,8 @@ def _metadata_agent() -> Optional[str]: return None if agent in ("1", "true"): return "ai_agent" - # Truncate to the largest valid UTF-8 prefix of _MAX_AGENT_SIZE bytes. - # "ignore" drops a character split by the limit instead of replacing it - # with U+FFFD. + # Largest valid UTF-8 prefix of _MAX_AGENT_SIZE bytes. "ignore" drops a + # split character instead of replacing it with U+FFFD. return agent.encode()[:_MAX_AGENT_SIZE].decode(errors="ignore") @@ -265,8 +257,8 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: metadata.pop("env", None) if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE: return - # 2. Omit env.agent, before trimming os and before sacrificing env.name. - # Drivers have reported env.name since before env.agent existed. + # 2. Omit env.agent. It goes before env.name, which drivers have reported + # for longer. if "agent" in trimmed_env: del trimmed_env["agent"] if trimmed_env: diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index 39a8e1d746..ec08c23ee0 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -2106,8 +2106,8 @@ def test_sigstop_sigcont(self): self.assertNotIn("ServerHeartbeatFailedEvent", log_output) async def _test_handshake(self, env_vars, expected_env): - # Clear any ambient agent-detection vars (e.g. AI_AGENT or CLAUDECODE set - # by the CI runner) so detection only reflects env_vars. + # Clear ambient agent vars (e.g. AI_AGENT set by the CI runner) so + # detection only reflects env_vars. agent_vars = ["AI_AGENT", *(var for var, _ in _AGENT_ENV_VARS)] cleared = {var: "" for var in agent_vars if var not in env_vars} with patch.dict("os.environ", {**cleared, **env_vars}): @@ -2222,20 +2222,19 @@ async def test_handshake_10_agent_known(self): await self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "opencode_client"}) async def test_handshake_10b_agent_known_precedence(self): - # When multiple known agent vars are set, the first in _AGENT_ENV_VARS - # order wins, regardless of which comes first in the environment dict. + # The first var in _AGENT_ENV_VARS order wins, not the first in the + # environment dict. await self._test_handshake({"GEMINI_CLI": "1", "CURSOR_AGENT": "1"}, {"agent": "cursor"}) async def test_handshake_11_agent_known_beats_generic(self): - # A known agent wins over the generic AI_AGENT variable, so a versioned - # AI_AGENT value cannot mask a known agent. + # A known agent wins over AI_AGENT, so a versioned AI_AGENT value + # cannot mask it. await self._test_handshake( {"AI_AGENT": "custom-agent", "CLAUDECODE": "1"}, {"agent": "claude_code"} ) async def test_handshake_12_agent_generic(self): - # A descriptive AI_AGENT value is used as-is, and the boolean values - # "1" and "true" map to the fixed string "ai_agent". + # A descriptive value is used as-is. "1" and "true" map to "ai_agent". await self._test_handshake({"AI_AGENT": "custom-agent"}, {"agent": "custom-agent"}) await self._test_handshake({"AI_AGENT": "1"}, {"agent": "ai_agent"}) await self._test_handshake({"AI_AGENT": "true"}, {"agent": "ai_agent"}) @@ -2247,17 +2246,17 @@ async def test_handshake_13_agent_generic_normalized(self): ) async def test_handshake_14_agent_generic_truncated(self): - # A long AI_AGENT value is truncated to _MAX_AGENT_SIZE bytes. + # A long value is truncated to _MAX_AGENT_SIZE bytes. await self._test_handshake({"AI_AGENT": "a" * 100}, {"agent": "a" * _MAX_AGENT_SIZE}) async def test_handshake_14b_agent_generic_truncated_on_boundary(self): - # The byte limit falls inside the two-byte "é", so the whole character - # is dropped. No part of it, and no U+FFFD, may appear. + # The byte limit falls inside the two-byte "é", so the character is + # dropped. No part of it, and no U+FFFD, may appear. value = "a" * (_MAX_AGENT_SIZE - 1) + "é" await self._test_handshake({"AI_AGENT": value}, {"agent": "a" * (_MAX_AGENT_SIZE - 1)}) async def test_handshake_15_agent_unset(self): - # An empty or whitespace-only value is treated as unset. + # An empty or whitespace-only value counts as unset. await self._test_handshake({"AI_AGENT": ""}, None) await self._test_handshake({"AI_AGENT": " "}, None) await self._test_handshake({"CLAUDECODE": " "}, None) diff --git a/test/test_client.py b/test/test_client.py index 9058d1b90c..b1482b3eb9 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -2063,8 +2063,8 @@ def test_sigstop_sigcont(self): self.assertNotIn("ServerHeartbeatFailedEvent", log_output) def _test_handshake(self, env_vars, expected_env): - # Clear any ambient agent-detection vars (e.g. AI_AGENT or CLAUDECODE set - # by the CI runner) so detection only reflects env_vars. + # Clear ambient agent vars (e.g. AI_AGENT set by the CI runner) so + # detection only reflects env_vars. agent_vars = ["AI_AGENT", *(var for var, _ in _AGENT_ENV_VARS)] cleared = {var: "" for var in agent_vars if var not in env_vars} with patch.dict("os.environ", {**cleared, **env_vars}): @@ -2179,20 +2179,19 @@ def test_handshake_10_agent_known(self): self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "opencode_client"}) def test_handshake_10b_agent_known_precedence(self): - # When multiple known agent vars are set, the first in _AGENT_ENV_VARS - # order wins, regardless of which comes first in the environment dict. + # The first var in _AGENT_ENV_VARS order wins, not the first in the + # environment dict. self._test_handshake({"GEMINI_CLI": "1", "CURSOR_AGENT": "1"}, {"agent": "cursor"}) def test_handshake_11_agent_known_beats_generic(self): - # A known agent wins over the generic AI_AGENT variable, so a versioned - # AI_AGENT value cannot mask a known agent. + # A known agent wins over AI_AGENT, so a versioned AI_AGENT value + # cannot mask it. self._test_handshake( {"AI_AGENT": "custom-agent", "CLAUDECODE": "1"}, {"agent": "claude_code"} ) def test_handshake_12_agent_generic(self): - # A descriptive AI_AGENT value is used as-is, and the boolean values - # "1" and "true" map to the fixed string "ai_agent". + # A descriptive value is used as-is. "1" and "true" map to "ai_agent". self._test_handshake({"AI_AGENT": "custom-agent"}, {"agent": "custom-agent"}) self._test_handshake({"AI_AGENT": "1"}, {"agent": "ai_agent"}) self._test_handshake({"AI_AGENT": "true"}, {"agent": "ai_agent"}) @@ -2204,17 +2203,17 @@ def test_handshake_13_agent_generic_normalized(self): ) def test_handshake_14_agent_generic_truncated(self): - # A long AI_AGENT value is truncated to _MAX_AGENT_SIZE bytes. + # A long value is truncated to _MAX_AGENT_SIZE bytes. self._test_handshake({"AI_AGENT": "a" * 100}, {"agent": "a" * _MAX_AGENT_SIZE}) def test_handshake_14b_agent_generic_truncated_on_boundary(self): - # The byte limit falls inside the two-byte "é", so the whole character - # is dropped. No part of it, and no U+FFFD, may appear. + # The byte limit falls inside the two-byte "é", so the character is + # dropped. No part of it, and no U+FFFD, may appear. value = "a" * (_MAX_AGENT_SIZE - 1) + "é" self._test_handshake({"AI_AGENT": value}, {"agent": "a" * (_MAX_AGENT_SIZE - 1)}) def test_handshake_15_agent_unset(self): - # An empty or whitespace-only value is treated as unset. + # An empty or whitespace-only value counts as unset. self._test_handshake({"AI_AGENT": ""}, None) self._test_handshake({"AI_AGENT": " "}, None) self._test_handshake({"CLAUDECODE": " "}, None) From 713edd258393de2ceec1489a943541a37661786f Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 23 Sep 2026 15:53:14 -0400 Subject: [PATCH 10/12] PYTHON-5929 Add changelog entry for agent handshake metadata --- doc/changelog.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/doc/changelog.rst b/doc/changelog.rst index f6aadd20a1..ccd0cae7b6 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -22,6 +22,13 @@ Changes in Version 4.18.0 the bytes remaining in the array now raises :class:`~bson.errors.InvalidBSON` instead of reading past the end of the buffer. +- The client handshake metadata now reports a detected coding agent in + ``client.env.agent``. The driver checks a fixed list of agent environment + variables (for example ``CLAUDECODE``, ``CURSOR_AGENT`` and + ``CODEX_SANDBOX``) and reports a fixed name for the first one that is set. + If none is set, the driver uses the generic ``AI_AGENT`` variable: ``1`` or + ``true`` is reported as ``ai_agent``, and any other value is trimmed, + lowercased and truncated to 64 bytes. Changes in Version 4.17.0 (2026/04/20) -------------------------------------- From 843672d65473a2e42986e72990392ba06ee76100 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Wed, 23 Sep 2026 16:17:48 -0400 Subject: [PATCH 11/12] PYTHON-5929 Clear ambient agent env vars in metadata tests test_metadata and test_container_metadata compare the full handshake metadata dict. They fail when the test process itself runs under a coding agent, because env.agent is then present. Add a no_ambient_agent() helper that clears every var the handshake reads, and use it in both tests. _test_handshake now reuses the helper instead of clearing the vars inline. --- test/asynchronous/test_client.py | 8 ++++---- test/test_client.py | 8 ++++---- test/utils_shared.py | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index 04b7f45e30..88e7274432 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -91,7 +91,6 @@ ) from pymongo.monitoring import ServerHeartbeatListener, ServerHeartbeatStartedEvent from pymongo.pool_options import ( - _AGENT_ENV_VARS, _MAX_AGENT_SIZE, _MAX_METADATA_SIZE, _METADATA, @@ -135,6 +134,7 @@ gevent_monkey_patched, is_greenthread_patched, lazy_client_trial, + no_ambient_agent, one, suppress_fork_deprecation, ) @@ -388,6 +388,7 @@ async def test_read_preference(self): ) self.assertEqual(c.read_preference, ReadPreference.NEAREST) + @no_ambient_agent() async def test_metadata(self): metadata = copy.deepcopy(_METADATA) if has_c(): @@ -460,6 +461,7 @@ async def test_metadata(self): _MAX_METADATA_SIZE, ) + @no_ambient_agent() @mock.patch.dict("os.environ", {ENV_VAR_K8S: "1"}) def test_container_metadata(self): metadata = copy.deepcopy(_METADATA) @@ -2229,9 +2231,7 @@ def test_sigstop_sigcont(self): async def _test_handshake(self, env_vars, expected_env): # Clear ambient agent vars (e.g. AI_AGENT set by the CI runner) so # detection only reflects env_vars. - agent_vars = ["AI_AGENT", *(var for var, _ in _AGENT_ENV_VARS)] - cleared = {var: "" for var in agent_vars if var not in env_vars} - with patch.dict("os.environ", {**cleared, **env_vars}): + with no_ambient_agent(keep=env_vars), patch.dict("os.environ", env_vars): metadata = copy.deepcopy(_METADATA) if has_c(): metadata["driver"]["name"] = "PyMongo|c|async" diff --git a/test/test_client.py b/test/test_client.py index e13d632c8a..7b3d985b8f 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -82,7 +82,6 @@ ) from pymongo.monitoring import ServerHeartbeatListener, ServerHeartbeatStartedEvent from pymongo.pool_options import ( - _AGENT_ENV_VARS, _MAX_AGENT_SIZE, _MAX_METADATA_SIZE, _METADATA, @@ -134,6 +133,7 @@ gevent_monkey_patched, is_greenthread_patched, lazy_client_trial, + no_ambient_agent, one, suppress_fork_deprecation, ) @@ -381,6 +381,7 @@ def test_read_preference(self): ) self.assertEqual(c.read_preference, ReadPreference.NEAREST) + @no_ambient_agent() def test_metadata(self): metadata = copy.deepcopy(_METADATA) if has_c(): @@ -453,6 +454,7 @@ def test_metadata(self): _MAX_METADATA_SIZE, ) + @no_ambient_agent() @mock.patch.dict("os.environ", {ENV_VAR_K8S: "1"}) def test_container_metadata(self): metadata = copy.deepcopy(_METADATA) @@ -2182,9 +2184,7 @@ def test_sigstop_sigcont(self): def _test_handshake(self, env_vars, expected_env): # Clear ambient agent vars (e.g. AI_AGENT set by the CI runner) so # detection only reflects env_vars. - agent_vars = ["AI_AGENT", *(var for var, _ in _AGENT_ENV_VARS)] - cleared = {var: "" for var in agent_vars if var not in env_vars} - with patch.dict("os.environ", {**cleared, **env_vars}): + with no_ambient_agent(keep=env_vars), patch.dict("os.environ", env_vars): metadata = copy.deepcopy(_METADATA) if has_c(): metadata["driver"]["name"] = "PyMongo|c" diff --git a/test/utils_shared.py b/test/utils_shared.py index 96f3b1f6ed..43121a0def 100644 --- a/test/utils_shared.py +++ b/test/utils_shared.py @@ -31,6 +31,7 @@ from collections import abc, defaultdict from functools import partial from inspect import iscoroutinefunction +from unittest.mock import patch from bson.objectid import ObjectId from pymongo import monitoring, operations, read_preferences @@ -51,6 +52,7 @@ PoolCreatedEvent, PoolReadyEvent, ) +from pymongo.pool_options import _AGENT_ENV_VARS, _GENERIC_AGENT_ENV_VAR from pymongo.pool_shared import _CancellationContext, _PoolGeneration from pymongo.read_concern import ReadConcern from pymongo.server_type import SERVER_TYPE @@ -613,6 +615,20 @@ def suppress_fork_deprecation(): yield +def no_ambient_agent(keep=()): + """Clear the coding agent env vars the handshake reads. + + The test process itself often runs under a coding agent, which would add + client.env.agent to the handshake metadata. Vars named in `keep` are left + alone so a test can set its own (PYTHON-5929). + + :param keep: env var names to leave unchanged. + :return: a patch.dict context manager / decorator. + """ + agent_vars = [_GENERIC_AGENT_ENV_VAR, *(var for var, _ in _AGENT_ENV_VARS)] + return patch.dict("os.environ", {var: "" for var in agent_vars if var not in keep}) + + def parse_read_preference(pref): # Make first letter lowercase to match read_pref's modes. mode_string = pref.get("mode", "primary") From e8c98d7b8c237b941fd53ac6331b1604e5e97b7a Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Fri, 9 Oct 2026 20:12:19 -0400 Subject: [PATCH 12/12] PYTHON-5929 Handle env with only agent in truncation step 2 --- pymongo/pool_options.py | 15 +++++++------ test/asynchronous/test_client.py | 36 ++++++++++++++++++++++++++++++++ test/test_client.py | 36 ++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index 69bef8f648..825d85b25d 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -288,12 +288,15 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: if size <= _MAX_METADATA_SIZE: return # 2. Omit env.agent. It goes before env.name, which drivers have reported - # for longer. - env_name = metadata.get("env", {}).get("name") - if env_name: - env = {"name": env_name} - size += _element_size("env", env) - _element_size("env", metadata["env"]) - metadata["env"] = env + # for longer. If env has no remaining fields, omit env entirely. + env = metadata.get("env") + if env is not None and "agent" in env: + new_env = {k: v for k, v in env.items() if k != "agent"} + size += _element_size("env", new_env) - _element_size("env", env) + if new_env: + metadata["env"] = new_env + else: + del metadata["env"] if size <= _MAX_METADATA_SIZE: return # 3. Omit fields from os except os.type. diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index 3ca31efe5a..2a9b32c8a5 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -516,6 +516,42 @@ def metadata_for(name_len: int) -> dict[str, Any]: {"name": "PyMongo|" + "W" * name_len, "version": "1.0|1.0"}, ) + async def test_metadata_truncation_omits_agent_before_name(self): + # Truncation keeps env.name and env.agent first, then omits env.agent + # alone. An env holding only agent has no remaining field, so the env + # document is omitted entirely. + + def metadata_for(plat_len: int, with_name: bool) -> dict[str, Any]: + env: dict[str, Any] = {"agent": "claude_code"} + if with_name: + env["name"] = "azure.func" + return {"env": env, "platform": "p" * plat_len} + + # Size platform so the document is one byte over the limit, less than + # the bytes the agent element costs: dropping agent alone must make it + # fit without touching platform. + plat_len = next( + n + for n in range(1, 2 * _MAX_METADATA_SIZE) + if len(bson.encode(metadata_for(n, True))) == _MAX_METADATA_SIZE + 1 + ) + metadata = metadata_for(plat_len, True) + _truncate_metadata(metadata) + self.assertLessEqual(len(bson.encode(metadata)), _MAX_METADATA_SIZE) + self.assertEqual(metadata["env"], {"name": "azure.func"}) + self.assertEqual(metadata["platform"], "p" * plat_len) + + plat_len = next( + n + for n in range(1, 2 * _MAX_METADATA_SIZE) + if len(bson.encode(metadata_for(n, False))) == _MAX_METADATA_SIZE + 1 + ) + metadata = metadata_for(plat_len, False) + _truncate_metadata(metadata) + self.assertLessEqual(len(bson.encode(metadata)), _MAX_METADATA_SIZE) + self.assertNotIn("env", metadata) + self.assertEqual(metadata["platform"], "p" * plat_len) + async def test_metadata_append_is_bounded(self): # Successive appends must stay within the limit and keep name and # version index-aligned after truncation. Once the metadata saturates, diff --git a/test/test_client.py b/test/test_client.py index c1a440f1dd..b73bf45a5a 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -509,6 +509,42 @@ def metadata_for(name_len: int) -> dict[str, Any]: {"name": "PyMongo|" + "W" * name_len, "version": "1.0|1.0"}, ) + def test_metadata_truncation_omits_agent_before_name(self): + # Truncation keeps env.name and env.agent first, then omits env.agent + # alone. An env holding only agent has no remaining field, so the env + # document is omitted entirely. + + def metadata_for(plat_len: int, with_name: bool) -> dict[str, Any]: + env: dict[str, Any] = {"agent": "claude_code"} + if with_name: + env["name"] = "azure.func" + return {"env": env, "platform": "p" * plat_len} + + # Size platform so the document is one byte over the limit, less than + # the bytes the agent element costs: dropping agent alone must make it + # fit without touching platform. + plat_len = next( + n + for n in range(1, 2 * _MAX_METADATA_SIZE) + if len(bson.encode(metadata_for(n, True))) == _MAX_METADATA_SIZE + 1 + ) + metadata = metadata_for(plat_len, True) + _truncate_metadata(metadata) + self.assertLessEqual(len(bson.encode(metadata)), _MAX_METADATA_SIZE) + self.assertEqual(metadata["env"], {"name": "azure.func"}) + self.assertEqual(metadata["platform"], "p" * plat_len) + + plat_len = next( + n + for n in range(1, 2 * _MAX_METADATA_SIZE) + if len(bson.encode(metadata_for(n, False))) == _MAX_METADATA_SIZE + 1 + ) + metadata = metadata_for(plat_len, False) + _truncate_metadata(metadata) + self.assertLessEqual(len(bson.encode(metadata)), _MAX_METADATA_SIZE) + self.assertNotIn("env", metadata) + self.assertEqual(metadata["platform"], "p" * plat_len) + def test_metadata_append_is_bounded(self): # Successive appends must stay within the limit and keep name and # version index-aligned after truncation. Once the metadata saturates,