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
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-UserAgent-kiro-ide.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "User Agent",
"description": "Add User-Agent attribution for the Kiro IDE agent and expose ``is_agentic_caller`` for callers that adapt behavior for agents."
}
26 changes: 26 additions & 0 deletions awscli/botocore/useragent.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
'AGENTIC_CALLER_PI': 'AU',
'AGENTIC_CALLER_COPILOT_CLI': 'AV',
'AGENTIC_CALLER_CURSOR': 'AW',
'AGENTIC_CALLER_KIRO_IDE': 'AX',
}
_USERAGENT_AGENTIC_CALLER_ENV_VAR_MAPPINGS = (
('CLAUDECODE', 'AGENTIC_CALLER_CLAUDE_CODE', '1'),
Expand All @@ -127,6 +128,10 @@
('PI_CODING_AGENT', 'AGENTIC_CALLER_PI', 'true'),
('COPILOT_CLI', 'AGENTIC_CALLER_COPILOT_CLI', '1'),
('CURSOR_AGENT', 'AGENTIC_CALLER_CURSOR', '1'),
# Kiro IDE, distinct from the Kiro CLI above. The IDE agent drives an
# integrated terminal and does not export KIRO_SESSION_ID, so its traffic
# was previously unattributed.
('KIRO_AGENT', 'AGENTIC_CALLER_KIRO_IDE', None),
)


Expand Down Expand Up @@ -170,6 +175,27 @@ def _agentic_env_var_is_set(env_var, expected_value):
return value == expected_value


def is_agentic_caller():
"""Return True if the current process appears to be driven by a known
agentic caller (e.g. Claude Code, Gemini CLI, Codex).

Detection uses the same environment-variable signals that populate the
``AGENTIC_CALLER_*`` metrics in the User-Agent header, so callers that
want to adapt their behavior for agents stay in sync with what the SDK
already reports.

Every signal in the table is scoped to agent activity rather than to a
host being present, so any match is treated as proof an agent issued the
command.
"""
return any(
_agentic_env_var_is_set(env_var, expected_value)
for env_var, _feature_id, expected_value in (
_USERAGENT_AGENTIC_CALLER_ENV_VAR_MAPPINGS
)
)


def _register_agentic_caller_env_features():
for (
env_var,
Expand Down
98 changes: 97 additions & 1 deletion tests/unit/botocore/test_useragent.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@
import pytest
from botocore import __version__ as botocore_version
from botocore.config import Config
from botocore.context import get_context
from botocore.context import get_context, start_as_current_context
from botocore.useragent import (
_USERAGENT_FEATURE_MAPPINGS,
_register_agentic_caller_env_features,
UserAgentComponent,
UserAgentComponentSizeConfig,
UserAgentString,
is_agentic_caller,
register_feature_id,
sanitize_user_agent_string_component,
)
Expand All @@ -37,6 +40,7 @@
'PI_CODING_AGENT',
'COPILOT_CLI',
'CURSOR_AGENT',
'KIRO_AGENT',
)


Expand Down Expand Up @@ -78,6 +82,98 @@ def test_sanitize_ua_string_component(raw_str, allow_hash, expected_str):
assert actual_str == expected_str


def test_is_agentic_caller_false_when_no_env_set():
# clear_agentic_caller_env fixture unsets all agentic caller env vars.
assert is_agentic_caller() is False


@pytest.mark.parametrize(
'env_var, value',
[
('CLAUDECODE', '1'),
('GEMINI_CLI', '1'),
('CODEX_THREAD_ID', 'some-thread-id'),
('KIRO_SESSION_ID', 'some-session-id'),
('OPENCODE', 'anything'),
('CURSOR_AGENT', '1'),
('PI_CODING_AGENT', 'true'),
],
)
def test_is_agentic_caller_true_when_env_set(monkeypatch, env_var, value):
monkeypatch.setenv(env_var, value)
assert is_agentic_caller() is True


def test_is_agentic_caller_false_when_value_does_not_match(monkeypatch):
# CLAUDECODE only counts when it equals '1'.
monkeypatch.setenv('CLAUDECODE', '0')
assert is_agentic_caller() is False


def test_kiro_ide_is_attributed_in_user_agent(monkeypatch):
# KIRO_AGENT identifies the Kiro IDE agent, which does not export
# KIRO_SESSION_ID. Its traffic must still be attributed.
monkeypatch.setenv('KIRO_AGENT', '1')
kiro_ide = _USERAGENT_FEATURE_MAPPINGS['AGENTIC_CALLER_KIRO_IDE']
with start_as_current_context():
_register_agentic_caller_env_features()
assert kiro_ide in get_context().features


def test_kiro_ide_uses_a_distinct_feature_id_from_kiro_cli(monkeypatch):
assert (
_USERAGENT_FEATURE_MAPPINGS['AGENTIC_CALLER_KIRO_IDE']
!= _USERAGENT_FEATURE_MAPPINGS['AGENTIC_CALLER_KIRO']
)


def test_kiro_agent_implies_agentic_caller(monkeypatch):
# KIRO_AGENT is scoped to agent activity, not to the editor being open
# (that is why it is not TERM_PROGRAM), so it counts as an agentic caller
# like CURSOR_AGENT and ANTIGRAVITY_AGENT do.
monkeypatch.setenv('KIRO_AGENT', '1')
assert is_agentic_caller() is True


def test_kiro_cli_inside_kiro_ide_reports_both_products(monkeypatch):
# Running the Kiro CLI from the Kiro IDE's terminal sets both markers.
# They must land as two distinct metrics so the two products stay
# separable, rather than one masking the other.
monkeypatch.setenv('KIRO_AGENT', '1')
monkeypatch.setenv('KIRO_SESSION_ID', 'some-session-id')
with start_as_current_context():
_register_agentic_caller_env_features()
features = get_context().features
assert _USERAGENT_FEATURE_MAPPINGS['AGENTIC_CALLER_KIRO_IDE'] in features
assert _USERAGENT_FEATURE_MAPPINGS['AGENTIC_CALLER_KIRO'] in features
assert is_agentic_caller() is True


def test_empty_kiro_agent_is_not_attributed(monkeypatch):
# KIRO_AGENT accepts any non-empty value, so '' must not match.
monkeypatch.setenv('KIRO_AGENT', '')
kiro_ide = _USERAGENT_FEATURE_MAPPINGS['AGENTIC_CALLER_KIRO_IDE']
with start_as_current_context():
_register_agentic_caller_env_features()
assert kiro_ide not in get_context().features


def test_kiro_ide_not_attributed_when_unset(monkeypatch):
# Running in any other terminal or editor: no IDE attribution. Guards
# against the marker being inferred from something like TERM_PROGRAM.
monkeypatch.setenv('TERM_PROGRAM', 'iTerm.app')
kiro_ide = _USERAGENT_FEATURE_MAPPINGS['AGENTIC_CALLER_KIRO_IDE']
with start_as_current_context():
_register_agentic_caller_env_features()
assert kiro_ide not in get_context().features


def test_is_agentic_caller_false_when_env_empty(monkeypatch):
# An env var expecting any non-empty value must not match ''.
monkeypatch.setenv('CODEX_THREAD_ID', '')
assert is_agentic_caller() is False


def test_basic_user_agent_string():
ua = UserAgentString(
platform_name='linux',
Expand Down
Loading