Skip to content
Merged
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
52 changes: 34 additions & 18 deletions packages/core/src/clientinfo/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,29 @@
* @module
*/

import {sanitize} from './clientinfo';

interface KnownAgent {
readonly envVar: string;
readonly product: string;
}

// Name of the agents.md standard env var. When set to a value that no
// known agent recognizes, detection falls back to "unknown".
// Name of the agents.md standard env var.
const AGENT_ENV_VAR = 'AGENT';

// Canonical list of AI coding agents. Keep this list in sync with the
// Go, Java, and Python SDKs. Agents are listed alphabetically by product
// name.
// Name of the Vercel @vercel/detect-agent convention env var. It serves
// the same purpose as AGENT_ENV_VAR; agentEnvFallback consults it only when
// AGENT_ENV_VAR is unset or empty.
const AI_AGENT_ENV_VAR = 'AI_AGENT';

// Caps fallback values to keep the user-agent bounded. Explicit-matcher
// products are short by construction; only the fallback path can carry
// arbitrary lengths.
const MAX_AGENT_FALLBACK_LEN = 64;

// Canonical list of AI coding agents. Keep this list, and the AGENT /
// AI_AGENT fallback handling in agentEnvFallback, in sync with the Go,
// Java, and Python SDKs. Agents are listed alphabetically by product name.
const KNOWN_AGENTS: readonly KnownAgent[] = [
// The amp agent also sets AGENT=amp, handled by the central fallback.
{envVar: 'AMP_CURRENT_THREAD_ID', product: 'amp'},
Expand All @@ -45,24 +56,30 @@ const KNOWN_AGENTS: readonly KnownAgent[] = [
{envVar: 'WINDSURF_AGENT', product: 'windsurf'},
];

/**
* Returns a sanitized, length-capped name from `AGENT` or `AI_AGENT`,
* preferring `AGENT` when both are non-empty. Empty is treated as unset for
* both. The value is passed through rather than categorized so that new
* names are propagated without the need to update the list of known agents.
*/
function agentEnvFallback(): string {
const v = process.env[AGENT_ENV_VAR];
let v = process.env[AGENT_ENV_VAR];
if (v === undefined || v === '') {
return '';
v = process.env[AI_AGENT_ENV_VAR];
}
if (KNOWN_AGENTS.some(a => a.product === v)) {
return v;
if (v === undefined || v === '') {
return '';
}
return 'unknown';
// slice is a no-op when the value is already within the cap.
return sanitize(v).slice(0, MAX_AGENT_FALLBACK_LEN);
}

/**
* Checks environment variables for known AI agents and returns the
* detected product name.
*
* Explicit product-specific env vars always take precedence over the
* generic agents.md `AGENT` env var. `AGENT` is consulted only as a
* fallback when no explicit matcher fires, so that an explicit signal
* generic `AGENT` and `AI_AGENT` env vars, so that an explicit signal
* (e.g. `CLAUDECODE=1`) always wins over a conflicting `AGENT=<name>`
* value.
*
Expand All @@ -73,8 +90,8 @@ function agentEnvFallback(): string {
* can be stacked when one agent invokes another as a subagent (e.g.
* Claude Code spawning a Cursor CLI subprocess), so the child process
* inherits env vars from multiple layers.
* - When no known env var is set and `AGENT` is a non-empty value: the
* value itself if it names a known product, otherwise `"unknown"`.
* - A sanitized, length-capped value from `AGENT` or `AI_AGENT` when no
* known env var is set (see {@link agentEnvFallback}).
* - `""` when nothing is set.
*/
export function lookupAgentProvider(): string {
Expand All @@ -101,13 +118,12 @@ let cached: string | undefined;
* Returns one of:
*
* - The known product name when exactly one agent is detected via
* explicit env matchers, or when `AGENT` is set to a known product
* name and no explicit matcher fired.
* explicit env matchers.
* - `"multiple"` when multiple explicit matchers fire for different
* agents (typically nested agents, e.g. Cursor CLI running as a
* Claude Code subagent).
* - `"unknown"` when no explicit matcher fired and `AGENT` is set to a
* value that is not a known product name.
* - A sanitized, length-capped value from `AGENT` or `AI_AGENT` when no
* explicit matcher fired (see {@link agentEnvFallback}).
* - `""` when no agent is detected.
*/
export function agentProvider(): string {
Expand Down
60 changes: 56 additions & 4 deletions packages/core/tests/clientinfo/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,19 @@ describe('lookupAgentProvider', () => {
want: 'cursor',
},
{
name: 'AGENT with unknown value falls back to unknown',
env: {AGENT: 'somethingweird'},
want: 'unknown',
name: 'AGENT with unrecognized value passes through (sanitized)',
env: {AGENT: 'someweirdthing'},
want: 'someweirdthing',
},
{
name: 'AGENT with disallowed chars is sanitized to hyphens',
env: {AGENT: 'claude code/agent'},
want: 'claude-code-agent',
},
{
name: 'AGENT longer than the cap is truncated',
env: {AGENT: 'a'.repeat(100)},
want: 'a'.repeat(64),
},
{
name: 'AGENT empty string does not trigger fallback',
Expand All @@ -160,7 +170,7 @@ describe('lookupAgentProvider', () => {
want: 'claude-code',
},
{
name: 'known matcher wins over AGENT fallback to unknown',
name: 'known matcher wins over unrecognized AGENT fallback',
env: {AGENT: 'somethingunknown', CLAUDECODE: '1'},
want: 'claude-code',
},
Expand All @@ -169,6 +179,48 @@ describe('lookupAgentProvider', () => {
env: {VSCODE_AGENT: '1', COPILOT_CLI: '1'},
want: 'multiple',
},
// AI_AGENT fallback (Vercel @vercel/detect-agent convention).
{
name: 'AI_AGENT=cursor falls back to cursor',
env: {AI_AGENT: 'cursor'},
want: 'cursor',
},
{
name: 'AI_AGENT empty string does not trigger fallback',
env: {AI_AGENT: ''},
want: '',
},
{
name: 'known matcher wins over AI_AGENT fallback',
env: {AI_AGENT: 'somethingunknown', CLAUDECODE: '1'},
want: 'claude-code',
},
// AGENT vs AI_AGENT precedence: AGENT wins when both are non-empty.
{
name: 'AGENT wins over AI_AGENT when both are set to known products',
env: {AGENT: 'claude-code', AI_AGENT: 'cursor'},
want: 'claude-code',
},
{
name: 'AGENT set to unrecognized non-empty value still wins over AI_AGENT',
env: {AGENT: 'somethingunknown', AI_AGENT: 'cursor'},
want: 'somethingunknown',
},
{
name: 'AGENT set, AI_AGENT empty: AGENT value is used',
env: {AGENT: 'cursor', AI_AGENT: ''},
want: 'cursor',
},
{
name: 'empty AGENT falls through to AI_AGENT',
env: {AGENT: '', AI_AGENT: 'cursor'},
want: 'cursor',
},
{
name: 'both AGENT and AI_AGENT empty returns no agent',
env: {AGENT: '', AI_AGENT: ''},
want: '',
},
];

it.each(testCases)('$name', ({env, want}) => {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/tests/clientinfo/default.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ describe('createDefault', () => {
want: `${prefix} agent/goose`,
},
{
name: 'AGENT fallback to unknown',
name: 'AGENT fallback passes unrecognized value through',
env: {AGENT: 'somethingweird'},
want: `${prefix} agent/unknown`,
want: `${prefix} agent/somethingweird`,
},
{
name: 'databricks runtime',
Expand Down
Loading