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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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, From 3d137971c1ead668cba365c498e02d8d4731191e Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 09:53:39 -0400 Subject: [PATCH 13/28] PYTHON-5929 Add OPENCODE env var detection and fix ambient-agent flakiness in metadata test --- pymongo/pool_options.py | 1 + test/asynchronous/test_client.py | 2 ++ test/test_client.py | 2 ++ 3 files changed, 5 insertions(+) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index fd15eb5674..0ec9019524 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -153,6 +153,7 @@ def _is_faas() -> bool: ("CLINE_ACTIVE", "cline"), ("GEMINI_CLI", "gemini_cli"), ("AUGMENT_AGENT", "auggie_cli"), + ("OPENCODE", "opencode_client"), ("OPENCODE_CLIENT", "opencode_client"), ("TRAE_AI_SHELL_ID", "trae_ai"), ("GOOSE_TERMINAL", "goose"), diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index be74945a5d..af8181c1b2 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -425,6 +425,7 @@ async def test_metadata_bad_driver_options(self): with self.assertRaises(TypeError): self.simple_client(driver=("Foo", "1", "a")) + @no_ambient_agent() async def test_metadata_appends_driver_info(self): metadata = _metadata_with_appended_driver(_IS_SYNC, "FooDriver", "1.2.3") client = self.simple_client( @@ -2481,6 +2482,7 @@ async def test_handshake_10_agent_known(self): # 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": "1"}, {"agent": "opencode_client"}) await self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "opencode_client"}) async def test_handshake_10b_agent_known_precedence(self): diff --git a/test/test_client.py b/test/test_client.py index cba28910c3..cb3c349a59 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -418,6 +418,7 @@ def test_metadata_bad_driver_options(self): with self.assertRaises(TypeError): self.simple_client(driver=("Foo", "1", "a")) + @no_ambient_agent() def test_metadata_appends_driver_info(self): metadata = _metadata_with_appended_driver(_IS_SYNC, "FooDriver", "1.2.3") client = self.simple_client( @@ -2434,6 +2435,7 @@ def test_handshake_10_agent_known(self): # 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": "1"}, {"agent": "opencode_client"}) self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "opencode_client"}) def test_handshake_10b_agent_known_precedence(self): From 882c8111955dcbf4dd1a55644de1f578c8ffd1c5 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 09:55:41 -0400 Subject: [PATCH 14/28] PYTHON-5929 Rename no_ambient_agent test helper to clear_agent_env --- test/asynchronous/test_client.py | 14 +++++++------- test/test_client.py | 14 +++++++------- test/utils_shared.py | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index af8181c1b2..048d0b0630 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -133,11 +133,11 @@ FunctionCallRecorder, _driver_version, _metadata_with_appended_driver, + clear_agent_env, delay, gevent_monkey_patched, is_greenthread_patched, lazy_client_trial, - no_ambient_agent, one, suppress_fork_deprecation, ) @@ -391,7 +391,7 @@ async def test_read_preference(self): ) self.assertEqual(c.read_preference, ReadPreference.NEAREST) - @no_ambient_agent() + @clear_agent_env() async def test_metadata(self): metadata = copy.deepcopy(_METADATA) if has_c(): @@ -425,7 +425,7 @@ async def test_metadata_bad_driver_options(self): with self.assertRaises(TypeError): self.simple_client(driver=("Foo", "1", "a")) - @no_ambient_agent() + @clear_agent_env() async def test_metadata_appends_driver_info(self): metadata = _metadata_with_appended_driver(_IS_SYNC, "FooDriver", "1.2.3") client = self.simple_client( @@ -607,7 +607,7 @@ async def test_metadata_deduplicates_none_and_empty(self): self.assertEqual(metadata["driver"]["name"], names) self.assertEqual(metadata["driver"]["version"], vers) - @no_ambient_agent() + @clear_agent_env() @mock.patch.dict("os.environ", {ENV_VAR_K8S: "1"}) def test_container_metadata(self): metadata = copy.deepcopy(_METADATA) @@ -2368,9 +2368,9 @@ def test_sigstop_sigcont(self): self.assertNotIn("ServerHeartbeatFailedEvent", log_output) 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. - with no_ambient_agent(keep=env_vars), patch.dict("os.environ", env_vars): + # Clear agent env vars already set in the test process (e.g. AI_AGENT + # set by the CI runner) so detection only reflects env_vars. + with clear_agent_env(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 cb3c349a59..faadd09202 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -132,11 +132,11 @@ FunctionCallRecorder, _driver_version, _metadata_with_appended_driver, + clear_agent_env, delay, gevent_monkey_patched, is_greenthread_patched, lazy_client_trial, - no_ambient_agent, one, suppress_fork_deprecation, ) @@ -384,7 +384,7 @@ def test_read_preference(self): ) self.assertEqual(c.read_preference, ReadPreference.NEAREST) - @no_ambient_agent() + @clear_agent_env() def test_metadata(self): metadata = copy.deepcopy(_METADATA) if has_c(): @@ -418,7 +418,7 @@ def test_metadata_bad_driver_options(self): with self.assertRaises(TypeError): self.simple_client(driver=("Foo", "1", "a")) - @no_ambient_agent() + @clear_agent_env() def test_metadata_appends_driver_info(self): metadata = _metadata_with_appended_driver(_IS_SYNC, "FooDriver", "1.2.3") client = self.simple_client( @@ -600,7 +600,7 @@ def test_metadata_deduplicates_none_and_empty(self): self.assertEqual(metadata["driver"]["name"], names) self.assertEqual(metadata["driver"]["version"], vers) - @no_ambient_agent() + @clear_agent_env() @mock.patch.dict("os.environ", {ENV_VAR_K8S: "1"}) def test_container_metadata(self): metadata = copy.deepcopy(_METADATA) @@ -2321,9 +2321,9 @@ def test_sigstop_sigcont(self): self.assertNotIn("ServerHeartbeatFailedEvent", log_output) 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. - with no_ambient_agent(keep=env_vars), patch.dict("os.environ", env_vars): + # Clear agent env vars already set in the test process (e.g. AI_AGENT + # set by the CI runner) so detection only reflects env_vars. + with clear_agent_env(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 2b4284f9c5..b1ae3ffbfa 100644 --- a/test/utils_shared.py +++ b/test/utils_shared.py @@ -617,7 +617,7 @@ def suppress_fork_deprecation(): yield -def no_ambient_agent(keep=()): +def clear_agent_env(keep=()): """Clear the coding agent env vars the handshake reads. The test process itself often runs under a coding agent, which would add From 31075f96f74e1e05b50292df3e25d5f2f6e2ded7 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 10:00:54 -0400 Subject: [PATCH 15/28] PYTHON-5929 Rename agent test helper to unset_agent_env --- test/asynchronous/test_client.py | 10 +++++----- test/test_client.py | 10 +++++----- test/utils_shared.py | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index 048d0b0630..fd760089d9 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -133,13 +133,13 @@ FunctionCallRecorder, _driver_version, _metadata_with_appended_driver, - clear_agent_env, delay, gevent_monkey_patched, is_greenthread_patched, lazy_client_trial, one, suppress_fork_deprecation, + unset_agent_env, ) _IS_SYNC = False @@ -391,7 +391,7 @@ async def test_read_preference(self): ) self.assertEqual(c.read_preference, ReadPreference.NEAREST) - @clear_agent_env() + @unset_agent_env() async def test_metadata(self): metadata = copy.deepcopy(_METADATA) if has_c(): @@ -425,7 +425,7 @@ async def test_metadata_bad_driver_options(self): with self.assertRaises(TypeError): self.simple_client(driver=("Foo", "1", "a")) - @clear_agent_env() + @unset_agent_env() async def test_metadata_appends_driver_info(self): metadata = _metadata_with_appended_driver(_IS_SYNC, "FooDriver", "1.2.3") client = self.simple_client( @@ -607,7 +607,7 @@ async def test_metadata_deduplicates_none_and_empty(self): self.assertEqual(metadata["driver"]["name"], names) self.assertEqual(metadata["driver"]["version"], vers) - @clear_agent_env() + @unset_agent_env() @mock.patch.dict("os.environ", {ENV_VAR_K8S: "1"}) def test_container_metadata(self): metadata = copy.deepcopy(_METADATA) @@ -2370,7 +2370,7 @@ def test_sigstop_sigcont(self): async def _test_handshake(self, env_vars, expected_env): # Clear agent env vars already set in the test process (e.g. AI_AGENT # set by the CI runner) so detection only reflects env_vars. - with clear_agent_env(keep=env_vars), patch.dict("os.environ", env_vars): + with unset_agent_env(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 faadd09202..1b5e96f377 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -132,13 +132,13 @@ FunctionCallRecorder, _driver_version, _metadata_with_appended_driver, - clear_agent_env, delay, gevent_monkey_patched, is_greenthread_patched, lazy_client_trial, one, suppress_fork_deprecation, + unset_agent_env, ) _IS_SYNC = True @@ -384,7 +384,7 @@ def test_read_preference(self): ) self.assertEqual(c.read_preference, ReadPreference.NEAREST) - @clear_agent_env() + @unset_agent_env() def test_metadata(self): metadata = copy.deepcopy(_METADATA) if has_c(): @@ -418,7 +418,7 @@ def test_metadata_bad_driver_options(self): with self.assertRaises(TypeError): self.simple_client(driver=("Foo", "1", "a")) - @clear_agent_env() + @unset_agent_env() def test_metadata_appends_driver_info(self): metadata = _metadata_with_appended_driver(_IS_SYNC, "FooDriver", "1.2.3") client = self.simple_client( @@ -600,7 +600,7 @@ def test_metadata_deduplicates_none_and_empty(self): self.assertEqual(metadata["driver"]["name"], names) self.assertEqual(metadata["driver"]["version"], vers) - @clear_agent_env() + @unset_agent_env() @mock.patch.dict("os.environ", {ENV_VAR_K8S: "1"}) def test_container_metadata(self): metadata = copy.deepcopy(_METADATA) @@ -2323,7 +2323,7 @@ def test_sigstop_sigcont(self): def _test_handshake(self, env_vars, expected_env): # Clear agent env vars already set in the test process (e.g. AI_AGENT # set by the CI runner) so detection only reflects env_vars. - with clear_agent_env(keep=env_vars), patch.dict("os.environ", env_vars): + with unset_agent_env(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 b1ae3ffbfa..74913f1c36 100644 --- a/test/utils_shared.py +++ b/test/utils_shared.py @@ -617,8 +617,8 @@ def suppress_fork_deprecation(): yield -def clear_agent_env(keep=()): - """Clear the coding agent env vars the handshake reads. +def unset_agent_env(keep=()): + """Unset 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 From 5644a860e9eae0857466c3a21733e6ef9090b2d8 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 10:02:42 -0400 Subject: [PATCH 16/28] PYTHON-5929 Trim non-critical comments --- pymongo/pool_options.py | 10 ++++------ test/asynchronous/test_client.py | 24 +++++------------------- test/test_client.py | 24 +++++------------------- 3 files changed, 14 insertions(+), 44 deletions(-) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index 0ec9019524..3498524dac 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -142,9 +142,8 @@ def _is_faas() -> bool: return _is_lambda() or _is_azure_func() or _is_gcp_func() or _is_vercel() -# 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. +# Ordered per the handshake spec (DRIVERS-3529): the first populated variable +# gives client.env.agent, whatever its value. _AGENT_ENV_VARS = [ ("CLAUDECODE", "claude_code"), ("CLAUDE_CODE_ENTRYPOINT", "claude_code"), @@ -161,16 +160,15 @@ def _is_faas() -> bool: ] # Generic agent variable, checked last so a known agent keeps its fixed name. +# Evaluated 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. _MAX_AGENT_SIZE = 64 def _metadata_agent() -> Optional[str]: """Detect a coding agent from the environment for client.env.agent.""" for var, name in _AGENT_ENV_VARS: - # 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() @@ -280,7 +278,7 @@ 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. If env has no remaining fields, omit env entirely. + # for longer. 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"} diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index fd760089d9..702096307f 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -518,19 +518,15 @@ def metadata_for(name_len: int) -> dict[str, Any]: ) 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. + # The document is one byte over the limit, less than the agent element + # costs, so dropping agent alone must make it fit without touching + # platform. plat_len = next( n for n in range(1, 2 * _MAX_METADATA_SIZE) @@ -2479,54 +2475,44 @@ async def test_handshake_09_container_with_provider(self): ) async def test_handshake_10_agent_known(self): - # 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": "1"}, {"agent": "opencode_client"}) await self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "opencode_client"}) async def test_handshake_10b_agent_known_precedence(self): - # 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 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 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"}) async def test_handshake_13_agent_generic_normalized(self): - # AI_AGENT is trimmed and lowercased. await self._test_handshake( {"AI_AGENT": " Claude-Code_2-1-238_Agent "}, {"agent": "claude-code_2-1-238_agent"} ) async def test_handshake_14_agent_generic_truncated(self): - # 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 character is - # dropped. No part of it, and no U+FFFD, may appear. + # The byte limit falls inside the two-byte "é": 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 counts 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", "CLAUDECODE": "1"}, {"name": "azure.func", "agent": "claude_code"}, diff --git a/test/test_client.py b/test/test_client.py index 1b5e96f377..944b7cbae7 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -511,19 +511,15 @@ def metadata_for(name_len: int) -> dict[str, Any]: ) 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. + # The document is one byte over the limit, less than the agent element + # costs, so dropping agent alone must make it fit without touching + # platform. plat_len = next( n for n in range(1, 2 * _MAX_METADATA_SIZE) @@ -2432,54 +2428,44 @@ def test_handshake_09_container_with_provider(self): ) def test_handshake_10_agent_known(self): - # 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": "1"}, {"agent": "opencode_client"}) self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "opencode_client"}) def test_handshake_10b_agent_known_precedence(self): - # 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 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 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"}) def test_handshake_13_agent_generic_normalized(self): - # AI_AGENT is trimmed and lowercased. self._test_handshake( {"AI_AGENT": " Claude-Code_2-1-238_Agent "}, {"agent": "claude-code_2-1-238_agent"} ) def test_handshake_14_agent_generic_truncated(self): - # 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 character is - # dropped. No part of it, and no U+FFFD, may appear. + # The byte limit falls inside the two-byte "é": 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 counts 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", "CLAUDECODE": "1"}, {"name": "azure.func", "agent": "claude_code"}, From e35554924463e527ff745ec8b02d38a0eb8558f0 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 10:04:31 -0400 Subject: [PATCH 17/28] PYTHON-5929 Remove duplicate comment --- pymongo/pool_options.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index 3498524dac..c23728c746 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -159,7 +159,6 @@ def _is_faas() -> bool: ("GOOSE_AGENT", "goose"), ] -# Generic agent variable, checked last so a known agent keeps its fixed name. # Evaluated last, so a known agent keeps its fixed name. _GENERIC_AGENT_ENV_VAR = "AI_AGENT" From e938ff119ca18d6f58d5e5dde166c0318c590d8e Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 10:07:11 -0400 Subject: [PATCH 18/28] PYTHON-5929 Shorten UTF-8 truncation comment --- pymongo/pool_options.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index c23728c746..25a8ac139e 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -175,8 +175,8 @@ def _metadata_agent() -> Optional[str]: return None if agent in ("1", "true"): return "ai_agent" - # Largest valid UTF-8 prefix of _MAX_AGENT_SIZE bytes. "ignore" drops a - # split character instead of replacing it with U+FFFD. + # Largest valid UTF-8 prefix of _MAX_AGENT_SIZE bytes; "ignore" drops a + # character split by the byte limit. return agent.encode()[:_MAX_AGENT_SIZE].decode(errors="ignore") From 28e6bd8cca05ece4658ea51dfbdff07bcc2b3897 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 10:09:09 -0400 Subject: [PATCH 19/28] PYTHON-5929 Use spec wording for env.agent truncation comment --- pymongo/pool_options.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index 25a8ac139e..c901ea5a61 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -276,8 +276,8 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: metadata["env"] = trimmed_env if size <= _MAX_METADATA_SIZE: return - # 2. Omit env.agent. It goes before env.name, which drivers have reported - # for longer. + # 2. Omit env.agent. env.agent is omitted before env.name because drivers + # have reported env.name since before env.agent existed. 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"} From aa1f8545976407c9b6d3df8cc2e2f4c676cf6452 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 10:11:42 -0400 Subject: [PATCH 20/28] PYTHON-5929 Reword comment to match unset_agent_env --- test/asynchronous/test_client.py | 2 +- test/test_client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index 702096307f..61a0bbb14e 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -2364,7 +2364,7 @@ def test_sigstop_sigcont(self): self.assertNotIn("ServerHeartbeatFailedEvent", log_output) async def _test_handshake(self, env_vars, expected_env): - # Clear agent env vars already set in the test process (e.g. AI_AGENT + # Unset agent env vars already set in the test process (e.g. AI_AGENT # set by the CI runner) so detection only reflects env_vars. with unset_agent_env(keep=env_vars), patch.dict("os.environ", env_vars): metadata = copy.deepcopy(_METADATA) diff --git a/test/test_client.py b/test/test_client.py index 944b7cbae7..769a19691f 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -2317,7 +2317,7 @@ def test_sigstop_sigcont(self): self.assertNotIn("ServerHeartbeatFailedEvent", log_output) def _test_handshake(self, env_vars, expected_env): - # Clear agent env vars already set in the test process (e.g. AI_AGENT + # Unset agent env vars already set in the test process (e.g. AI_AGENT # set by the CI runner) so detection only reflects env_vars. with unset_agent_env(keep=env_vars), patch.dict("os.environ", env_vars): metadata = copy.deepcopy(_METADATA) From 3f8aacda2dddada752ab66dc1f90fb2ffdb7ec38 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 10:18:09 -0400 Subject: [PATCH 21/28] PYTHON-5929 Reword unset_agent_env docstring --- test/utils_shared.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utils_shared.py b/test/utils_shared.py index 74913f1c36..2af048a724 100644 --- a/test/utils_shared.py +++ b/test/utils_shared.py @@ -620,7 +620,7 @@ def suppress_fork_deprecation(): def unset_agent_env(keep=()): """Unset the coding agent env vars the handshake reads. - The test process itself often runs under a coding agent, which would add + A coding agent often runs the test process itself, 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). From 86e1cfe2f19b06a7ecb8fb23f67b85ca5df8e0f4 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 10:22:11 -0400 Subject: [PATCH 22/28] PYTHON-5929 Renumber agent handshake tests without letter suffixes --- test/asynchronous/test_client.py | 16 ++++++++-------- test/test_client.py | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index 61a0bbb14e..6d53d99fac 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -2480,39 +2480,39 @@ async def test_handshake_10_agent_known(self): await self._test_handshake({"OPENCODE": "1"}, {"agent": "opencode_client"}) await self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "opencode_client"}) - async def test_handshake_10b_agent_known_precedence(self): + async def test_handshake_11_agent_known_precedence(self): await self._test_handshake({"GEMINI_CLI": "1", "CURSOR_AGENT": "1"}, {"agent": "cursor"}) - async def test_handshake_11_agent_known_beats_generic(self): + async def test_handshake_12_agent_known_beats_generic(self): await self._test_handshake( {"AI_AGENT": "custom-agent", "CLAUDECODE": "1"}, {"agent": "claude_code"} ) - async def test_handshake_12_agent_generic(self): + async def test_handshake_13_agent_generic(self): 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): + async def test_handshake_14_agent_generic_normalized(self): await self._test_handshake( {"AI_AGENT": " Claude-Code_2-1-238_Agent "}, {"agent": "claude-code_2-1-238_agent"} ) - async def test_handshake_14_agent_generic_truncated(self): + async def test_handshake_15_agent_generic_truncated(self): await self._test_handshake({"AI_AGENT": "a" * 100}, {"agent": "a" * _MAX_AGENT_SIZE}) - async def test_handshake_14b_agent_generic_truncated_on_boundary(self): + async def test_handshake_16_agent_generic_truncated_on_boundary(self): # The byte limit falls inside the two-byte "é": 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): + async def test_handshake_17_agent_unset(self): 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): + async def test_handshake_18_agent_with_provider(self): await self._test_handshake( {"FUNCTIONS_WORKER_RUNTIME": "python", "CLAUDECODE": "1"}, {"name": "azure.func", "agent": "claude_code"}, diff --git a/test/test_client.py b/test/test_client.py index 769a19691f..5480032d1f 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -2433,39 +2433,39 @@ def test_handshake_10_agent_known(self): self._test_handshake({"OPENCODE": "1"}, {"agent": "opencode_client"}) self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "opencode_client"}) - def test_handshake_10b_agent_known_precedence(self): + def test_handshake_11_agent_known_precedence(self): self._test_handshake({"GEMINI_CLI": "1", "CURSOR_AGENT": "1"}, {"agent": "cursor"}) - def test_handshake_11_agent_known_beats_generic(self): + def test_handshake_12_agent_known_beats_generic(self): self._test_handshake( {"AI_AGENT": "custom-agent", "CLAUDECODE": "1"}, {"agent": "claude_code"} ) - def test_handshake_12_agent_generic(self): + def test_handshake_13_agent_generic(self): 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): + def test_handshake_14_agent_generic_normalized(self): self._test_handshake( {"AI_AGENT": " Claude-Code_2-1-238_Agent "}, {"agent": "claude-code_2-1-238_agent"} ) - def test_handshake_14_agent_generic_truncated(self): + def test_handshake_15_agent_generic_truncated(self): self._test_handshake({"AI_AGENT": "a" * 100}, {"agent": "a" * _MAX_AGENT_SIZE}) - def test_handshake_14b_agent_generic_truncated_on_boundary(self): + def test_handshake_16_agent_generic_truncated_on_boundary(self): # The byte limit falls inside the two-byte "é": 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): + def test_handshake_17_agent_unset(self): 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): + def test_handshake_18_agent_with_provider(self): self._test_handshake( {"FUNCTIONS_WORKER_RUNTIME": "python", "CLAUDECODE": "1"}, {"name": "azure.func", "agent": "claude_code"}, From 72e33d58342079d4515f80a63d168dc5ee776fc0 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 10:26:20 -0400 Subject: [PATCH 23/28] PYTHON-5929 Remove non-critical commas from comments --- pymongo/pool_options.py | 6 +++--- test/asynchronous/test_client.py | 8 ++++---- test/test_client.py | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index c901ea5a61..57779813fc 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -159,7 +159,7 @@ def _is_faas() -> bool: ("GOOSE_AGENT", "goose"), ] -# Evaluated last, so a known agent keeps its fixed name. +# Evaluated last so a known agent keeps its fixed name. _GENERIC_AGENT_ENV_VAR = "AI_AGENT" _MAX_AGENT_SIZE = 64 @@ -321,7 +321,7 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: plat = metadata.pop("platform", None) if plat is not None: size -= _element_size("platform", plat) - # 6. Truncate driver info, keeping name and version 1:1 index-aligned. + # 6. Truncate driver info keeping name and version 1:1 index-aligned. driver = metadata.get("driver", {}) if driver: # Keep the name and version segments paired so they stay 1:1 aligned, @@ -342,7 +342,7 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: overflow = size - _MAX_METADATA_SIZE while overflow > 0 and len(pairs) > 1: # A single remaining pair never exceeds the limit: it is the base - # driver pair, which steps 1-5 left small enough to fit. + # driver pair which steps 1-5 left small enough to fit. last_name, last_version = pairs[-1] if last_version: new_version = _truncate_utf8(last_version, overflow) diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index 6d53d99fac..986f8706ec 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -524,8 +524,8 @@ def metadata_for(plat_len: int, with_name: bool) -> dict[str, Any]: env["name"] = "azure.func" return {"env": env, "platform": "p" * plat_len} - # The document is one byte over the limit, less than the agent element - # costs, so dropping agent alone must make it fit without touching + # The document is one byte over the limit and less than the agent + # element costs. Dropping agent alone must make it fit without touching # platform. plat_len = next( n @@ -2502,8 +2502,8 @@ async def test_handshake_15_agent_generic_truncated(self): await self._test_handshake({"AI_AGENT": "a" * 100}, {"agent": "a" * _MAX_AGENT_SIZE}) async def test_handshake_16_agent_generic_truncated_on_boundary(self): - # The byte limit falls inside the two-byte "é": no part of it, and no - # U+FFFD, may appear. + # The byte limit falls inside the two-byte "é". 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)}) diff --git a/test/test_client.py b/test/test_client.py index 5480032d1f..9c97775231 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -517,8 +517,8 @@ def metadata_for(plat_len: int, with_name: bool) -> dict[str, Any]: env["name"] = "azure.func" return {"env": env, "platform": "p" * plat_len} - # The document is one byte over the limit, less than the agent element - # costs, so dropping agent alone must make it fit without touching + # The document is one byte over the limit and less than the agent + # element costs. Dropping agent alone must make it fit without touching # platform. plat_len = next( n @@ -2455,8 +2455,8 @@ def test_handshake_15_agent_generic_truncated(self): self._test_handshake({"AI_AGENT": "a" * 100}, {"agent": "a" * _MAX_AGENT_SIZE}) def test_handshake_16_agent_generic_truncated_on_boundary(self): - # The byte limit falls inside the two-byte "é": no part of it, and no - # U+FFFD, may appear. + # The byte limit falls inside the two-byte "é". 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)}) From 9f3fbbf4db63db7f4e3951c8edcb8f1b7521451f Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 10:26:43 -0400 Subject: [PATCH 24/28] PYTHON-5929 Replace semicolon in truncation 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 57779813fc..ca611c1dc0 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -175,7 +175,7 @@ def _metadata_agent() -> Optional[str]: return None if agent in ("1", "true"): return "ai_agent" - # Largest valid UTF-8 prefix of _MAX_AGENT_SIZE bytes; "ignore" drops a + # Largest valid UTF-8 prefix of _MAX_AGENT_SIZE bytes. "ignore" drops a # character split by the byte limit. return agent.encode()[:_MAX_AGENT_SIZE].decode(errors="ignore") From df68e32dbf926db8f39f0b74ec687496a60a8aa1 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 11:18:45 -0400 Subject: [PATCH 25/28] PYTHON-5929 Address review comments --- doc/changelog.rst | 10 ++--- pymongo/pool_options.py | 9 ++-- test/asynchronous/test_client.py | 73 +++++++++++++++++++++----------- test/test_client.py | 73 +++++++++++++++++++++----------- test/utils_shared.py | 8 ++-- 5 files changed, 110 insertions(+), 63 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index ca0607c68d..dd1012bad1 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -4,6 +4,9 @@ Changelog Changes in Version 4.20.0 (2026/XX/XX) -------------------------------------- +- The client handshake metadata now reports a detected coding agent in + ``client.env.agent``. + Bug fixes ......... @@ -76,13 +79,6 @@ PyMongo 4.19 brings a number of changes including: security considerations. - ``srvAllowedHostsSuffix`` may now be set to a single label reserved for private or special use, such as ``localhost``, ``test``, or ``internal``. -- 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. Bug fixes ......... diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index ca611c1dc0..f191f38c44 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -176,8 +176,9 @@ def _metadata_agent() -> Optional[str]: if agent in ("1", "true"): return "ai_agent" # Largest valid UTF-8 prefix of _MAX_AGENT_SIZE bytes. "ignore" drops a - # character split by the byte limit. - return agent.encode()[:_MAX_AGENT_SIZE].decode(errors="ignore") + # character split by the byte limit and surrogate-escaped bytes from a + # malformed value so detection never raises. + return agent.encode(errors="ignore")[:_MAX_AGENT_SIZE].decode(errors="ignore") def _getenv_int(key: str) -> Optional[int]: @@ -280,11 +281,13 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: # have reported env.name since before env.agent existed. env = metadata.get("env") if env is not None and "agent" in env: + old_size = _element_size("env", 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: + size += _element_size("env", new_env) - old_size metadata["env"] = new_env else: + size -= old_size del metadata["env"] if size <= _MAX_METADATA_SIZE: return diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index 986f8706ec..7e132706fd 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -96,6 +96,7 @@ _METADATA, ENV_VAR_K8S, PoolOptions, + _element_size, _truncate_metadata, ) from pymongo.read_preferences import ReadPreference @@ -517,37 +518,55 @@ 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): - 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} - - # The document is one byte over the limit and less than the agent - # element costs. Dropping agent alone must make it fit without touching - # platform. + def _metadata_with_agent_env(self, with_name: bool) -> dict[str, Any]: + """Metadata over the limit with env holding agent (and name). + + Dropping env.agent alone must bring the document back under the + limit. With agent-only env the excess is the env element less one byte + so removing env alone must bring the document one byte under. + """ + env: dict[str, Any] = {"agent": "claude_code"} + if with_name: + env["name"] = "azure.func" + os_meta = {"type": "Darwin", "name": "Darwin", "architecture": "arm64", "version": "26.7.1"} + + def metadata_for(plat_len: int) -> dict[str, Any]: + return {"env": env, "os": os_meta, "platform": "p" * plat_len} + + target = ( + _MAX_METADATA_SIZE + 1 + if with_name + else _MAX_METADATA_SIZE - 1 + _element_size("env", env) + ) 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 + if len(bson.encode(metadata_for(n))) == target ) - metadata = metadata_for(plat_len, True) + return metadata_for(plat_len) + + async def test_metadata_truncation_omits_agent_before_name(self): + metadata = self._metadata_with_agent_env(with_name=True) + os_meta = metadata["os"] + plat = metadata["platform"] _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) + self.assertEqual(metadata["os"], os_meta) + self.assertEqual(metadata["platform"], plat) + + async def test_metadata_truncation_omits_agent_only_env(self): + # env holds only agent so the whole env document is omitted. The size + # accounting must record the full env element so no later field is + # touched. + metadata = self._metadata_with_agent_env(with_name=False) + os_meta = metadata["os"] + plat = metadata["platform"] _truncate_metadata(metadata) self.assertLessEqual(len(bson.encode(metadata)), _MAX_METADATA_SIZE) self.assertNotIn("env", metadata) - self.assertEqual(metadata["platform"], "p" * plat_len) + self.assertEqual(metadata["os"], os_meta) + self.assertEqual(metadata["platform"], plat) async def test_metadata_append_is_bounded(self): # Successive appends must stay within the limit and keep name and @@ -2366,7 +2385,7 @@ def test_sigstop_sigcont(self): async def _test_handshake(self, env_vars, expected_env): # Unset agent env vars already set in the test process (e.g. AI_AGENT # set by the CI runner) so detection only reflects env_vars. - with unset_agent_env(keep=env_vars), patch.dict("os.environ", env_vars): + with unset_agent_env(), patch.dict("os.environ", env_vars): metadata = copy.deepcopy(_METADATA) if has_c(): metadata["driver"]["name"] = "PyMongo|c|async" @@ -2492,6 +2511,8 @@ async def test_handshake_13_agent_generic(self): 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"}) + await self._test_handshake({"AI_AGENT": " true "}, {"agent": "ai_agent"}) + await self._test_handshake({"AI_AGENT": "custom-\udcff"}, {"agent": "custom-"}) async def test_handshake_14_agent_generic_normalized(self): await self._test_handshake( @@ -2514,8 +2535,12 @@ async def test_handshake_17_agent_unset(self): async def test_handshake_18_agent_with_provider(self): await self._test_handshake( - {"FUNCTIONS_WORKER_RUNTIME": "python", "CLAUDECODE": "1"}, - {"name": "azure.func", "agent": "claude_code"}, + { + "AWS_EXECUTION_ENV": "AWS_Lambda_java8", + "AWS_REGION": "us-east-2", + "CLAUDECODE": "1", + }, + {"name": "aws.lambda", "region": "us-east-2", "agent": "claude_code"}, ) def test_dict_hints(self): diff --git a/test/test_client.py b/test/test_client.py index 9c97775231..c9e0211e87 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -87,6 +87,7 @@ _METADATA, ENV_VAR_K8S, PoolOptions, + _element_size, _truncate_metadata, ) from pymongo.read_preferences import ReadPreference @@ -510,37 +511,55 @@ 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): - 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} - - # The document is one byte over the limit and less than the agent - # element costs. Dropping agent alone must make it fit without touching - # platform. + def _metadata_with_agent_env(self, with_name: bool) -> dict[str, Any]: + """Metadata over the limit with env holding agent (and name). + + Dropping env.agent alone must bring the document back under the + limit. With agent-only env the excess is the env element less one byte + so removing env alone must bring the document one byte under. + """ + env: dict[str, Any] = {"agent": "claude_code"} + if with_name: + env["name"] = "azure.func" + os_meta = {"type": "Darwin", "name": "Darwin", "architecture": "arm64", "version": "26.7.1"} + + def metadata_for(plat_len: int) -> dict[str, Any]: + return {"env": env, "os": os_meta, "platform": "p" * plat_len} + + target = ( + _MAX_METADATA_SIZE + 1 + if with_name + else _MAX_METADATA_SIZE - 1 + _element_size("env", env) + ) 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 + if len(bson.encode(metadata_for(n))) == target ) - metadata = metadata_for(plat_len, True) + return metadata_for(plat_len) + + def test_metadata_truncation_omits_agent_before_name(self): + metadata = self._metadata_with_agent_env(with_name=True) + os_meta = metadata["os"] + plat = metadata["platform"] _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) + self.assertEqual(metadata["os"], os_meta) + self.assertEqual(metadata["platform"], plat) + + def test_metadata_truncation_omits_agent_only_env(self): + # env holds only agent so the whole env document is omitted. The size + # accounting must record the full env element so no later field is + # touched. + metadata = self._metadata_with_agent_env(with_name=False) + os_meta = metadata["os"] + plat = metadata["platform"] _truncate_metadata(metadata) self.assertLessEqual(len(bson.encode(metadata)), _MAX_METADATA_SIZE) self.assertNotIn("env", metadata) - self.assertEqual(metadata["platform"], "p" * plat_len) + self.assertEqual(metadata["os"], os_meta) + self.assertEqual(metadata["platform"], plat) def test_metadata_append_is_bounded(self): # Successive appends must stay within the limit and keep name and @@ -2319,7 +2338,7 @@ def test_sigstop_sigcont(self): def _test_handshake(self, env_vars, expected_env): # Unset agent env vars already set in the test process (e.g. AI_AGENT # set by the CI runner) so detection only reflects env_vars. - with unset_agent_env(keep=env_vars), patch.dict("os.environ", env_vars): + with unset_agent_env(), patch.dict("os.environ", env_vars): metadata = copy.deepcopy(_METADATA) if has_c(): metadata["driver"]["name"] = "PyMongo|c" @@ -2445,6 +2464,8 @@ def test_handshake_13_agent_generic(self): 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"}) + self._test_handshake({"AI_AGENT": " true "}, {"agent": "ai_agent"}) + self._test_handshake({"AI_AGENT": "custom-\udcff"}, {"agent": "custom-"}) def test_handshake_14_agent_generic_normalized(self): self._test_handshake( @@ -2467,8 +2488,12 @@ def test_handshake_17_agent_unset(self): def test_handshake_18_agent_with_provider(self): self._test_handshake( - {"FUNCTIONS_WORKER_RUNTIME": "python", "CLAUDECODE": "1"}, - {"name": "azure.func", "agent": "claude_code"}, + { + "AWS_EXECUTION_ENV": "AWS_Lambda_java8", + "AWS_REGION": "us-east-2", + "CLAUDECODE": "1", + }, + {"name": "aws.lambda", "region": "us-east-2", "agent": "claude_code"}, ) def test_dict_hints(self): diff --git a/test/utils_shared.py b/test/utils_shared.py index 2af048a724..c739cf4367 100644 --- a/test/utils_shared.py +++ b/test/utils_shared.py @@ -617,18 +617,16 @@ def suppress_fork_deprecation(): yield -def unset_agent_env(keep=()): +def unset_agent_env(): """Unset the coding agent env vars the handshake reads. A coding agent often runs the test process itself, 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). + client.env.agent to the handshake metadata (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}) + return patch.dict("os.environ", dict.fromkeys(agent_vars, "")) def parse_read_preference(pref): From 52ad273c84b6bc4bc9db22364d17ecc88ba116b6 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 13:58:50 -0400 Subject: [PATCH 26/28] PYTHON-5929 Address review comments --- doc/changelog.rst | 3 +- pymongo/pool_options.py | 6 ++-- test/asynchronous/test_client.py | 48 +++++++++++++++++++------------- test/test_client.py | 48 +++++++++++++++++++------------- test/utils_shared.py | 4 +-- 5 files changed, 63 insertions(+), 46 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index dd1012bad1..585e679759 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -5,7 +5,7 @@ Changes in Version 4.20.0 (2026/XX/XX) -------------------------------------- - The client handshake metadata now reports a detected coding agent in - ``client.env.agent``. + ``client.env.agent`` (`PYTHON-5929`_). Bug fixes ......... @@ -26,6 +26,7 @@ Bug fixes :meth:`~bson.objectid.ObjectId.is_valid` returns ``False`` for such a string (`PYTHON-5873`_). +.. _PYTHON-5929: https://jira.mongodb.org/browse/PYTHON-5929 .. _PYTHON-6172: https://jira.mongodb.org/browse/PYTHON-6172 .. _PYTHON-6149: https://jira.mongodb.org/browse/PYTHON-6149 .. _PYTHON-5873: https://jira.mongodb.org/browse/PYTHON-5873 diff --git a/pymongo/pool_options.py b/pymongo/pool_options.py index f191f38c44..45a9adf5da 100644 --- a/pymongo/pool_options.py +++ b/pymongo/pool_options.py @@ -142,7 +142,7 @@ def _is_faas() -> bool: return _is_lambda() or _is_azure_func() or _is_gcp_func() or _is_vercel() -# Ordered per the handshake spec (DRIVERS-3529): the first populated variable +# Ordered per the handshake spec: the first populated variable # gives client.env.agent, whatever its value. _AGENT_ENV_VARS = [ ("CLAUDECODE", "claude_code"), @@ -324,7 +324,7 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: plat = metadata.pop("platform", None) if plat is not None: size -= _element_size("platform", plat) - # 6. Truncate driver info keeping name and version 1:1 index-aligned. + # 6. Truncate driver info, keeping name and version 1:1 index-aligned. driver = metadata.get("driver", {}) if driver: # Keep the name and version segments paired so they stay 1:1 aligned, @@ -345,7 +345,7 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None: overflow = size - _MAX_METADATA_SIZE while overflow > 0 and len(pairs) > 1: # A single remaining pair never exceeds the limit: it is the base - # driver pair which steps 1-5 left small enough to fit. + # driver pair, which steps 1-5 left small enough to fit. last_name, last_version = pairs[-1] if last_version: new_version = _truncate_utf8(last_version, overflow) diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index 7e132706fd..902b4fb69a 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -519,12 +519,7 @@ def metadata_for(name_len: int) -> dict[str, Any]: ) def _metadata_with_agent_env(self, with_name: bool) -> dict[str, Any]: - """Metadata over the limit with env holding agent (and name). - - Dropping env.agent alone must bring the document back under the - limit. With agent-only env the excess is the env element less one byte - so removing env alone must bring the document one byte under. - """ + """Create metadata just over the size limit enforced by omitting `env.agent`.""" env: dict[str, Any] = {"agent": "claude_code"} if with_name: env["name"] = "azure.func" @@ -538,11 +533,8 @@ def metadata_for(plat_len: int) -> dict[str, Any]: if with_name else _MAX_METADATA_SIZE - 1 + _element_size("env", env) ) - plat_len = next( - n - for n in range(1, 2 * _MAX_METADATA_SIZE) - if len(bson.encode(metadata_for(n))) == target - ) + # Each platform character adds exactly one byte. + plat_len = target - len(bson.encode(metadata_for(0))) return metadata_for(plat_len) async def test_metadata_truncation_omits_agent_before_name(self): @@ -2495,45 +2487,61 @@ async def test_handshake_09_container_with_provider(self): async def test_handshake_10_agent_known(self): await self._test_handshake({"CLAUDECODE": "1"}, {"agent": "claude_code"}) + + async def test_handshake_11_agent_known_fixed_name(self): await self._test_handshake({"CURSOR_AGENT": "some-value-42"}, {"agent": "cursor"}) + + async def test_handshake_12_agent_known_opencode(self): await self._test_handshake({"OPENCODE": "1"}, {"agent": "opencode_client"}) + + async def test_handshake_13_agent_known_opencode_client(self): await self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "opencode_client"}) - async def test_handshake_11_agent_known_precedence(self): + async def test_handshake_14_agent_known_precedence(self): await self._test_handshake({"GEMINI_CLI": "1", "CURSOR_AGENT": "1"}, {"agent": "cursor"}) - async def test_handshake_12_agent_known_beats_generic(self): + async def test_handshake_15_agent_known_beats_generic(self): await self._test_handshake( {"AI_AGENT": "custom-agent", "CLAUDECODE": "1"}, {"agent": "claude_code"} ) - async def test_handshake_13_agent_generic(self): + async def test_handshake_16_agent_generic_descriptive(self): await self._test_handshake({"AI_AGENT": "custom-agent"}, {"agent": "custom-agent"}) + + async def test_handshake_17_agent_generic_boolean(self): await self._test_handshake({"AI_AGENT": "1"}, {"agent": "ai_agent"}) await self._test_handshake({"AI_AGENT": "true"}, {"agent": "ai_agent"}) + + async def test_handshake_18_agent_generic_boolean_whitespace(self): await self._test_handshake({"AI_AGENT": " true "}, {"agent": "ai_agent"}) - await self._test_handshake({"AI_AGENT": "custom-\udcff"}, {"agent": "custom-"}) - async def test_handshake_14_agent_generic_normalized(self): + async def test_handshake_19_agent_generic_normalized(self): await self._test_handshake( {"AI_AGENT": " Claude-Code_2-1-238_Agent "}, {"agent": "claude-code_2-1-238_agent"} ) - async def test_handshake_15_agent_generic_truncated(self): + async def test_handshake_20_agent_generic_truncated(self): await self._test_handshake({"AI_AGENT": "a" * 100}, {"agent": "a" * _MAX_AGENT_SIZE}) - async def test_handshake_16_agent_generic_truncated_on_boundary(self): + async def test_handshake_21_agent_generic_truncated_on_boundary(self): # The byte limit falls inside the two-byte "é". 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_17_agent_unset(self): + async def test_handshake_22_agent_generic_malformed(self): + await self._test_handshake({"AI_AGENT": "custom-\udcff"}, {"agent": "custom-"}) + + async def test_handshake_23_agent_unset_empty(self): await self._test_handshake({"AI_AGENT": ""}, None) + + async def test_handshake_24_agent_unset_whitespace(self): await self._test_handshake({"AI_AGENT": " "}, None) + + async def test_handshake_25_agent_unset_known_whitespace(self): await self._test_handshake({"CLAUDECODE": " "}, None) - async def test_handshake_18_agent_with_provider(self): + async def test_handshake_26_agent_with_provider(self): await self._test_handshake( { "AWS_EXECUTION_ENV": "AWS_Lambda_java8", diff --git a/test/test_client.py b/test/test_client.py index c9e0211e87..29f4d28a25 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -512,12 +512,7 @@ def metadata_for(name_len: int) -> dict[str, Any]: ) def _metadata_with_agent_env(self, with_name: bool) -> dict[str, Any]: - """Metadata over the limit with env holding agent (and name). - - Dropping env.agent alone must bring the document back under the - limit. With agent-only env the excess is the env element less one byte - so removing env alone must bring the document one byte under. - """ + """Create metadata just over the size limit enforced by omitting `env.agent`.""" env: dict[str, Any] = {"agent": "claude_code"} if with_name: env["name"] = "azure.func" @@ -531,11 +526,8 @@ def metadata_for(plat_len: int) -> dict[str, Any]: if with_name else _MAX_METADATA_SIZE - 1 + _element_size("env", env) ) - plat_len = next( - n - for n in range(1, 2 * _MAX_METADATA_SIZE) - if len(bson.encode(metadata_for(n))) == target - ) + # Each platform character adds exactly one byte. + plat_len = target - len(bson.encode(metadata_for(0))) return metadata_for(plat_len) def test_metadata_truncation_omits_agent_before_name(self): @@ -2448,45 +2440,61 @@ def test_handshake_09_container_with_provider(self): def test_handshake_10_agent_known(self): self._test_handshake({"CLAUDECODE": "1"}, {"agent": "claude_code"}) + + def test_handshake_11_agent_known_fixed_name(self): self._test_handshake({"CURSOR_AGENT": "some-value-42"}, {"agent": "cursor"}) + + def test_handshake_12_agent_known_opencode(self): self._test_handshake({"OPENCODE": "1"}, {"agent": "opencode_client"}) + + def test_handshake_13_agent_known_opencode_client(self): self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "opencode_client"}) - def test_handshake_11_agent_known_precedence(self): + def test_handshake_14_agent_known_precedence(self): self._test_handshake({"GEMINI_CLI": "1", "CURSOR_AGENT": "1"}, {"agent": "cursor"}) - def test_handshake_12_agent_known_beats_generic(self): + def test_handshake_15_agent_known_beats_generic(self): self._test_handshake( {"AI_AGENT": "custom-agent", "CLAUDECODE": "1"}, {"agent": "claude_code"} ) - def test_handshake_13_agent_generic(self): + def test_handshake_16_agent_generic_descriptive(self): self._test_handshake({"AI_AGENT": "custom-agent"}, {"agent": "custom-agent"}) + + def test_handshake_17_agent_generic_boolean(self): self._test_handshake({"AI_AGENT": "1"}, {"agent": "ai_agent"}) self._test_handshake({"AI_AGENT": "true"}, {"agent": "ai_agent"}) + + def test_handshake_18_agent_generic_boolean_whitespace(self): self._test_handshake({"AI_AGENT": " true "}, {"agent": "ai_agent"}) - self._test_handshake({"AI_AGENT": "custom-\udcff"}, {"agent": "custom-"}) - def test_handshake_14_agent_generic_normalized(self): + def test_handshake_19_agent_generic_normalized(self): self._test_handshake( {"AI_AGENT": " Claude-Code_2-1-238_Agent "}, {"agent": "claude-code_2-1-238_agent"} ) - def test_handshake_15_agent_generic_truncated(self): + def test_handshake_20_agent_generic_truncated(self): self._test_handshake({"AI_AGENT": "a" * 100}, {"agent": "a" * _MAX_AGENT_SIZE}) - def test_handshake_16_agent_generic_truncated_on_boundary(self): + def test_handshake_21_agent_generic_truncated_on_boundary(self): # The byte limit falls inside the two-byte "é". 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_17_agent_unset(self): + def test_handshake_22_agent_generic_malformed(self): + self._test_handshake({"AI_AGENT": "custom-\udcff"}, {"agent": "custom-"}) + + def test_handshake_23_agent_unset_empty(self): self._test_handshake({"AI_AGENT": ""}, None) + + def test_handshake_24_agent_unset_whitespace(self): self._test_handshake({"AI_AGENT": " "}, None) + + def test_handshake_25_agent_unset_known_whitespace(self): self._test_handshake({"CLAUDECODE": " "}, None) - def test_handshake_18_agent_with_provider(self): + def test_handshake_26_agent_with_provider(self): self._test_handshake( { "AWS_EXECUTION_ENV": "AWS_Lambda_java8", diff --git a/test/utils_shared.py b/test/utils_shared.py index c739cf4367..aa7cd7045a 100644 --- a/test/utils_shared.py +++ b/test/utils_shared.py @@ -618,10 +618,10 @@ def suppress_fork_deprecation(): def unset_agent_env(): - """Unset the coding agent env vars the handshake reads. + """Blank the coding agent env vars the handshake reads. A coding agent often runs the test process itself, which would add - client.env.agent to the handshake metadata (PYTHON-5929). + client.env.agent to the handshake metadata. :return: a patch.dict context manager / decorator. """ From ca8f1a3da29b642ca1a838b953c79c5f108d2036 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 14:10:54 -0400 Subject: [PATCH 27/28] PYTHON-5929 Add test for truncation keeping env.name and env.agent --- test/asynchronous/test_client.py | 24 ++++++++++++++++++++++++ test/test_client.py | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/test/asynchronous/test_client.py b/test/asynchronous/test_client.py index 902b4fb69a..cc32639e18 100644 --- a/test/asynchronous/test_client.py +++ b/test/asynchronous/test_client.py @@ -537,6 +537,30 @@ def metadata_for(plat_len: int) -> dict[str, Any]: plat_len = target - len(bson.encode(metadata_for(0))) return metadata_for(plat_len) + async def test_metadata_truncation_keeps_name_and_agent(self): + # Dropping the other env fields alone must bring the document back + # under the limit. + env: dict[str, Any] = { + "name": "azure.func", + "agent": "claude_code", + "region": "us-east-2", + "memory_mb": 1024, + } + os_meta = {"type": "Darwin", "name": "Darwin", "architecture": "arm64", "version": "26.7.1"} + + def metadata_for(plat_len: int) -> dict[str, Any]: + return {"env": env, "os": os_meta, "platform": "p" * plat_len} + + # Each platform character adds exactly one byte. + plat_len = _MAX_METADATA_SIZE + 1 - len(bson.encode(metadata_for(0))) + metadata = metadata_for(plat_len) + plat = metadata["platform"] + _truncate_metadata(metadata) + self.assertLessEqual(len(bson.encode(metadata)), _MAX_METADATA_SIZE) + self.assertEqual(metadata["env"], {"name": "azure.func", "agent": "claude_code"}) + self.assertEqual(metadata["os"], os_meta) + self.assertEqual(metadata["platform"], plat) + async def test_metadata_truncation_omits_agent_before_name(self): metadata = self._metadata_with_agent_env(with_name=True) os_meta = metadata["os"] diff --git a/test/test_client.py b/test/test_client.py index 29f4d28a25..bca4270ad1 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -530,6 +530,30 @@ def metadata_for(plat_len: int) -> dict[str, Any]: plat_len = target - len(bson.encode(metadata_for(0))) return metadata_for(plat_len) + def test_metadata_truncation_keeps_name_and_agent(self): + # Dropping the other env fields alone must bring the document back + # under the limit. + env: dict[str, Any] = { + "name": "azure.func", + "agent": "claude_code", + "region": "us-east-2", + "memory_mb": 1024, + } + os_meta = {"type": "Darwin", "name": "Darwin", "architecture": "arm64", "version": "26.7.1"} + + def metadata_for(plat_len: int) -> dict[str, Any]: + return {"env": env, "os": os_meta, "platform": "p" * plat_len} + + # Each platform character adds exactly one byte. + plat_len = _MAX_METADATA_SIZE + 1 - len(bson.encode(metadata_for(0))) + metadata = metadata_for(plat_len) + plat = metadata["platform"] + _truncate_metadata(metadata) + self.assertLessEqual(len(bson.encode(metadata)), _MAX_METADATA_SIZE) + self.assertEqual(metadata["env"], {"name": "azure.func", "agent": "claude_code"}) + self.assertEqual(metadata["os"], os_meta) + self.assertEqual(metadata["platform"], plat) + def test_metadata_truncation_omits_agent_before_name(self): metadata = self._metadata_with_agent_env(with_name=True) os_meta = metadata["os"] From 786dccf78c5612472b361608fa348bce081ff2f6 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Mon, 26 Oct 2026 14:13:45 -0400 Subject: [PATCH 28/28] PYTHON-5929 Reword unset_agent_env docstring --- test/utils_shared.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utils_shared.py b/test/utils_shared.py index aa7cd7045a..13ea464949 100644 --- a/test/utils_shared.py +++ b/test/utils_shared.py @@ -618,7 +618,7 @@ def suppress_fork_deprecation(): def unset_agent_env(): - """Blank the coding agent env vars the handshake reads. + """Unset the coding agent env vars the handshake reads. A coding agent often runs the test process itself, which would add client.env.agent to the handshake metadata.