Skip to content

Commit 31fd142

Browse files
authored
Add AI agent detection to user-agent string (#701)
## Summary - Detect known AI coding agents via environment variables and append `agent/<name>` to the HTTP user-agent header - Canonical agent list: Antigravity (`ANTIGRAVITY_AGENT`), Claude Code (`CLAUDECODE`), Cline (`CLINE_ACTIVE`), Codex (`CODEX_CI`), Copilot CLI (`COPILOT_CLI`), Cursor (`CURSOR_AGENT`), Gemini CLI (`GEMINI_CLI`), OpenCode (`OPENCODE`), OpenClaw (`OPENCLAW_SHELL`) - Returns empty string when zero or multiple agents are detected (ambiguity guard) - Uses the same double-checked locking pattern as existing CI/CD detection - Part of a cross-SDK effort (Go SDK PR #1537, Python SDK PR #1327) The `COPILOT_CLI` env var was confirmed by direct testing in Copilot CLI. The `OPENCLAW_SHELL` env var uses context-qualified values (`exec`, `acp`, `acp-client`); any non-empty value triggers detection. ## Test plan - [x] Unit test for each of the 9 agents individually - [x] Test: no agent env vars set produces no `agent/` segment - [x] Test: multiple agent env vars set produces no `agent/` segment - [x] Test: empty env var value produces no `agent/` segment - [x] Test: cached value persists after environment change - [x] `mvn spotless:apply` passes - [x] `mvn -Dtest=UserAgentTest test` passes
1 parent 662a5b0 commit 31fd142

File tree

3 files changed

+240
-3
lines changed

3 files changed

+240
-3
lines changed

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Release v0.104.0
44

55
### New Features and Improvements
6+
* Added automatic detection of AI coding agents (Antigravity, Claude Code, Cline, Codex, Copilot CLI, Cursor, Gemini CLI, OpenCode) in the user-agent string. The SDK now appends `agent/<name>` to HTTP request headers when running inside a known AI agent environment.
67

78
### Bug Fixes
89
* Fixed Databricks CLI authentication to detect when the cached token's scopes don't match the SDK's configured scopes. Previously, a scope mismatch was silently ignored, causing requests to use wrong permissions. The SDK now raises an error with instructions to re-authenticate.

databricks-sdk-java/src/main/java/com/databricks/sdk/core/UserAgent.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ public static String asString() {
129129
if (!cicdProvider.isEmpty()) {
130130
segments.add(String.format("cicd/%s", cicdProvider));
131131
}
132+
String agent = agentProvider();
133+
if (!agent.isEmpty()) {
134+
segments.add(String.format("agent/%s", agent));
135+
}
132136
// Concurrent iteration over ArrayList must be guarded with synchronized.
133137
synchronized (otherInfo) {
134138
segments.addAll(
@@ -168,6 +172,8 @@ private static List<CicdProvider> listCiCdProviders() {
168172
// reordering by the compiler.
169173
protected static volatile String cicdProvider = null;
170174

175+
protected static volatile String agentProvider = null;
176+
171177
protected static Environment env = null;
172178

173179
// Represents an environment variable with its name and expected value
@@ -231,6 +237,66 @@ private static String cicdProvider() {
231237
return cicdProvider;
232238
}
233239

240+
// Maps an environment variable to an agent product name.
241+
private static class AgentDef {
242+
private final String envVar;
243+
private final String product;
244+
245+
AgentDef(String envVar, String product) {
246+
this.envVar = envVar;
247+
this.product = product;
248+
}
249+
}
250+
251+
// Canonical list of known AI coding agents.
252+
// Keep this list in sync with databricks-sdk-go and databricks-sdk-py.
253+
private static List<AgentDef> listKnownAgents() {
254+
return Arrays.asList(
255+
new AgentDef("ANTIGRAVITY_AGENT", "antigravity"), // Closed source (Google)
256+
new AgentDef("CLAUDECODE", "claude-code"), // https://github.com/anthropics/claude-code
257+
new AgentDef("CLINE_ACTIVE", "cline"), // https://github.com/cline/cline (v3.24.0+)
258+
new AgentDef("CODEX_CI", "codex"), // https://github.com/openai/codex
259+
new AgentDef("COPILOT_CLI", "copilot-cli"), // https://github.com/features/copilot
260+
new AgentDef("CURSOR_AGENT", "cursor"), // Closed source
261+
new AgentDef("GEMINI_CLI", "gemini-cli"), // https://google-gemini.github.io/gemini-cli
262+
new AgentDef("OPENCODE", "opencode"), // https://github.com/opencode-ai/opencode
263+
new AgentDef("OPENCLAW_SHELL", "openclaw")); // https://github.com/anthropics/openclaw
264+
}
265+
266+
// Looks up the active agent provider based on environment variables.
267+
// Returns the agent name if exactly one is set (non-empty).
268+
// Returns empty string if zero or multiple agents detected.
269+
private static String lookupAgentProvider(Environment env) {
270+
String detected = "";
271+
int count = 0;
272+
for (AgentDef agent : listKnownAgents()) {
273+
String value = env.get(agent.envVar);
274+
if (value != null && !value.isEmpty()) {
275+
detected = agent.product;
276+
count++;
277+
if (count > 1) {
278+
return "";
279+
}
280+
}
281+
}
282+
if (count == 1) {
283+
return detected;
284+
}
285+
return "";
286+
}
287+
288+
// Thread-safe lazy initialization of agent provider detection
289+
private static String agentProvider() {
290+
if (agentProvider == null) {
291+
synchronized (UserAgent.class) {
292+
if (agentProvider == null) {
293+
agentProvider = lookupAgentProvider(env());
294+
}
295+
}
296+
}
297+
return agentProvider;
298+
}
299+
234300
private static Environment env() {
235301
if (env == null) {
236302
env =

databricks-sdk-java/src/test/java/com/databricks/sdk/core/UserAgentTest.java

Lines changed: 173 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,30 @@
33
import com.databricks.sdk.core.utils.Environment;
44
import java.util.ArrayList;
55
import java.util.HashMap;
6+
import java.util.Map;
7+
import org.junit.jupiter.api.AfterEach;
68
import org.junit.jupiter.api.Assertions;
79
import org.junit.jupiter.api.Test;
810

911
public class UserAgentTest {
12+
13+
@AfterEach
14+
void tearDown() {
15+
cleanupAgentEnv();
16+
}
17+
18+
private void setupAgentEnv(Map<String, String> envMap) {
19+
UserAgent.agentProvider = null;
20+
UserAgent.cicdProvider = null;
21+
UserAgent.env = new Environment(envMap, new ArrayList<>(), System.getProperty("os.name"));
22+
}
23+
24+
private void cleanupAgentEnv() {
25+
UserAgent.env = null;
26+
UserAgent.agentProvider = null;
27+
UserAgent.cicdProvider = null;
28+
}
29+
1030
@Test
1131
public void testUserAgent() {
1232
UserAgent.withProduct("product", "productVersion");
@@ -66,7 +86,6 @@ public void testUserAgentCicdNoProvider() {
6686
UserAgent.env =
6787
new Environment(new HashMap<>(), new ArrayList<>(), System.getProperty("os.name"));
6888
Assertions.assertFalse(UserAgent.asString().contains("cicd"));
69-
UserAgent.env = null;
7089
}
7190

7291
@Test
@@ -82,7 +101,6 @@ public void testUserAgentCicdOneProvider() {
82101
new ArrayList<>(),
83102
System.getProperty("os.name"));
84103
Assertions.assertTrue(UserAgent.asString().contains("cicd/github"));
85-
UserAgent.env = null;
86104
}
87105

88106
@Test
@@ -99,6 +117,158 @@ public void testUserAgentCicdTwoProvider() {
99117
new ArrayList<>(),
100118
System.getProperty("os.name"));
101119
Assertions.assertTrue(UserAgent.asString().contains("cicd/gitlab"));
102-
UserAgent.env = null;
120+
}
121+
122+
@Test
123+
public void testAgentProviderAntigravity() {
124+
setupAgentEnv(
125+
new HashMap<String, String>() {
126+
{
127+
put("ANTIGRAVITY_AGENT", "1");
128+
}
129+
});
130+
Assertions.assertTrue(UserAgent.asString().contains("agent/antigravity"));
131+
}
132+
133+
@Test
134+
public void testAgentProviderClaudeCode() {
135+
setupAgentEnv(
136+
new HashMap<String, String>() {
137+
{
138+
put("CLAUDECODE", "1");
139+
}
140+
});
141+
Assertions.assertTrue(UserAgent.asString().contains("agent/claude-code"));
142+
}
143+
144+
@Test
145+
public void testAgentProviderCline() {
146+
setupAgentEnv(
147+
new HashMap<String, String>() {
148+
{
149+
put("CLINE_ACTIVE", "1");
150+
}
151+
});
152+
Assertions.assertTrue(UserAgent.asString().contains("agent/cline"));
153+
}
154+
155+
@Test
156+
public void testAgentProviderCodex() {
157+
setupAgentEnv(
158+
new HashMap<String, String>() {
159+
{
160+
put("CODEX_CI", "1");
161+
}
162+
});
163+
Assertions.assertTrue(UserAgent.asString().contains("agent/codex"));
164+
}
165+
166+
@Test
167+
public void testAgentProviderCopilotCli() {
168+
setupAgentEnv(
169+
new HashMap<String, String>() {
170+
{
171+
put("COPILOT_CLI", "1");
172+
}
173+
});
174+
Assertions.assertTrue(UserAgent.asString().contains("agent/copilot-cli"));
175+
}
176+
177+
@Test
178+
public void testAgentProviderCursor() {
179+
setupAgentEnv(
180+
new HashMap<String, String>() {
181+
{
182+
put("CURSOR_AGENT", "1");
183+
}
184+
});
185+
Assertions.assertTrue(UserAgent.asString().contains("agent/cursor"));
186+
}
187+
188+
@Test
189+
public void testAgentProviderGeminiCli() {
190+
setupAgentEnv(
191+
new HashMap<String, String>() {
192+
{
193+
put("GEMINI_CLI", "1");
194+
}
195+
});
196+
Assertions.assertTrue(UserAgent.asString().contains("agent/gemini-cli"));
197+
}
198+
199+
@Test
200+
public void testAgentProviderOpencode() {
201+
setupAgentEnv(
202+
new HashMap<String, String>() {
203+
{
204+
put("OPENCODE", "1");
205+
}
206+
});
207+
Assertions.assertTrue(UserAgent.asString().contains("agent/opencode"));
208+
}
209+
210+
@Test
211+
public void testAgentProviderOpenclaw() {
212+
setupAgentEnv(
213+
new HashMap<String, String>() {
214+
{
215+
put("OPENCLAW_SHELL", "exec");
216+
}
217+
});
218+
Assertions.assertTrue(UserAgent.asString().contains("agent/openclaw"));
219+
}
220+
221+
@Test
222+
public void testAgentProviderNoAgent() {
223+
setupAgentEnv(new HashMap<>());
224+
Assertions.assertFalse(UserAgent.asString().contains("agent/"));
225+
}
226+
227+
@Test
228+
public void testAgentProviderMultipleAgents() {
229+
setupAgentEnv(
230+
new HashMap<String, String>() {
231+
{
232+
put("CLAUDECODE", "1");
233+
put("CURSOR_AGENT", "1");
234+
}
235+
});
236+
Assertions.assertFalse(UserAgent.asString().contains("agent/"));
237+
}
238+
239+
@Test
240+
public void testAgentProviderEmptyValue() {
241+
setupAgentEnv(
242+
new HashMap<String, String>() {
243+
{
244+
put("CLAUDECODE", "");
245+
}
246+
});
247+
Assertions.assertFalse(UserAgent.asString().contains("agent/"));
248+
}
249+
250+
@Test
251+
public void testAgentProviderCached() {
252+
// Set up with cursor agent
253+
setupAgentEnv(
254+
new HashMap<String, String>() {
255+
{
256+
put("CURSOR_AGENT", "1");
257+
}
258+
});
259+
Assertions.assertTrue(UserAgent.asString().contains("agent/cursor"));
260+
261+
// Change env after caching. Cached result should persist.
262+
UserAgent.env =
263+
new Environment(
264+
new HashMap<String, String>() {
265+
{
266+
put("CLAUDECODE", "1");
267+
}
268+
},
269+
new ArrayList<>(),
270+
System.getProperty("os.name"));
271+
Assertions.assertTrue(UserAgent.asString().contains("agent/cursor"));
272+
Assertions.assertFalse(UserAgent.asString().contains("agent/claude-code"));
103273
}
104274
}

0 commit comments

Comments
 (0)