Skip to content
Open
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
136 changes: 136 additions & 0 deletions pkg/services/api/ai_agent_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package api

import "strings"

// AI agents are detected separately from automation (CI) environments because the two are
// independent dimensions: an agent commonly runs inside a CI system or on an Octopus worker,
// and both should be reported rather than one masking the other.

// aiAgentEnvVar is the emerging cross-vendor convention for an agent to identify itself.
// https://github.com/vercel/vercel/tree/main/packages/detect-agent
const aiAgentEnvVar = "AI_AGENT"

type aiAgentRule struct {
variable string
// value, when set, must match the variable's value (case-insensitive). When empty, any
// non-empty value matches.
value string
}

type aiAgent struct {
name string
rules []aiAgentRule
}

// knownAIAgents is ordered: the first agent with a matching rule wins, so the result is
// deterministic when several agents' variables are present (e.g. one agent running another).
//
// Variables that are commonly present without an agent driving the process are deliberately
// excluded: CURSOR_TRACE_ID (any Cursor terminal), COPILOT_GITHUB_TOKEN and GOOSE_PROVIDER
// (user configuration), REPL_ID (any Replit workspace) and ZED_TERM (any Zed terminal).
//
// References:
// https://code.claude.com/docs/en/env-vars
// https://source.dot.net/aspire/Telemetry/CodingAgentDetector.cs.html
// https://docs.rs/is-ai-agent
var knownAIAgents = []aiAgent{
{"ClaudeCode", []aiAgentRule{{variable: "CLAUDECODE"}, {variable: "CLAUDE_CODE_ENTRYPOINT"}}},
{"Cursor", []aiAgentRule{{variable: "CURSOR_AGENT"}, {variable: "CURSOR_EXTENSION_HOST_ROLE", value: "agent-exec"}}},
{"Codex", []aiAgentRule{{variable: "CODEX_SANDBOX"}, {variable: "CODEX_THREAD_ID"}, {variable: "CODEX_CI"}}},
{"GeminiCLI", []aiAgentRule{{variable: "GEMINI_CLI"}}},
{"GitHubCopilot", []aiAgentRule{{variable: "COPILOT_CLI"}, {variable: "COPILOT_AGENT"}, {variable: "GITHUB_COPILOT_CLI_MODE"}}},
{"OpenCode", []aiAgentRule{{variable: "OPENCODE", value: "1"}, {variable: "OPENCODE_CLIENT"}, {variable: "OPENCODE_AI"}}},
{"Antigravity", []aiAgentRule{{variable: "ANTIGRAVITY_AGENT"}}},
{"Goose", []aiAgentRule{{variable: "GOOSE_TERMINAL"}, {variable: "AGENT", value: "goose"}}},
{"Amp", []aiAgentRule{{variable: "AGENT", value: "amp"}}},
{"Cline", []aiAgentRule{{variable: "CLINE_ACTIVE"}, {variable: "CLINE_TASK_ID"}}},
{"RooCode", []aiAgentRule{{variable: "ROO_CODE_TASK_ID"}}},
{"Augment", []aiAgentRule{{variable: "AUGMENT_AGENT"}}},
{"QwenCode", []aiAgentRule{{variable: "QWEN_CODE"}}},
{"FactoryDroid", []aiAgentRule{{variable: "DROID_CLI"}}},
{"KimiCLI", []aiAgentRule{{variable: "KIMI_CLI"}}},
{"Trae", []aiAgentRule{{variable: "TRAE_AI_SHELL_ID"}}},
{"Aider", []aiAgentRule{{variable: "OR_APP_NAME", value: "Aider"}}},
{"OpenHands", []aiAgentRule{{variable: "OR_APP_NAME", value: "OpenHands"}}},
}

// aiAgentValuePrefixes maps the start of a normalised AI_AGENT value to a known agent name.
// Vendors don't agree on a format (e.g. "claude-code_2-1-322_agent", "github_copilot_vscode_agent",
// "devin@1"), so values are lowercased and runs of underscores and whitespace replaced with a single hyphen
// before matching.
var aiAgentValuePrefixes = []struct {
prefix string
name string
}{
{"claude-code", "ClaudeCode"},
{"cursor", "Cursor"},
{"codex", "Codex"},
{"gemini", "GeminiCLI"},
{"github-copilot", "GitHubCopilot"},
{"opencode", "OpenCode"},
{"antigravity", "Antigravity"},
{"goose", "Goose"},
{"amp", "Amp"},
{"cline", "Cline"},
{"augment", "Augment"},
{"devin", "Devin"},
{"replit", "Replit"},
{"v0", "V0"},
}

// GetAIAgent returns a user agent token naming the AI agent driving this process, or an empty
// string when none is detected. Only a normalised name is reported: the raw AI_AGENT value is
// free-form and user-settable, so it is used for matching and never included in the output.
// Detection is based on self-reported environment variables, so it is suitable for analytics but
// must not be relied on for security decisions.
//
// Examples:
//
// AIAgent/ClaudeCode AI_AGENT=claude-code_2-1-322_agent, or CLAUDECODE=1
// AIAgent/Cursor CURSOR_AGENT=1
// AIAgent/Other AI_AGENT set to a value that isn't recognised
func GetAIAgent(e Environment) string {
aiAgentValue := strings.TrimSpace(e.Getenv(aiAgentEnvVar))

name := nameFromAIAgentValue(aiAgentValue)
if name == "" {
// Also covers an agent that sets AI_AGENT in a format that isn't recognised.
name = nameFromVendorVariables(e)
}
if name == "" && aiAgentValue != "" {
name = "Other"
}
if name == "" {
return ""
}

return "AIAgent/" + name
}

func nameFromVendorVariables(e Environment) string {
for _, agent := range knownAIAgents {
for _, rule := range agent.rules {
value := e.Getenv(rule.variable)
if value == "" {
continue
}
if rule.value == "" || strings.EqualFold(value, rule.value) {
return agent.name
}
}
}
return ""
}

func nameFromAIAgentValue(value string) string {
if value == "" {
return ""
}
normalised := strings.Join(strings.Fields(strings.ReplaceAll(strings.ToLower(value), "_", " ")), "-")
for _, p := range aiAgentValuePrefixes {
if strings.HasPrefix(normalised, p.prefix) {
return p.name
}
}
return ""
}
149 changes: 149 additions & 0 deletions pkg/services/api/ai_agent_provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package api_test

import (
"strings"
"testing"

"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/services/api"
"github.com/stretchr/testify/require"
)

func TestGetAIAgent_None(t *testing.T) {
e := api.NewMockEnvironment()
require.Equal(t, "", api.GetAIAgent(e))
}

func TestGetAIAgent_VendorVariables(t *testing.T) {
cases := []struct {
variable string
value string
expected string
}{
{"CLAUDECODE", "1", "AIAgent/ClaudeCode"},
{"CLAUDE_CODE_ENTRYPOINT", "cli", "AIAgent/ClaudeCode"},
{"CURSOR_AGENT", "1", "AIAgent/Cursor"},
{"CURSOR_EXTENSION_HOST_ROLE", "agent-exec", "AIAgent/Cursor"},
{"CODEX_SANDBOX", "seatbelt", "AIAgent/Codex"},
{"CODEX_THREAD_ID", "abc", "AIAgent/Codex"},
{"GEMINI_CLI", "1", "AIAgent/GeminiCLI"},
{"COPILOT_CLI", "1", "AIAgent/GitHubCopilot"},
{"OPENCODE", "1", "AIAgent/OpenCode"},
{"OPENCODE_CLIENT", "cli", "AIAgent/OpenCode"},
{"ANTIGRAVITY_AGENT", "1", "AIAgent/Antigravity"},
{"GOOSE_TERMINAL", "1", "AIAgent/Goose"},
{"AGENT", "goose", "AIAgent/Goose"},
{"AGENT", "amp", "AIAgent/Amp"},
{"CLINE_ACTIVE", "true", "AIAgent/Cline"},
{"ROO_CODE_TASK_ID", "abc", "AIAgent/RooCode"},
{"AUGMENT_AGENT", "1", "AIAgent/Augment"},
{"QWEN_CODE", "1", "AIAgent/QwenCode"},
{"DROID_CLI", "1", "AIAgent/FactoryDroid"},
{"KIMI_CLI", "1", "AIAgent/KimiCLI"},
{"TRAE_AI_SHELL_ID", "abc", "AIAgent/Trae"},
{"OR_APP_NAME", "aider", "AIAgent/Aider"},
{"OR_APP_NAME", "OpenHands", "AIAgent/OpenHands"},
}

for _, c := range cases {
t.Run(c.variable+"="+c.value, func(t *testing.T) {
e := api.NewMockEnvironment()
e.Setenv(c.variable, c.value)
require.Equal(t, c.expected, api.GetAIAgent(e))
})
}
}

func TestGetAIAgent_ValueRulesRequireMatch(t *testing.T) {
cases := map[string]string{
"CURSOR_EXTENSION_HOST_ROLE": "user",
"OPENCODE": "0",
"AGENT": "teamcity-agent-01",
"OR_APP_NAME": "SomethingElse",
}

for variable, value := range cases {
t.Run(variable, func(t *testing.T) {
e := api.NewMockEnvironment()
e.Setenv(variable, value)
require.Equal(t, "", api.GetAIAgent(e))
})
}
}

func TestGetAIAgent_ExcludesNoisyVariables(t *testing.T) {
for _, variable := range []string{"CURSOR_TRACE_ID", "COPILOT_GITHUB_TOKEN", "GOOSE_PROVIDER", "REPL_ID", "ZED_TERM"} {
t.Run(variable, func(t *testing.T) {
e := api.NewMockEnvironment()
e.Setenv(variable, "1")
require.Equal(t, "", api.GetAIAgent(e))
})
}
}

func TestGetAIAgent_IsDeterministicWhenAgentsAreNested(t *testing.T) {
e := api.NewMockEnvironment()
e.Setenv("CURSOR_AGENT", "1")
e.Setenv("CLAUDECODE", "1")
for i := 0; i < 50; i++ {
require.Equal(t, "AIAgent/ClaudeCode", api.GetAIAgent(e))
}
}

func TestGetAIAgent_AIAgentVariable(t *testing.T) {
cases := []struct {
value string
expected string
}{
{"claude-code_2-1-322_agent", "AIAgent/ClaudeCode"},
{"claude-code", "AIAgent/ClaudeCode"},
{"Claude-Code", "AIAgent/ClaudeCode"},
{"Claude Code", "AIAgent/ClaudeCode"},
{"cursor-cli", "AIAgent/Cursor"},
{"github_copilot_vscode_agent", "AIAgent/GitHubCopilot"},
{"github-copilot-cli", "AIAgent/GitHubCopilot"},
{"devin@1", "AIAgent/Devin"},
{"custom-agent@2.0", "AIAgent/Other"},
}

for _, c := range cases {
t.Run(c.value, func(t *testing.T) {
e := api.NewMockEnvironment()
e.Setenv("AI_AGENT", c.value)
require.Equal(t, c.expected, api.GetAIAgent(e))
})
}
}

func TestGetAIAgent_AIAgentTakesPrecedenceOverVendorVariables(t *testing.T) {
e := api.NewMockEnvironment()
e.Setenv("CURSOR_AGENT", "1")
e.Setenv("AI_AGENT", "claude-code_2-1-322_agent")
require.Equal(t, "AIAgent/ClaudeCode", api.GetAIAgent(e))
}

func TestGetAIAgent_UnrecognisedAIAgentFallsBackToVendorName(t *testing.T) {
e := api.NewMockEnvironment()
e.Setenv("GEMINI_CLI", "1")
e.Setenv("AI_AGENT", "gcli")
require.Equal(t, "AIAgent/GeminiCLI", api.GetAIAgent(e))
}

func TestGetAIAgent_NeverIncludesRawAIAgentValue(t *testing.T) {
e := api.NewMockEnvironment()
e.Setenv("AI_AGENT", " evil) agent\r\nX-Injected: 1 ")
require.Equal(t, "AIAgent/Other", api.GetAIAgent(e))

e.Setenv("AI_AGENT", strings.Repeat("a", 200))
require.Equal(t, "AIAgent/Other", api.GetAIAgent(e))

e.Setenv("AI_AGENT", " \t ")
require.Equal(t, "", api.GetAIAgent(e))
}

func TestGetAutomationEnvironment_IgnoresAIAgents(t *testing.T) {
e := api.NewMockEnvironment()
e.Setenv("CLAUDECODE", "1")
e.Setenv("GITHUB_ACTIONS", "true")
require.Equal(t, "GitHubActions", api.GetAutomationEnvironment(e))
require.Equal(t, "AIAgent/ClaudeCode", api.GetAIAgent(e))
}
6 changes: 5 additions & 1 deletion pkg/services/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ func GetUserAgentString(requestingTool string) string {
}
}

automationEnvironment := GetAutomationEnvironment(&OsEnvironment{})
environment := &OsEnvironment{}
automationEnvironment := GetAutomationEnvironment(environment)
if aiAgent := GetAIAgent(environment); aiAgent != "" {
automationEnvironment = automationEnvironment + " " + aiAgent
}

return strings.TrimSpace(fmt.Sprintf("%s/%s (%s; %s) go/%s %s %s", "go-octopusdeploy", version, runtime.GOOS, runtime.GOARCH, runtime.Version(), automationEnvironment, requestingTool))
}
Loading