Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions pymongo/pool_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,34 @@ def _is_faas() -> bool:
return _is_lambda() or _is_azure_func() or _is_gcp_func() or _is_vercel()


# Environment variables that indicate a coding agent, checked in order. The
# first match determines the value of the client.env.agent metadata field.
# See DRIVERS-3529 and PYTHON-5929.
_AGENT_ENV_VARS = [
("CLAUDECODE", "CLAUDECODE"),
("CURSOR_AGENT", "CURSOR"),
("GEMINI_CLI", "GEMINI_CLI"),
("CODEX_SANDBOX", "CODEX_SANDBOX"),
("AUGMENT_AGENT", "AUGMENT"),
("OPENCODE_CLIENT", "OPENCODE"),
]


def _metadata_agent() -> Optional[str]:
"""Detect a coding agent from the environment for client.env.agent.

A generic AI_AGENT or AGENT environment variable takes precedence and its
value is used verbatim. Otherwise the first matching known agent variable
determines the value."""
agent = os.getenv("AI_AGENT") or os.getenv("AGENT")
if agent:
return agent
for var, name in _AGENT_ENV_VARS:
if os.getenv(var):
return name
return None


def _getenv_int(key: str) -> Optional[int]:
"""Like os.getenv but returns an int, or None if the value is missing/malformed."""
val = os.getenv(key)
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -217,6 +249,16 @@ def _truncate_metadata(metadata: MutableMapping[str, Any]) -> None:
metadata["os"] = {"type": os_type}
if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE:
return
# 2b. Drop env.agent (which may hold an arbitrarily large AI_AGENT/AGENT
# value) before sacrificing env.name by dropping the env document entirely.
if "agent" in trimmed_env:
del trimmed_env["agent"]
if trimmed_env:
metadata["env"] = trimmed_env
else:
metadata.pop("env", None)
if len(bson.encode(metadata)) <= _MAX_METADATA_SIZE:
return
# 3. Omit the env document entirely.
metadata.pop("env", None)
encoded_size = len(bson.encode(metadata))
Expand Down
26 changes: 26 additions & 0 deletions test/asynchronous/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2204,6 +2204,32 @@ async def test_handshake_09_container_with_provider(self):
},
)

async def test_handshake_10_agent_known(self):
# A known coding-agent env var maps to its metadata value.
await self._test_handshake({"CLAUDECODE": "1"}, {"agent": "CLAUDECODE"})
await self._test_handshake({"CURSOR_AGENT": "1"}, {"agent": "CURSOR"})
await self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "OPENCODE"})

async def test_handshake_11_agent_generic(self):
# Generic AI_AGENT/AGENT vars are used verbatim and take precedence.
await self._test_handshake({"AI_AGENT": "myagent"}, {"agent": "myagent"})
await self._test_handshake({"AGENT": "myagent"}, {"agent": "myagent"})
await self._test_handshake({"AI_AGENT": "myagent", "CLAUDECODE": "1"}, {"agent": "myagent"})

async def test_handshake_12_agent_with_provider(self):
# agent is reported alongside a FaaS provider.
await self._test_handshake(
{"FUNCTIONS_WORKER_RUNTIME": "python", "CLAUDECODE": "1"},
{"name": "azure.func", "agent": "CLAUDECODE"},
)

async def test_handshake_13_agent_too_long(self):
# A too-long agent value is dropped during truncation before env.name.
await self._test_handshake(
{"FUNCTIONS_WORKER_RUNTIME": "python", "AI_AGENT": "a" * 512},
{"name": "azure.func"},
)

def test_dict_hints(self):
self.db.t.find(hint={"x": 1})

Expand Down
26 changes: 26 additions & 0 deletions test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2161,6 +2161,32 @@ def test_handshake_09_container_with_provider(self):
},
)

def test_handshake_10_agent_known(self):
# A known coding-agent env var maps to its metadata value.
self._test_handshake({"CLAUDECODE": "1"}, {"agent": "CLAUDECODE"})
self._test_handshake({"CURSOR_AGENT": "1"}, {"agent": "CURSOR"})
self._test_handshake({"OPENCODE_CLIENT": "1"}, {"agent": "OPENCODE"})

def test_handshake_11_agent_generic(self):
# Generic AI_AGENT/AGENT vars are used verbatim and take precedence.
self._test_handshake({"AI_AGENT": "myagent"}, {"agent": "myagent"})
self._test_handshake({"AGENT": "myagent"}, {"agent": "myagent"})
self._test_handshake({"AI_AGENT": "myagent", "CLAUDECODE": "1"}, {"agent": "myagent"})

def test_handshake_12_agent_with_provider(self):
# agent is reported alongside a FaaS provider.
self._test_handshake(
{"FUNCTIONS_WORKER_RUNTIME": "python", "CLAUDECODE": "1"},
{"name": "azure.func", "agent": "CLAUDECODE"},
)

def test_handshake_13_agent_too_long(self):
# A too-long agent value is dropped during truncation before env.name.
self._test_handshake(
{"FUNCTIONS_WORKER_RUNTIME": "python", "AI_AGENT": "a" * 512},
{"name": "azure.func"},
)

def test_dict_hints(self):
self.db.t.find(hint={"x": 1})

Expand Down
Loading