Skip to content

Commit d14c67c

Browse files
lukepietteclaude
andauthored
feat: detect all coding agents in user-agent, not just Claude Code (#519)
* feat: detect all coding agents in user-agent, not just Claude Code Extends agent source tracking beyond CLAUDECODE to the full set of coding agent harnesses, mirroring runpodctl PR #280 and Hugging Face's public agent-harnesses registry so traffic is attributed under the same identifiers across surfaces. - Add runpod/agent.py: ordered harness registry (claude-code, cursor, cursor-cli, codex, gemini-cli, github-copilot, cline, replit, zed, etc.) plus a sanitized generic AI_AGENT fallback. - Wire agent.detect() into construct_user_agent(), replacing the single CLAUDECODE check. Emits "(via <agent>)" as before. - Tests for detection precedence, sanitization, and the user-agent string. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: address Copilot review on agent tracking - user_agent.py: use agent.suffix() instead of re-implementing the "(via <id>)" fragment, so the format lives in one place and cannot drift. - agent.detect(): strip env var values before matching, so a whitespace-only value does not count as detection (aligns harness matching with the docstring and the AI_AGENT path, which already strips). - Add a test for the whitespace-only case. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 18c6841 commit d14c67c

4 files changed

Lines changed: 260 additions & 11 deletions

File tree

runpod/agent.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Detects which AI coding agent (if any) is driving the SDK.
2+
3+
Detection is based on the environment variables that agent harnesses set in
4+
the processes they spawn. The registry mirrors Hugging Face's public
5+
agent-harnesses list so that traffic is attributed under the same agent
6+
identifiers across tools:
7+
https://github.com/huggingface/huggingface.js/blob/main/packages/tasks/src/agent-harnesses.ts
8+
"""
9+
10+
import os
11+
12+
# Each entry maps an agent identifier to the environment variables that
13+
# identify it. Detection matches if ANY of the listed variables is set to a
14+
# non-empty value. The list is checked in order and the first match wins;
15+
# order matters so that more specific signals come before broader ones they
16+
# can co-occur with (e.g. cowork before claude-code, cursor-cli before cursor).
17+
HARNESSES = [
18+
("antigravity", ["ANTIGRAVITY_AGENT"]),
19+
("augment-cli", ["AUGMENT_AGENT"]),
20+
("cline", ["CLINE_ACTIVE"]),
21+
("cowork", ["CLAUDE_CODE_IS_COWORK"]),
22+
("claude-code", ["CLAUDECODE", "CLAUDE_CODE"]),
23+
("codex", ["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"]),
24+
("crush", ["CRUSH"]),
25+
("gemini-cli", ["GEMINI_CLI"]),
26+
("github-copilot", ["COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"]),
27+
("goose", ["GOOSE_TERMINAL"]),
28+
("hermes-agent", ["HERMES_SESSION_ID"]),
29+
("kilo-code", ["KILOCODE_FEATURE"]),
30+
("kiro", ["AGENT_CONTEXT_OUT"]),
31+
("openclaw", ["OPENCLAW_SHELL"]),
32+
("opencode", ["OPENCODE_CLIENT"]),
33+
("pi", ["PI_CODING_AGENT"]),
34+
("replit", ["REPL_ID"]),
35+
("trae", ["TRAE_AI_SHELL_ID"]),
36+
("zed", ["ZED_TERM"]),
37+
("cursor-cli", ["CURSOR_AGENT"]),
38+
("cursor", ["CURSOR_TRACE_ID"]),
39+
]
40+
41+
# Generic variables any tool can set to identify itself. When set, the value is
42+
# sanitized and used as the agent id. Only AI_AGENT is honored: a bare AGENT is
43+
# too common in unrelated environments (CI runners, shell setups) and would
44+
# attribute traffic to arbitrary values.
45+
STANDARD_ENV_VARS = ["AI_AGENT"]
46+
47+
48+
def known_env_vars():
49+
"""Returns every environment variable the registry inspects, including the
50+
standard AI_AGENT signal. Useful for tests that need to isolate detection
51+
from the ambient environment.
52+
"""
53+
variables = []
54+
for _, env_vars in HARNESSES:
55+
variables.extend(env_vars)
56+
return variables + list(STANDARD_ENV_VARS)
57+
58+
59+
def _sanitize(value):
60+
"""Keeps only User-Agent-safe characters ([A-Za-z0-9._-]), capped at 64
61+
characters, so an arbitrary env value cannot produce a malformed header.
62+
"""
63+
value = value.strip()
64+
safe = []
65+
for char in value:
66+
if len(safe) >= 64:
67+
break
68+
if char.isascii() and (char.isalnum() or char in "._-"):
69+
safe.append(char)
70+
return "".join(safe)
71+
72+
73+
def detect():
74+
"""Returns the identifier of the AI coding agent driving the SDK, or an
75+
empty string if none is detected. Specific harness markers take priority
76+
over the generic AI_AGENT signal.
77+
"""
78+
for agent_id, env_vars in HARNESSES:
79+
for env in env_vars:
80+
if os.getenv(env, "").strip():
81+
return agent_id
82+
83+
for env in STANDARD_ENV_VARS:
84+
value = _sanitize(os.getenv(env, ""))
85+
if value:
86+
return value
87+
88+
return ""
89+
90+
91+
def suffix():
92+
"""Returns the " (via <id>)" User-Agent fragment for the detected agent, or
93+
an empty string when none is detected. Centralizing the fragment here keeps
94+
the tag format identical across every client's User-Agent.
95+
"""
96+
agent_id = detect()
97+
if agent_id:
98+
return f" (via {agent_id})"
99+
return ""

runpod/user_agent.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import platform
55

6+
from runpod import agent
67
from runpod.version import __version__ as runpod_version
78

89

@@ -25,8 +26,9 @@ def construct_user_agent():
2526
if integration_method:
2627
ua_components.append(f"Integration/{integration_method}")
2728

28-
if os.getenv("CLAUDECODE") == "1":
29-
ua_components.append("(via claude-code)")
29+
agent_suffix = agent.suffix()
30+
if agent_suffix:
31+
ua_components.append(agent_suffix.strip())
3032

3133
user_agent = " ".join(ua_components)
3234
return user_agent

tests/test_agent.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Tests for the agent detection module."""
2+
3+
import os
4+
import unittest
5+
from unittest.mock import patch
6+
7+
from runpod import agent
8+
9+
10+
def _clean_env():
11+
"""Returns an environment dict with every known agent signal removed."""
12+
env = dict(os.environ)
13+
for key in agent.known_env_vars():
14+
env.pop(key, None)
15+
return env
16+
17+
18+
class TestDetect(unittest.TestCase):
19+
"""Test the agent.detect function."""
20+
21+
def test_no_agent(self):
22+
"""No agent env vars set means no detection."""
23+
with patch.dict(os.environ, _clean_env(), clear=True):
24+
self.assertEqual(agent.detect(), "")
25+
self.assertEqual(agent.suffix(), "")
26+
27+
def test_claude_code(self):
28+
"""CLAUDECODE detects claude-code."""
29+
env = _clean_env()
30+
env["CLAUDECODE"] = "1"
31+
with patch.dict(os.environ, env, clear=True):
32+
self.assertEqual(agent.detect(), "claude-code")
33+
self.assertEqual(agent.suffix(), " (via claude-code)")
34+
35+
def test_claude_code_alternate_var(self):
36+
"""CLAUDE_CODE also detects claude-code."""
37+
env = _clean_env()
38+
env["CLAUDE_CODE"] = "1"
39+
with patch.dict(os.environ, env, clear=True):
40+
self.assertEqual(agent.detect(), "claude-code")
41+
42+
def test_cursor(self):
43+
"""CURSOR_TRACE_ID detects cursor."""
44+
env = _clean_env()
45+
env["CURSOR_TRACE_ID"] = "abc123"
46+
with patch.dict(os.environ, env, clear=True):
47+
self.assertEqual(agent.detect(), "cursor")
48+
49+
def test_cursor_cli_before_cursor(self):
50+
"""cursor-cli is more specific and wins over cursor when both are set."""
51+
env = _clean_env()
52+
env["CURSOR_AGENT"] = "1"
53+
env["CURSOR_TRACE_ID"] = "abc123"
54+
with patch.dict(os.environ, env, clear=True):
55+
self.assertEqual(agent.detect(), "cursor-cli")
56+
57+
def test_cowork_before_claude_code(self):
58+
"""cowork is more specific and wins over claude-code when both are set."""
59+
env = _clean_env()
60+
env["CLAUDE_CODE_IS_COWORK"] = "1"
61+
env["CLAUDECODE"] = "1"
62+
with patch.dict(os.environ, env, clear=True):
63+
self.assertEqual(agent.detect(), "cowork")
64+
65+
def test_codex(self):
66+
"""A Codex marker detects codex."""
67+
env = _clean_env()
68+
env["CODEX_THREAD_ID"] = "t-1"
69+
with patch.dict(os.environ, env, clear=True):
70+
self.assertEqual(agent.detect(), "codex")
71+
72+
def test_gemini_cli(self):
73+
"""GEMINI_CLI detects gemini-cli."""
74+
env = _clean_env()
75+
env["GEMINI_CLI"] = "1"
76+
with patch.dict(os.environ, env, clear=True):
77+
self.assertEqual(agent.detect(), "gemini-cli")
78+
79+
def test_empty_value_not_detected(self):
80+
"""An env var set to empty string does not count as detection."""
81+
env = _clean_env()
82+
env["CLAUDECODE"] = ""
83+
with patch.dict(os.environ, env, clear=True):
84+
self.assertEqual(agent.detect(), "")
85+
86+
def test_whitespace_value_not_detected(self):
87+
"""An env var set to whitespace only does not count as detection."""
88+
env = _clean_env()
89+
env["CLAUDECODE"] = " "
90+
with patch.dict(os.environ, env, clear=True):
91+
self.assertEqual(agent.detect(), "")
92+
93+
def test_ai_agent_generic_fallback(self):
94+
"""The generic AI_AGENT signal is used when no harness matches."""
95+
env = _clean_env()
96+
env["AI_AGENT"] = "my-custom-agent"
97+
with patch.dict(os.environ, env, clear=True):
98+
self.assertEqual(agent.detect(), "my-custom-agent")
99+
100+
def test_harness_wins_over_ai_agent(self):
101+
"""A specific harness marker takes priority over the generic AI_AGENT."""
102+
env = _clean_env()
103+
env["AI_AGENT"] = "my-custom-agent"
104+
env["CLAUDECODE"] = "1"
105+
with patch.dict(os.environ, env, clear=True):
106+
self.assertEqual(agent.detect(), "claude-code")
107+
108+
def test_ai_agent_sanitized(self):
109+
"""AI_AGENT values are sanitized to User-Agent-safe characters."""
110+
env = _clean_env()
111+
env["AI_AGENT"] = "bad value (with) chars!"
112+
with patch.dict(os.environ, env, clear=True):
113+
self.assertEqual(agent.detect(), "badvaluewithchars")
114+
115+
def test_ai_agent_length_capped(self):
116+
"""AI_AGENT values are capped at 64 characters."""
117+
env = _clean_env()
118+
env["AI_AGENT"] = "a" * 200
119+
with patch.dict(os.environ, env, clear=True):
120+
self.assertEqual(agent.detect(), "a" * 64)
121+
122+
123+
if __name__ == "__main__":
124+
unittest.main()

tests/test_user_agent.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@
55
from unittest.mock import patch
66

77
from runpod import __version__ as runpod_version
8+
from runpod import agent
89
from runpod.user_agent import construct_user_agent
910

1011

12+
def _agent_env_keys():
13+
"""Every env var that could inject an agent tag, plus the integration var."""
14+
return agent.known_env_vars() + ["RUNPOD_UA_INTEGRATION"]
15+
16+
1117
class TestConstructUserAgent(unittest.TestCase):
1218
"""Test the construct_user_agent function."""
1319

@@ -19,7 +25,7 @@ def test_user_agent_without_integration(
1925
self, mock_python_version, mock_machine, mock_release, mock_system
2026
):
2127
"""Test the User-Agent string without specifying an integration method."""
22-
saved = {k: os.environ.pop(k) for k in ("RUNPOD_UA_INTEGRATION", "CLAUDECODE") if k in os.environ}
28+
saved = {k: os.environ.pop(k) for k in _agent_env_keys() if k in os.environ}
2329

2430
expected_ua = f"RunPod-Python-SDK/{runpod_version} (Windows 10; AMD64) Language/Python 3.8.10" # pylint: disable=line-too-long
2531
self.assertEqual(construct_user_agent(), expected_ua)
@@ -39,15 +45,14 @@ def test_user_agent_with_integration(
3945
self, mock_python_version, mock_machine, mock_release, mock_system
4046
):
4147
"""Test the User-Agent string with an integration method specified."""
42-
saved_claude = os.environ.pop("CLAUDECODE", None)
48+
saved = {k: os.environ.pop(k) for k in _agent_env_keys() if k in os.environ}
4349
os.environ["RUNPOD_UA_INTEGRATION"] = "SkyPilot"
4450

4551
expected_ua = f"RunPod-Python-SDK/{runpod_version} (Linux 5.4; x86_64) Language/Python 3.9.5 Integration/SkyPilot" # pylint: disable=line-too-long
4652
self.assertEqual(construct_user_agent(), expected_ua)
4753

4854
os.environ.pop("RUNPOD_UA_INTEGRATION")
49-
if saved_claude is not None:
50-
os.environ["CLAUDECODE"] = saved_claude
55+
os.environ.update(saved)
5156

5257
assert mock_python_version.called
5358
assert mock_machine.called
@@ -62,13 +67,15 @@ def test_user_agent_with_integration(
6267
def test_user_agent_with_claude_code(
6368
self, mock_python_version, mock_machine, mock_release, mock_system
6469
):
65-
"""Test the User-Agent string includes claude-code agent tag."""
66-
os.environ.pop("RUNPOD_UA_INTEGRATION", None)
70+
"""Test the User-Agent string includes the claude-code agent tag."""
71+
saved = {k: os.environ.pop(k) for k in _agent_env_keys() if k in os.environ}
6772
os.environ["CLAUDECODE"] = "1"
6873

6974
expected_ua = f"RunPod-Python-SDK/{runpod_version} (Linux 5.4; x86_64) Language/Python 3.9.5 (via claude-code)"
7075
self.assertEqual(construct_user_agent(), expected_ua)
76+
7177
os.environ.pop("CLAUDECODE", None)
78+
os.environ.update(saved)
7279

7380
@patch("runpod.user_agent.platform.system", return_value="Linux")
7481
@patch("runpod.user_agent.platform.release", return_value="5.4")
@@ -77,12 +84,29 @@ def test_user_agent_with_claude_code(
7784
def test_user_agent_without_claude_code(
7885
self, mock_python_version, mock_machine, mock_release, mock_system
7986
):
80-
"""Test the User-Agent string excludes agent tag when env var is not set."""
81-
saved = {k: os.environ.pop(k) for k in ("RUNPOD_UA_INTEGRATION", "CLAUDECODE") if k in os.environ}
87+
"""Test the User-Agent string excludes agent tag when no env var is set."""
88+
saved = {k: os.environ.pop(k) for k in _agent_env_keys() if k in os.environ}
89+
90+
ua = construct_user_agent()
91+
self.assertNotIn("(via ", ua)
92+
93+
os.environ.update(saved)
94+
95+
@patch("runpod.user_agent.platform.system", return_value="Linux")
96+
@patch("runpod.user_agent.platform.release", return_value="5.4")
97+
@patch("runpod.user_agent.platform.machine", return_value="x86_64")
98+
@patch("runpod.user_agent.platform.python_version", return_value="3.9.5")
99+
def test_user_agent_with_other_agent(
100+
self, mock_python_version, mock_machine, mock_release, mock_system
101+
):
102+
"""Test the User-Agent string includes a non-Claude agent tag (e.g. cursor)."""
103+
saved = {k: os.environ.pop(k) for k in _agent_env_keys() if k in os.environ}
104+
os.environ["CURSOR_TRACE_ID"] = "abc123"
82105

83106
ua = construct_user_agent()
84-
self.assertNotIn("via claude-code", ua)
107+
self.assertIn("(via cursor)", ua)
85108

109+
os.environ.pop("CURSOR_TRACE_ID", None)
86110
os.environ.update(saved)
87111

88112

0 commit comments

Comments
 (0)