From 3fd35d2aaa41713e635dba437dea257dbca1ad56 Mon Sep 17 00:00:00 2001 From: Seshu Brahma Date: Wed, 12 Aug 2026 16:12:49 -0700 Subject: [PATCH] feat(useragent): Attribute the Kiro IDE agent The Kiro IDE and Kiro CLI are separate products with separate app bundles. Only the CLI exports KIRO_SESSION_ID, so commands driven by the IDE's agent were reported as ordinary human-run commands and its traffic was unattributed. Add AGENTIC_CALLER_KIRO_IDE (AX), keyed on KIRO_AGENT. The variable is scoped to agent activity rather than to the editor being open, following CURSOR_AGENT and ANTIGRAVITY_AGENT. An editor-presence signal such as TERM_PROGRAM=kiro was rejected: the IDE's integrated terminal is also human-driven, so it would attribute a person's typing as agentic and make the metric non-comparable with the other AGENTIC_CALLER_* rows. Kiro must export KIRO_AGENT for this to report, matching how Cursor behaves today. Also expose is_agentic_caller() so callers that adapt behavior for agents (for example, suppressing interactive prompts) use the same signals the User-Agent already reports instead of reimplementing the table. --- .../enhancement-UserAgent-kiro-ide.json | 5 + awscli/botocore/useragent.py | 26 +++++ tests/unit/botocore/test_useragent.py | 98 ++++++++++++++++++- 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 .changes/next-release/enhancement-UserAgent-kiro-ide.json diff --git a/.changes/next-release/enhancement-UserAgent-kiro-ide.json b/.changes/next-release/enhancement-UserAgent-kiro-ide.json new file mode 100644 index 000000000000..2d346d55ec7a --- /dev/null +++ b/.changes/next-release/enhancement-UserAgent-kiro-ide.json @@ -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." +} diff --git a/awscli/botocore/useragent.py b/awscli/botocore/useragent.py index 51fddfb8bf2a..8bb1a737b8d1 100644 --- a/awscli/botocore/useragent.py +++ b/awscli/botocore/useragent.py @@ -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'), @@ -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), ) @@ -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, diff --git a/tests/unit/botocore/test_useragent.py b/tests/unit/botocore/test_useragent.py index 53f884647a1e..ed515d567200 100644 --- a/tests/unit/botocore/test_useragent.py +++ b/tests/unit/botocore/test_useragent.py @@ -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, ) @@ -37,6 +40,7 @@ 'PI_CODING_AGENT', 'COPILOT_CLI', 'CURSOR_AGENT', + 'KIRO_AGENT', ) @@ -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',