File size: 7,337 Bytes
de3bbe0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# Copyright 2026 The HuggingFace Team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Detect whether the process is being invoked by an AI coding agent.

Detection is based on environment variables that AI agents set in their shell
sessions. `AI_AGENT` and `AGENT` are treated as a universal standard (any
tool can set its harness id there); the remaining checks are tool-specific and
ordered by priority (first match wins).

The list of known harnesses is maintained on the Hub and exposed at
`{ENDPOINT}/api/agent-harnesses`. We fetch it at most once a day and cache it
locally so the list can be updated without requiring a new client release.

Detection is entirely best-effort: there is no hardcoded list of harnesses. When
the registry cannot be fetched (and no cached copy is available), detection simply
reports "no agent". Any error while fetching/reading the registry is swallowed —
detection must never make a process fail.

More details: https://huggingface.co/docs/hub/agents-overview#register-your-agent-harness
"""

import json
import os
import time
from pathlib import Path
from typing import Optional, TypedDict

from .. import constants
from . import logging


logger = logging.get_logger(__name__)

# Refresh the cached registry at most once every 24 hours.
_REGISTRY_TTL_SECONDS = 24 * 3600

# Short timeout: fetching the registry is best-effort telemetry, never block the caller for long.
_REGISTRY_FETCH_TIMEOUT = 3


class HarnessInfo(TypedDict, total=False):
    """A single harness entry. `envVars` maps an env var name to a match pattern (see `_env_vars_match`)."""

    envVars: dict[str, str]


class Registry(TypedDict):
    """The agent harness registry, as served by `{ENDPOINT}/api/agent-harnesses`."""

    standardEnvVars: list[str]
    harnesses: dict[str, HarnessInfo]


# Empty registry: detection is disabled (no agent ever detected). Used when the
# Hub is unreachable and no cached copy is available.
_EMPTY_REGISTRY: Registry = {"standardEnvVars": [], "harnesses": {}}

# In-process cache of the resolved registry. Populated lazily on first detection.
_registry: Registry | None = None


def detect_agent() -> Optional[str]:
    """Return the id of the detected AI agent harness or `None`.

    Harnesses are checked in registry order; for each one we match its env var
    pattern(s) and, failing that, the standard `AI_AGENT` / `AGENT` vars
    against the harness id. The first match wins. When a standard var is set to
    an unrecognized value, `"unknown"` is returned.
    """
    registry = _get_registry()
    standard_vars = registry.get("standardEnvVars") or []
    harnesses = registry.get("harnesses") or {}

    for harness_id, info in harnesses.items():
        env_vars = (info or {}).get("envVars")
        if env_vars and _env_vars_match(env_vars):
            return harness_id
        for var in standard_vars:
            if os.environ.get(var, "").strip() == harness_id:
                return harness_id

    # No harness matched but a standard var is set => unrecognized agent.
    lowercased_harnesses = {k.lower() for k in harnesses.keys()}
    for var in standard_vars:
        if value := os.environ.get(var, "").strip().lower():
            if value in lowercased_harnesses:
                return value
            return "unknown"

    return None


def is_agent() -> bool:
    """Return `True` if the process is being invoked by an AI coding agent."""
    return detect_agent() is not None


def _env_vars_match(env_vars: dict[str, str]) -> bool:
    """Return `True` if any `(var, pattern)` from the harness matches the environment.

    Supported patterns:
      - `"*"`: the variable is set to any non-empty value
      - `"<value>"`: the variable equals this exact value
    """
    for var, pattern in env_vars.items():
        value = os.environ.get(var)
        if not value:
            continue
        if pattern == "*":
            return True
        if value == pattern:
            return True
    return False


def _get_registry() -> Registry:
    """Return the harness registry, loading (and caching in-process) on first call.

    Best-effort: any unexpected error degrades to an empty registry so detection
    never raises.
    """
    global _registry
    if _registry is None:
        try:
            _registry = _load_registry()
        except Exception:
            logger.debug("Could not resolve agent harnesses registry.", exc_info=True)
            _registry = _EMPTY_REGISTRY
    return _registry


def _load_registry() -> Registry:
    """Resolve the registry from the local cache or the Hub.

    No hardcoded list: if the Hub is unreachable and no cached copy exists, an
    empty registry is returned (i.e. no agent is detected).
    """
    path = constants.AGENT_HARNESSES_PATH

    # 1. Use the cached file if it was refreshed within the last 24 hours.
    if cached := _read_cached_registry(path, max_age=_REGISTRY_TTL_SECONDS):
        return cached

    # 2. Otherwise refresh it from the Hub and persist it for next time.
    if (fetched := _fetch_registry()) is not None:
        _write_cached_registry(path, fetched)
        return fetched

    # 3. Fetch failed: reuse a stale cache if available, else give up (no detection).
    if stale := _read_cached_registry(path, max_age=None):
        return stale
    return _EMPTY_REGISTRY


def _read_cached_registry(path: str, max_age: int | None) -> Registry | None:
    """Return the cached registry, or `None` if missing/stale/unreadable."""
    try:
        if not os.path.exists(path):
            return None
        if max_age is not None and (time.time() - os.path.getmtime(path)) >= max_age:
            return None
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        logger.debug("Could not read cached agent harnesses registry.", exc_info=True)
        return None


def _write_cached_registry(path: str, registry: Registry) -> None:
    try:
        Path(path).parent.mkdir(parents=True, exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            json.dump(registry, f)
    except Exception:
        logger.debug("Could not cache agent harnesses registry.", exc_info=True)


def _fetch_registry() -> Registry | None:
    """Fetch the registry from the Hub. Returns `None` when offline or on any error."""
    if constants.HF_HUB_OFFLINE:
        return None
    try:
        from ._http import get_session

        response = get_session().get(
            f"{constants.ENDPOINT}/api/agent-harnesses",
            timeout=_REGISTRY_FETCH_TIMEOUT,
        )
        response.raise_for_status()
        return response.json()
    except Exception:
        logger.debug("Could not fetch agent harnesses registry from the Hub.", exc_info=True)
        return None