From 4f51b6d695135b8107bfb2c4666c20033d707be2 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Thu, 17 Sep 2026 23:33:42 +0200 Subject: [PATCH] feat(plugins): Hermes agent plugin --- .github/scripts/changelog-for-release.test.sh | 18 + .github/scripts/changelog-top-version.test.sh | 6 + .github/workflows/ci.yml | 38 + AGENTS.md | 7 +- README.md | 4 +- docs/concepts/content-capture-modes.md | 14 +- docs/concepts/tags-and-metadata.md | 10 + docs/development.md | 20 +- llms.txt | 3 + mise.toml | 33 +- plugins/README.md | 7 +- .../hermes/.agents/skills/e2e-test/SKILL.md | 126 ++ .../e2e-test/scripts/check-generations.py | 88 ++ .../skills/e2e-test/scripts/check-install.py | 34 + .../skills/e2e-test/scripts/mock-provider.py | 203 +++ .../skills/e2e-test/scripts/otlp-sink.py | 78 ++ .../e2e-test/scripts/probe-plugin/__init__.py | 80 ++ .../e2e-test/scripts/probe-plugin/plugin.yaml | 15 + .../skills/e2e-test/scripts/run-hermes.sh | 120 ++ .../skills/e2e-test/scripts/run-mock.sh | 76 ++ .../.agents/skills/e2e-test/scripts/setup.sh | 62 + .../skills/e2e-test/scripts/show-hooks.py | 60 + .../skills/e2e-test/scripts/show-spans.py | 52 + .../skills/e2e-test/scripts/verify-backend.sh | 66 + plugins/hermes/.gitignore | 4 + plugins/hermes/CHANGELOG.md | 70 + plugins/hermes/README.md | 116 ++ plugins/hermes/llms.txt | 97 ++ plugins/hermes/pyproject.toml | 78 ++ plugins/hermes/scripts/check-package.py | 129 ++ plugins/hermes/scripts/run-check.sh | 28 + .../src/grafana_agento11y_hermes/__init__.py | 82 ++ .../src/grafana_agento11y_hermes/_client.py | 192 +++ .../src/grafana_agento11y_hermes/_coerce.py | 70 + .../src/grafana_agento11y_hermes/_compat.py | 108 ++ .../src/grafana_agento11y_hermes/_config.py | 175 +++ .../src/grafana_agento11y_hermes/_errors.py | 36 + .../src/grafana_agento11y_hermes/_hooks.py | 1135 ++++++++++++++++ .../src/grafana_agento11y_hermes/_otel.py | 256 ++++ .../src/grafana_agento11y_hermes/_redact.py | 151 +++ .../src/grafana_agento11y_hermes/_request.py | 298 +++++ .../src/grafana_agento11y_hermes/_state.py | 355 +++++ .../src/grafana_agento11y_hermes/_tags.py | 197 +++ .../src/grafana_agento11y_hermes/_version.py | 44 + plugins/hermes/tests/__init__.py | 0 plugins/hermes/tests/conftest.py | 218 +++ plugins/hermes/tests/test_coerce.py | 109 ++ plugins/hermes/tests/test_compat.py | 110 ++ plugins/hermes/tests/test_config.py | 223 ++++ plugins/hermes/tests/test_fail_open.py | 236 ++++ plugins/hermes/tests/test_flush.py | 146 +++ plugins/hermes/tests/test_hook_edges.py | 169 +++ plugins/hermes/tests/test_hooks.py | 849 ++++++++++++ .../hermes/tests/test_hooks_request_scoped.py | 1166 +++++++++++++++++ plugins/hermes/tests/test_message_mapping.py | 322 +++++ plugins/hermes/tests/test_otel_autosetup.py | 324 +++++ plugins/hermes/tests/test_package_checks.py | 89 ++ plugins/hermes/tests/test_privacy.py | 185 +++ plugins/hermes/tests/test_redact.py | 147 +++ plugins/hermes/tests/test_register.py | 32 + plugins/hermes/tests/test_request_facts.py | 301 +++++ plugins/hermes/tests/test_tags.py | 390 ++++++ plugins/hermes/tests/test_version.py | 60 + plugins/hermes/uv.lock | 788 +++++++++++ redaction/README.md | 12 +- 65 files changed, 10697 insertions(+), 20 deletions(-) create mode 100644 plugins/hermes/.agents/skills/e2e-test/SKILL.md create mode 100644 plugins/hermes/.agents/skills/e2e-test/scripts/check-generations.py create mode 100644 plugins/hermes/.agents/skills/e2e-test/scripts/check-install.py create mode 100644 plugins/hermes/.agents/skills/e2e-test/scripts/mock-provider.py create mode 100644 plugins/hermes/.agents/skills/e2e-test/scripts/otlp-sink.py create mode 100644 plugins/hermes/.agents/skills/e2e-test/scripts/probe-plugin/__init__.py create mode 100644 plugins/hermes/.agents/skills/e2e-test/scripts/probe-plugin/plugin.yaml create mode 100755 plugins/hermes/.agents/skills/e2e-test/scripts/run-hermes.sh create mode 100755 plugins/hermes/.agents/skills/e2e-test/scripts/run-mock.sh create mode 100755 plugins/hermes/.agents/skills/e2e-test/scripts/setup.sh create mode 100644 plugins/hermes/.agents/skills/e2e-test/scripts/show-hooks.py create mode 100644 plugins/hermes/.agents/skills/e2e-test/scripts/show-spans.py create mode 100755 plugins/hermes/.agents/skills/e2e-test/scripts/verify-backend.sh create mode 100644 plugins/hermes/.gitignore create mode 100644 plugins/hermes/CHANGELOG.md create mode 100644 plugins/hermes/README.md create mode 100644 plugins/hermes/llms.txt create mode 100644 plugins/hermes/pyproject.toml create mode 100644 plugins/hermes/scripts/check-package.py create mode 100644 plugins/hermes/scripts/run-check.sh create mode 100644 plugins/hermes/src/grafana_agento11y_hermes/__init__.py create mode 100644 plugins/hermes/src/grafana_agento11y_hermes/_client.py create mode 100644 plugins/hermes/src/grafana_agento11y_hermes/_coerce.py create mode 100644 plugins/hermes/src/grafana_agento11y_hermes/_compat.py create mode 100644 plugins/hermes/src/grafana_agento11y_hermes/_config.py create mode 100644 plugins/hermes/src/grafana_agento11y_hermes/_errors.py create mode 100644 plugins/hermes/src/grafana_agento11y_hermes/_hooks.py create mode 100644 plugins/hermes/src/grafana_agento11y_hermes/_otel.py create mode 100644 plugins/hermes/src/grafana_agento11y_hermes/_redact.py create mode 100644 plugins/hermes/src/grafana_agento11y_hermes/_request.py create mode 100644 plugins/hermes/src/grafana_agento11y_hermes/_state.py create mode 100644 plugins/hermes/src/grafana_agento11y_hermes/_tags.py create mode 100644 plugins/hermes/src/grafana_agento11y_hermes/_version.py create mode 100644 plugins/hermes/tests/__init__.py create mode 100644 plugins/hermes/tests/conftest.py create mode 100644 plugins/hermes/tests/test_coerce.py create mode 100644 plugins/hermes/tests/test_compat.py create mode 100644 plugins/hermes/tests/test_config.py create mode 100644 plugins/hermes/tests/test_fail_open.py create mode 100644 plugins/hermes/tests/test_flush.py create mode 100644 plugins/hermes/tests/test_hook_edges.py create mode 100644 plugins/hermes/tests/test_hooks.py create mode 100644 plugins/hermes/tests/test_hooks_request_scoped.py create mode 100644 plugins/hermes/tests/test_message_mapping.py create mode 100644 plugins/hermes/tests/test_otel_autosetup.py create mode 100644 plugins/hermes/tests/test_package_checks.py create mode 100644 plugins/hermes/tests/test_privacy.py create mode 100644 plugins/hermes/tests/test_redact.py create mode 100644 plugins/hermes/tests/test_register.py create mode 100644 plugins/hermes/tests/test_request_facts.py create mode 100644 plugins/hermes/tests/test_tags.py create mode 100644 plugins/hermes/tests/test_version.py create mode 100644 plugins/hermes/uv.lock diff --git a/.github/scripts/changelog-for-release.test.sh b/.github/scripts/changelog-for-release.test.sh index c43733b04..c02519ccb 100755 --- a/.github/scripts/changelog-for-release.test.sh +++ b/.github/scripts/changelog-for-release.test.sh @@ -125,5 +125,23 @@ assert_contains 'frameworks path included' '- **frameworks/langchain**: new adap assert_not_contains 'path outside the SDK excluded' 'outside the python tree' "$out" assert_not_contains 'seed before previous tag excluded' 'seed core' "$out" +mkdir -p plugins/hermes +printf 'hermes\n' > plugins/hermes/file.txt +git add plugins/hermes/file.txt +git commit -q -m 'feat(hermes): import plugin' +git tag sdk-python/v9.0.0 +out=$("$CHANGELOG" 0.10.1 plugins/hermes plugins/hermes) +assert_contains 'first Hermes release includes import without a baseline tag' '- **hermes**: import plugin' "$out" +assert_not_contains 'Hermes excludes SDK changes' 'core export change' "$out" +assert_not_contains 'Hermes excludes other plugin changes' 'repair login' "$out" + +git tag plugins/hermes/v0.10.1 +printf 'fix\n' >> plugins/hermes/file.txt +git add plugins/hermes/file.txt +git commit -q -m 'fix(hermes): repair export' +out=$("$CHANGELOG" 0.10.2 plugins/hermes plugins/hermes) +assert_contains 'Hermes uses its own previous tag' '- **hermes**: repair export' "$out" +assert_not_contains 'previous Hermes release excluded' 'import plugin' "$out" + echo "passed: ${pass}, failed: ${fail}" [[ $fail -eq 0 ]] diff --git a/.github/scripts/changelog-top-version.test.sh b/.github/scripts/changelog-top-version.test.sh index 1856b1c94..b60c6aeee 100755 --- a/.github/scripts/changelog-top-version.test.sh +++ b/.github/scripts/changelog-top-version.test.sh @@ -39,6 +39,12 @@ assert_eq 'first of several sections wins' '1.2.3' "$("$TOP_VERSION" "$FILE")" printf '# Changelog\n\n## [Unreleased]\n\n## [0.9.0] - 2026-01-01\n\n- c\n' > "$FILE" assert_eq 'non-semver heading skipped' '0.9.0' "$("$TOP_VERSION" "$FILE")" +printf '# Changelog\n\n## [Unreleased]\n\n## [0.10.0](https://example.com/releases/0.10.0) - 2026-09-17\n' > "$FILE" +assert_eq 'Hermes linked version heading' '0.10.0' "$("$TOP_VERSION" "$FILE")" + +printf '# Changelog\n\n## [0.10.1] - 2026-09-18\n\n## [0.10.0](https://example.com/releases/0.10.0) - 2026-09-17\n' > "$FILE" +assert_eq 'plain version heading precedes linked heading' '0.10.1' "$("$TOP_VERSION" "$FILE")" + assert_eq 'missing file prints nothing' '' "$("$TOP_VERSION" "${TMP}/absent.md" 2>/dev/null)" assert_eq 'missing file still exits 0' 0 "$("$TOP_VERSION" "${TMP}/absent.md" >/dev/null 2>&1; echo $?)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1c53679c..7e128f3aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,6 +253,44 @@ jobs: - name: Build opencode plugin run: pnpm --filter @grafana/agento11y-opencode run build + hermes-checks: + name: Hermes lint, types, and artifacts + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: '3.11' + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + install: false + - run: mise run lint:py:plugin-hermes + - run: mise run typecheck:py:plugin-hermes + - run: mise run build:py:plugin-hermes + + hermes-test: + name: Hermes Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python-version: ['3.11', '3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: ${{ matrix.python-version }} + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + install: false + - run: mise run test:py:plugin-hermes ${{ matrix.python-version }} + python-lint: name: Python Lint runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index fc445cb77..791066b2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,13 +63,14 @@ Three steps run per release, and none of them creates a tag on the release PR: ## Plugins layout -`plugins/` ships two flavors of launcher. They are not uniform; don't assume they are. +`plugins/` contains launchers and in-process plugins. They are not uniform; don't assume they are. | Plugin dir | What it actually is | |------------|---------------------| | `plugins/agento11y/` | The shared Go binary, installed as `agento11y` (`brew install grafana/grafana/agento11y`; the old `sigil` name still works but will be removed). Has subcommands `claude`, `codex`, `copilot`, `cursor`, `opencode`, `pi`, `vibe`, `login`, `doctor`, `local`, `history`, `skills`, `help`. This is also what consumers use. | | `plugins/claude-code/`, `plugins/codex/`, `plugins/copilot/`, `plugins/cursor/` | Thin glue: hook scripts and READMEs that wire the host agent to the shared `agento11y` binary. No independent code paths. | | `plugins/opencode/` | Independent npm package `@grafana/agento11y-opencode`. Runs in-process inside opencode through its TypeScript plugin API; `agento11y opencode` installs and launches it. | +| `plugins/hermes/` | Independent Python package `grafana-agento11y-hermes`. Runs in-process through Hermes's `agento11y` plugin entry point. No shared launcher/login/config/local mode or release-table registration. See `plugins/hermes/README.md` for setup and release limitations. | | `plugins/pi/` | Independent npm package `@grafana/agento11y-pi`. Runs in-process inside pi; `agento11y pi` installs and launches it. | | `plugins/vibe/` | README only. `agento11y vibe` upserts three `[[hooks]]` entries into `hooks.toml` under `$VIBE_HOME` (default `~/.vibe`) and sets `VIBE_ENABLE_EXPERIMENTAL_HOOKS=true` on the child, which only a vibe below 2.21.0 needs. Vibe 2.21.0 renamed all three hook types, so the install path picks the spelling from `vibe --version` and the hook dispatcher answers to both. See `internal/agents/vibe/version.go`. | @@ -120,4 +121,6 @@ test_home=$(mktemp -d) GOPATH="$go_path" GOCACHE="$go_cache" TMPDIR=/tmp GOWORK=off "$go_root/bin/go" test ./...) ``` -`mise run check` is the full local CI gate: lint + typecheck + proto-drift + redaction-drift + every SDK suite. For a focused change, run the matching narrow task (e.g. `mise run test:py:sdk-langgraph`); the full gate is slow. +Hermes uses `format:py:plugin-hermes`, `lint:py:plugin-hermes`, `typecheck:py:plugin-hermes`, `test:py:plugin-hermes`, and `build:py:plugin-hermes`. The build validates wheel and source-distribution artifacts. CI tests Python 3.11–3.14 with branch coverage and a 99% minimum. Optional real-Hermes tests require an explicit loopback provider and both telemetry channels routed locally or disabled; inspect the plugin's e2e skill and scripts first. + +`mise run check` is the full local CI gate: lint + typecheck + proto-drift + redaction-drift + every SDK suite + Hermes artifact validation. For a focused change, run the matching narrow task (e.g. `mise run test:py:sdk-langgraph`); the full gate is slow. diff --git a/README.md b/README.md index 2e056103d..828afaa80 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ Capture sessions from the coding agents you already use — Cursor, Claude Code, Codex, Copilot CLI, OpenCode, Pi, Vibe, and others — so you can observe usage, cost, tokens, and tools across all of them in one place. -Install `agento11y`: +For Hermes, use its separate [Python plugin guide](plugins/hermes/README.md). The shared launcher does not support Hermes. + +For the other coding agents, install `agento11y`: ```sh # Linux or macOS diff --git a/docs/concepts/content-capture-modes.md b/docs/concepts/content-capture-modes.md index 5e787d9d9..4fc84037c 100644 --- a/docs/concepts/content-capture-modes.md +++ b/docs/concepts/content-capture-modes.md @@ -42,10 +42,12 @@ The default differs between SDK clients and coding-agent plugins. | Surface | Default mode | | --- | --- | | Core SDK client (Go, Python, JS/TS, Java, .NET) | `no_tool_content`. Generation content is captured; tool-execution arguments and results stay out of spans. | -| Coding-agent plugins (shared `agento11y` binary, `@grafana/agento11y-pi`, `@grafana/agento11y-opencode`) | `metadata_only`. Coding-agent sessions usually run on shared machines, so the plugins ship metadata-only by default. | +| Coding-agent plugins (shared `agento11y` binary, `@grafana/agento11y-pi`, `@grafana/agento11y-opencode`, `grafana-agento11y-hermes`) | `metadata_only`. Coding-agent sessions usually run on shared machines, so the plugins ship metadata-only by default. | `default` at the client level resolves to `no_tool_content`. To get full content on a core SDK client, set `contentCapture: 'full'` (or the language equivalent) explicitly. +These Hermes defaults apply to source installations. Published PyPI `grafana-agento11y-hermes` `0.10.0` defaults to full content without shared secret redaction. See the [Hermes installation guide](../../plugins/hermes/README.md#install). + ## Resolution precedence The SDK resolves capture mode differently by recording type and language. @@ -76,9 +78,9 @@ Per-language READMEs include code examples: - Java: [`java/README.md`](../../java/README.md) - .NET: [`dotnet/README.md`](../../dotnet/README.md) -For coding-agent plugins, the relevant env var is `AGENTO11Y_CONTENT_CAPTURE_MODE`. All plugins (the shared `agento11y` binary used by Claude Code, Codex, Copilot, Cursor, and Vibe; Pi via `@grafana/agento11y-pi`; OpenCode via `@grafana/agento11y-opencode`) accept `full`, `no_tool_content`, `metadata_only`, and `full_with_metadata_spans`. `default` is accepted as an alias for `metadata_only` so plugins match the Go envconfig resolver rather than the JS SDK's client-level default of `no_tool_content`. +For coding-agent plugins, the relevant env var is `AGENTO11Y_CONTENT_CAPTURE_MODE`. All plugins (the shared `agento11y` binary used by Claude Code, Codex, Copilot, Cursor, and Vibe; Pi via `@grafana/agento11y-pi`; OpenCode via `@grafana/agento11y-opencode`; Hermes via `grafana-agento11y-hermes`) accept `full`, `no_tool_content`, `metadata_only`, and `full_with_metadata_spans`. `default` is accepted as an alias for `metadata_only` so plugins match the Go envconfig resolver rather than the JS SDK's client-level default of `no_tool_content`. -Unknown values fall back to `metadata_only` with a warning in the plugin log. A plugin can still export less than the SDK allows. For example, an adapter may drop a field if the host agent does not pass it through. +Unknown values fall back to `metadata_only`. The launchers, Pi, and OpenCode log a warning; Hermes falls back silently. A plugin can still export less than the SDK allows. For example, an adapter may drop a field if the host agent does not pass it through. ## Secret redaction in the plugins @@ -88,11 +90,13 @@ A plugin redacts known secret formats out of every content field it exports: use Set `AGENTO11Y_REDACT_INPUT_MESSAGES=false` in `~/.config/agento11y/config.env` or the environment to export prompt text without redaction. The flag covers the prompt only: every other field stays redacted, and message structure, roles, token counts, tags, and IDs do not change. An unrecognised value keeps redaction on, so a typo cannot disable it. +For Hermes, set variables in Hermes's environment or its `.env`, which overrides shell exports. The plugin does not read the shared launcher's config file. Prompt redaction defaults to on; the same opt-out applies. Hermes also sanitizes tool-execution spans, which do not pass through the generation sanitizer. Payload limits and upstream request clipping can reduce content further. Hermes also redacts secret-pattern matches in hook-derived IDs and metadata. Custom `AGENTO11Y_TAGS` values are not sanitized. + ### Strength per field There are two pattern tiers. Tier 1 is high-confidence secret formats (`glc_…`, `AKIA…`, a PEM block, a connection string). Tier 2 is the key/value heuristics (`PASSWORD=…`, `"token": "…"`), which catch a secret with no recognisable format but also fire on ordinary text. -Every plugin applies the same tier per field, and it is the tier the SDKs' generation sanitizer applies: +Every plugin applies the same tier per content field as the SDKs' generation sanitizer: | Field | Tier | Why | | --- | --- | --- | @@ -105,7 +109,7 @@ Every plugin applies the same tier per field, and it is the tier the SDKs' gener Tier 2 on a prompt has a real cost: `sort key: name` is exported as `sort key: [REDACTED:env-secret-value]`, because the heuristic cannot tell that `key:` is part of a sentence. Turn prompt redaction off with `AGENTO11Y_REDACT_INPUT_MESSAGES=false` if the prompt text matters more than the coverage. Tier 2 is kept off prose for that reason, and a secret a model repeats in prose is still caught by tier 1 as long as it has a known format. -On a tool payload that decodes as JSON, the shared `agento11y` binary also redacts a value under a secret-looking key (`authorization`, `cookie`, `client_secret`), which the tier 2 key list does not cover. The OpenCode and Pi plugins do not: they redact the encoded JSON as text, so they catch only the key names in the tier 2 patterns. +On a tool payload that decodes as JSON, the shared `agento11y` binary also redacts a value under a secret-looking key (`authorization`, `cookie`, `client_secret`), which the tier 2 key list does not cover. The SDK-based OpenCode, Pi, and Hermes sanitizers do not: they redact the encoded JSON as text, so they catch only the key names in the tier 2 patterns. ## Related diff --git a/docs/concepts/tags-and-metadata.md b/docs/concepts/tags-and-metadata.md index 1e18881a8..294f1cb51 100644 --- a/docs/concepts/tags-and-metadata.md +++ b/docs/concepts/tags-and-metadata.md @@ -140,6 +140,12 @@ The coding-agent plugins (claude-code, codex, copilot, cursor, opencode, pi, vib Launchers also set a few keys specific to one host, so this table is not the full list of what arrives on a generation. +### Hermes tags + +The [Hermes plugin](../../plugins/hermes/README.md) does not use the shared launcher. It emits no automatic `cwd` and no unconditional `git.branch`. The launcher built-ins above do not apply. Automatic `user`, `repo`, and `git.branch` client tags require the switches below. Explicit `AGENTO11Y_TAGS` values win over automatically resolved values. + +Hermes resolves the user from `AGENTO11Y_USER_ID`, then the operating-system account; it has no signed-in host-account lookup. Its process-wide client freezes automatic values at initialization, unlike a launcher invocation or a per-session client. Set the switches in Hermes's environment, not the shared launcher's config. `agento11y login` and `doctor` do not configure or diagnose Hermes. + ## Opt-in automatic tags (`AGENTO11Y_AUTO_CODING_AGENT_TAGS`) The built-in tags above are per-generation tags, so they reach the Agent Observability UI but never become metric labels. `AGENTO11Y_AUTO_CODING_AGENT_TAGS` resolves the same kind of session facts and attaches them as **client tags** instead, which is the one mechanism that does reach OTel metrics. That is what lets the Usage and Cost view filter and break down by user, repository, or branch. It is a coding-agent-plugin feature; the SDKs have nothing like it. @@ -192,6 +198,8 @@ Enabling these names is a deliberate trade. Read this first: - `repo` and `user` are usually bounded per organization. `branch` is not. Set `AGENTO11Y_AUTO_CODING_AGENT_TAGS_NAMES=user,repo` first and add `branch` only if you need per-branch cost. - In the pi and opencode plugins the client is built once per session, so their metric labels freeze at session start. A checkout that changes mid-session keeps the label it started with. The hook-based agents (claude-code, codex, copilot, cursor, vibe) build a client per invocation and follow the checkout. +For Hermes, automatic tags use the same opt-in and allowlist contract, but unsupported names and an inactive allowlist are currently ignored without logging. No per-generation branch override is added. Removing content with `metadata_only` does not remove tags; enabling user or repository labels still exposes those values. + ## Built-in metadata from the agent launchers Metadata is exported but never turned into a metric label, so launchers use it for numbers and for keys with too many distinct values to be a tag. @@ -209,6 +217,8 @@ Codex and copilot also add their own `codex.*` and `copilot.*` keys, so this tab | `opencode.parent_session_id` | Session id of the run that spawned this subagent session. On every subagent generation, including one whose parent turn could not be named. | opencode | | `opencode.child_session_id` | Subagent's own session id. Present when its turns were reparented onto the spawning conversation, where `conversation_id` names the root session of the subagent chain instead. | opencode | +Hermes exports host facts under `hermes.*` metadata, including `hermes.request_facts_reused` when it reuses cached request fields. These facts are generation metadata, not client tags or metric labels. Tool names and sampling parameters may remain visible in metadata-only mode; request text and tool schemas do not. See the [Hermes README](../../plugins/hermes/README.md) for clipping and cache limitations. + ## See also - [Content Capture Modes](content-capture-modes.md) — which content fields ship. Content capture does not strip `tags` or `metadata`; both are always exported. diff --git a/docs/development.md b/docs/development.md index 9dba74166..cb2e2f7c9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,6 +2,24 @@ Notes for contributors working in this repo. +## Hermes plugin + +[`plugins/hermes`](../plugins/hermes/README.md) is an independent Python package. There is no release-table registration or publishing workflow. Install in Hermes's Python environment, not through the shared launcher. + +Run these tasks from the repository root: + +```sh +mise run format:py:plugin-hermes +mise run lint:py:plugin-hermes +mise run typecheck:py:plugin-hermes +mise run test:py:plugin-hermes +mise run build:py:plugin-hermes +``` + +The build task validates the wheel and source distribution, including package identity, version, dependencies, and entry point. Root `mise run check` includes artifact validation. CI tests Python 3.11, 3.12, 3.13, and 3.14 with branch coverage enabled and a 99% minimum. Keep the plugin's `uv.lock` synchronized with dependencies. + +Tests must run without inherited Cloud/provider credentials or personal configuration. Real-Hermes tests are optional and separate from the normal checks. Read the [e2e skill](../plugins/hermes/.agents/skills/e2e-test/SKILL.md) and inspect scripts before running them. Use an explicit loopback model provider. Route both telemetry channels to loopback receivers, or disable unused channels. A local OTLP sink alone does not prevent paid model calls. + ## Regenerating protobuf stubs The proto lives at [`proto/agento11y/v1/generation_ingest.proto`](../proto/agento11y/v1/generation_ingest.proto). After editing it, regenerate every language's stubs from the repo root: @@ -78,7 +96,7 @@ That writes five files: | Output | Consumer | | --- | --- | | `go/agento11y/redaction_patterns_gen.go` | Go SDK | -| `python/agento11y/_redaction_patterns.py` | Python SDK | +| `python/agento11y/_redaction_patterns.py` | Python SDK; reused by the Hermes plugin's SDK redaction | | `js/src/redaction-patterns.generated.ts` | JS SDK and, through `@grafana/agento11y-core`, the opencode plugin | | `dotnet/src/Grafana.Agento11y/RedactionPatterns.g.cs` | .NET SDK | | `plugins/agento11y/internal/redact/patterns_gen.go` | shared `agento11y` binary | diff --git a/llms.txt b/llms.txt index 29e9cdba6..63c114231 100644 --- a/llms.txt +++ b/llms.txt @@ -69,6 +69,8 @@ printf '%s' ':' | base64 | tr -d '\n' The user wants Grafana Agent observability to capture sessions from their coding agent. They do not write app code for this; they install a plugin and configure credentials. +For Hermes, stop here and follow https://github.com/grafana/agento11y/tree/main/plugins/hermes/llms.txt. Hermes uses an independent Python plugin, not the shared launcher. Install in Hermes's Python environment and enable its `agento11y` entry point through Hermes YAML. Shared login, `config.env`, local mode, and the launcher steps below do not apply. Read the Hermes README's release warning before installing from PyPI. + If the `agento11y` binary is already installed on this machine, run `agento11y skills show setup-coding-agent`. It prints a fuller version of everything below: doctor-based triage, troubleshooting, and the config reference. A binary older than that command prints `agento11y: unknown agent "skills"` and exits 2. If that happens, or the binary is not installed yet, continue here. @@ -84,6 +86,7 @@ A binary older than that command prints `agento11y: unknown agent "skills"` and | [OpenCode](https://opencode.ai) | [`plugins/opencode/`](https://github.com/grafana/agento11y/tree/main/plugins/opencode) | Shared `agento11y` Go binary, installs `@grafana/agento11y-opencode` | | [Pi](https://github.com/earendil-works/pi) | [`plugins/pi/`](https://github.com/grafana/agento11y/tree/main/plugins/pi) | Independent npm package `@grafana/agento11y-pi` | | [Vibe](https://github.com/mistralai/mistral-vibe) | [`plugins/vibe/`](https://github.com/grafana/agento11y/tree/main/plugins/vibe) | Shared `agento11y` Go binary via `hooks.toml` | +| [Hermes](https://github.com/NousResearch/hermes-agent) | [`plugins/hermes/`](https://github.com/grafana/agento11y/tree/main/plugins/hermes) | Independent Python package `grafana-agento11y-hermes`; use its separate setup guide | ## Fast path diff --git a/mise.toml b/mise.toml index 2717fa9b1..5ba09a23f 100644 --- a/mise.toml +++ b/mise.toml @@ -24,6 +24,29 @@ run = "pnpm install --frozen-lockfile" description = "Update JS/TS dependencies and pnpm lockfile" run = "pnpm install --no-frozen-lockfile" +# --- Hermes plugin --- + +[tasks."format:py:plugin-hermes"] +description = "Format Hermes plugin code with its locked Ruff" +run = "bash plugins/hermes/scripts/run-check.sh format" + +[tasks."lint:py:plugin-hermes"] +description = "Lint and format-check Hermes plugin code" +run = "bash plugins/hermes/scripts/run-check.sh lint" + +[tasks."typecheck:py:plugin-hermes"] +description = "Type-check Hermes plugin code" +run = "bash plugins/hermes/scripts/run-check.sh typecheck" + +[tasks."test:py:plugin-hermes"] +description = "Test Hermes with isolated credentials and 99% branch coverage" +usage = 'arg "[python]" default="3.11"' +run = 'bash plugins/hermes/scripts/run-check.sh test "${usage_python?}"' + +[tasks."build:py:plugin-hermes"] +description = "Verify Hermes wheel and rebuilt sdist outside the checkout" +run = "bash plugins/hermes/scripts/run-check.sh build" + # --- Formatting --- [tasks."format:go"] @@ -67,7 +90,7 @@ run = "pnpm --filter @grafana/agento11y-local-viewer run lint:fix" [tasks.format] description = "Format all code" -depends = ["format:go", "format:py", "format:cs", "format:ts:sdk-js", "format:ts:plugin-pi", "format:ts:plugin-opencode", "format:ts:local-viewer"] +depends = ["format:go", "format:py", "format:py:plugin-hermes", "format:cs", "format:ts:sdk-js", "format:ts:plugin-pi", "format:ts:plugin-opencode", "format:ts:local-viewer"] # --- Linting --- @@ -146,7 +169,7 @@ run = "pnpm run check:js-dependency-pinning" [tasks.lint] description = "Run all linting" -depends = ["lint:go", "lint:py", "lint:cs", "lint:ts:sdk-js", "lint:ts:plugin-pi", "lint:ts:plugin-opencode", "lint:ts:local-viewer", "lint:ts:redaction-generator", "lint:js-dependency-pinning"] +depends = ["lint:go", "lint:py", "lint:py:plugin-hermes", "lint:cs", "lint:ts:sdk-js", "lint:ts:plugin-pi", "lint:ts:plugin-opencode", "lint:ts:local-viewer", "lint:ts:redaction-generator", "lint:js-dependency-pinning"] # --- Type checking --- @@ -173,6 +196,7 @@ run = "pnpm --filter @grafana/agento11y-local-viewer run typecheck" [tasks.typecheck] description = "Run all type checks" depends = [ + "typecheck:py:plugin-hermes", "typecheck:ts:sdk-js", "typecheck:ts:plugin-pi", "typecheck:ts:plugin-opencode", @@ -673,6 +697,7 @@ mise run test:ts:sdk-js mise run test:ts:plugin-pi mise run test:ts:plugin-opencode mise run test:ts:local-viewer +mise run test:py:plugin-hermes mise run test:py:sdk-core mise run test:py:sdk-provider-conformance mise run test:py:sdk-framework-conformance @@ -950,6 +975,6 @@ ls -la "$web/vendor" "$web/fonts" """ [tasks.check] -description = "Run lint + typecheck + tests" -depends = ["lint", "typecheck", "check:proto", "check:redaction"] +description = "Run lint + typecheck + tests + Hermes artifact verification" +depends = ["lint", "typecheck", "check:proto", "check:redaction", "build:py:plugin-hermes"] run = "mise run test:sdk:all" diff --git a/plugins/README.md b/plugins/README.md index deea3ed08..f046901eb 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -4,6 +4,8 @@ Send conversations from your coding agent to [Grafana Agent Observability](https Full docs: [Instrument coding agents](https://grafana.com/docs/grafana-cloud/machine-learning/agent-observability/guides/instrument-coding-agents/). +The [Hermes plugin](hermes/README.md) is installed separately in Hermes's Python environment. The launcher instructions below do not apply to Hermes. + ## Install On macOS use Homebrew; on Linux and Windows (or any platform with Go 1.25+) use `go install`. @@ -45,12 +47,15 @@ Cursor has no launcher; see [`cursor/README.md`](cursor/README.md) for setup. | [OpenCode](https://opencode.ai) | [`opencode/`](opencode/) | Available | | [Pi](https://github.com/earendil-works/pi) | [`pi/`](pi/) | Available | | [Vibe](https://github.com/mistralai/mistral-vibe) | [`vibe/`](vibe/) | Experimental | +| [Hermes](https://github.com/NousResearch/hermes-agent) | [`hermes/`](hermes/) | Separate Python plugin; no launcher | ## Content and redaction Plugins send metadata only by default. `AGENTO11Y_CONTENT_CAPTURE_MODE=full` adds conversation content; see [Content Capture Modes](../docs/concepts/content-capture-modes.md). -When a plugin exports content, it redacts known secret formats first. That covers user prompts, system prompts, assistant text, thinking, conversation titles, error messages, tool arguments, and tool results, on the generation and on the tool-execution span. Set `AGENTO11Y_REDACT_INPUT_MESSAGES=false` to send prompts without redaction; everything else stays redacted. The strength differs per field, and prose fields are deliberately treated more gently than pasted content; [Content Capture Modes](../docs/concepts/content-capture-modes.md#strength-per-field) has the table. +When a plugin exports content, it redacts known secret formats first. That covers user prompts, system prompts, assistant text, thinking, conversation titles, error messages, tool arguments, and tool results, on the generation and on the tool-execution span. Set `AGENTO11Y_REDACT_INPUT_MESSAGES=false` to send user prompts without redaction; everything else stays redacted. The strength differs per field, and prose fields are deliberately treated more gently than pasted content; [Content Capture Modes](../docs/concepts/content-capture-modes.md#strength-per-field) has the table. + +See the [Hermes installation guide](hermes/README.md#install) for privacy differences between the source and published PyPI `0.10.0`. ## Configuration diff --git a/plugins/hermes/.agents/skills/e2e-test/SKILL.md b/plugins/hermes/.agents/skills/e2e-test/SKILL.md new file mode 100644 index 000000000..c44c4937e --- /dev/null +++ b/plugins/hermes/.agents/skills/e2e-test/SKILL.md @@ -0,0 +1,126 @@ +--- +name: e2e-test +description: Optional credential-free Hermes integration checks using an explicit loopback model provider and local telemetry receivers. Inspect the scripts before running them; their defaults are not safe evidence of a local-only run. +--- + +# Optional Hermes end-to-end checks + +Unit tests stub the SDK and invent hook payloads. These optional checks inspect +real Hermes hooks and exported OTel data. They are not part of normal CI. + +Run from `plugins/hermes`. Read each script before execution. Do not run Cloud +verification, install packages, start servers, or invoke Hermes without approval. + +## Safety boundary + +A local telemetry sink does not make the model provider local. A mock provider +does not make telemetry local either. Explicitly configure all three destinations: + +| Destination | Credential-free choice | +| --- | --- | +| Model provider | LOOPBACK: `openai-api`, `mock-model`, `http://127.0.0.1:8799/v1`, dummy key | +| Generation export | Disabled: `AGENTO11Y_PROTOCOL=none`, `AGENTO11Y_AUTH_MODE=none`, no token or tenant | +| Traces and metrics | LOOPBACK: `http://127.0.0.1:8801`, no auth headers | + +Use a new temporary HOME and HERMES_HOME, not the user's real config. Clear the +inherited environment so provider keys, telemetry aliases, per-signal endpoints, +proxy settings, and `AGENTO11Y_ENV_FILE` cannot redirect the test. +Hermes's `.env` overrides process exports: never reuse an old test home. +Use synthetic content only. Hook-probe logs contain payloads before redaction. + +## Wrapper limitations + +- `setup.sh` installs packages and defaults its config to Anthropic. Its warning + requesting an Anthropic key is not applicable to the loopback recipe. +- `run-hermes.sh sink` redirects OTel only. Its provider defaults to Anthropic, + so `sink` alone is neither credential-free nor a local-only test. +- `run-hermes.sh full` leaves capture mode unset. It tests + `metadata_only`, despite its name. The wrapper does not pass through capture + mode, redaction, or automatic-tag environment switches. +- `run-mock.sh` chooses a loopback provider but reads generation and OTLP + destinations from the environment or `AGENTO11Y_ENV_FILE`. It forces basic + auth and uses broad `pkill` matching. Do not use it for this recipe. +- `verify-backend.sh` uses Cloud queries. It is outside this credential-free flow. +- `otlp-sink.py` decodes spans and metrics, but logs only attribute names. + It does not validate generation ingestion or secret-redaction values. + +## Isolated setup + +Prerequisites: `uv`, Python 3.11+, and permission to download dependencies. +The setup step can access package registries; the model and telemetry steps +below use loopback. Run the steps in the same shell. + +```sh +S="$PWD/.agents/skills/e2e-test/scripts" +E2E_DIR=$(mktemp -d) +mkdir -p "$E2E_DIR/user" +env -i PATH="$PATH" HOME="$E2E_DIR/user" E2E_DIR="$E2E_DIR" PY_VERSION=3.13 "$S/setup.sh" 0.19.0 +``` + +`setup.sh` installs `plugins/hermes`. Confirm it reports the +`agento11y` entry point and registered hooks. Keep the fresh home free of `.env` +files. No provider key is needed for the next step. + +## Start loopback receivers and run Hermes directly + +Bypass the wrappers so privacy switches reach the Hermes process. +The mock and OTLP server both bind to `127.0.0.1`. Choose unused ports; if either +server exits on startup, stop rather than connecting to an unknown listener. + +```sh +env -i PATH="$PATH" HOME="$E2E_DIR/user" E2E_DIR="$E2E_DIR" MOCK_SCRIPT=tool,ok "$E2E_DIR/.venv/bin/python" "$S/mock-provider.py" 8799 >"$E2E_DIR/mock.out" 2>&1 & +mock_pid=$! +env -i PATH="$PATH" HOME="$E2E_DIR/user" E2E_DIR="$E2E_DIR" "$E2E_DIR/.venv/bin/python" "$S/otlp-sink.py" 8801 >"$E2E_DIR/sink.out" 2>&1 & +sink_pid=$! +trap 'kill "$mock_pid" "$sink_pid" 2>/dev/null || true' EXIT +sleep 1 +kill -0 "$mock_pid" "$sink_pid" || exit 1 + +env -i PATH="$PATH" HOME="$E2E_DIR/user" TERM=dumb E2E_DIR="$E2E_DIR" HERMES_HOME="$E2E_DIR/home" OPENAI_API_KEY=mock-key OPENAI_BASE_URL=http://127.0.0.1:8799/v1 AGENTO11Y_PROTOCOL=none AGENTO11Y_AUTH_MODE=none AGENTO11Y_CONTENT_CAPTURE_MODE=metadata_only AGENTO11Y_AUTO_CODING_AGENT_TAGS=false OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:8801 OTEL_EXPORTER_OTLP_INSECURE=true "$E2E_DIR/.venv/bin/hermes" -m mock-model --provider openai-api -z 'List the available skills, then reply OK.' +``` + +This checks only the OTel channel. The disabled generation channel is deliberate, +not evidence that generation export works. To test neither channel, omit the +OTLP variables and set `AGENTO11Y_HERMES_OTEL_AUTO=false`. + +To test generation export, first provide a loopback receiver that implements +`/api/v1/generations:export` and validates the SDK request. Explicitly set its +loopback endpoint and HTTP protocol. The plugin's channel activation also needs +a supported non-`none` auth mode; use dummy local credentials only. Keep OTel +pointed to the local sink or disable it. The OTLP sink does not implement +this ingest protocol; do not treat its generic HTTP 200 response as validation. + +## What to inspect + +Read `mock.log`, `hooks.jsonl`, and `otlp-sink.log` under the temporary directory. +The mock should show a tool request followed by completion. The sink should show +generation/tool spans and metrics. Missing spans can indicate a flush or hook +problem; no Cloud sampling is involved here. + +Check these cases with explicit settings on the direct Hermes invocation: + +- Capture mode unset, `default`, and invalid: all must resolve to `metadata_only`. +- Explicit `full`: exercise content and shared redaction with synthetic secrets. + Use a receiver that inspects values; this sink's attribute-name log is insufficient. +- `AGENTO11Y_REDACT_INPUT_MESSAGES=false`: only prompt redaction turns off. +- Automatic tags off: no automatic user/repo/branch and no `cwd`. + Enable `user,repo`, then `branch`, and check metric labels and explicit-tag precedence. +- Sampling rate zero: no generation or tool telemetry. +- Retry scripts `429,429,ok`, `empty`, `401`, and `scratchpad`: restart the mock + with the chosen `MOCK_SCRIPT` and inspect actual hooks, not assumed attempt counts. +- Tool spans parent to the requesting generation, even though that parent ended. +- Request clipping and cache reuse: vary `HERMES_PLUGIN_PAYLOAD_MAX_CHARS`. + Reused sampling parameters must come from the same model. + +One-shot mode disables logging and bypasses atexit, so missing plugin logs are +expected. Some early-return paths can omit finalization and lose open records. +Use interactive Hermes when diagnosing logging or exit hooks. + +Repeat on the supported floor and proposed Hermes upgrades. The PyPI release's +hook call sites are the contract; upstream HEAD can contain unreleased kwargs. + +## Cleanup + +Stop only the PIDs started above. Review the synthetic logs, then remove only the +temporary directory printed by `printf '%s\n' "$E2E_DIR"`. Do not use broad `pkill` +or delete a reused path. No Cloud data should have been written. diff --git a/plugins/hermes/.agents/skills/e2e-test/scripts/check-generations.py b/plugins/hermes/.agents/skills/e2e-test/scripts/check-generations.py new file mode 100644 index 000000000..9480e158d --- /dev/null +++ b/plugins/hermes/.agents/skills/e2e-test/scripts/check-generations.py @@ -0,0 +1,88 @@ +"""Check one exported conversation against what the plugin promises to record. + +Reads the JSON of `agento11y conversations get -o json` and reports, per +generation, whether each recorded field arrived. Reports rather than asserts: +what is expected depends on the capture mode, the channels in use and the hermes +version, and the point is to see the whole picture in one place after a run. + +usage: check-generations.py +""" + +from __future__ import annotations + +import json +import sys + + +def part_kinds(messages: list) -> list[str]: + kinds = [] + for message in messages or []: + role = str(message.get("role", "")).replace("MESSAGE_ROLE_", "").lower() + for part in message.get("parts") or []: + kind = next(iter(part), "?") if part else "empty" + kinds.append(f"{role}:{kind}") + return kinds + + +def mark(ok: bool) -> str: + return "yes" if ok else "NO" + + +def clip_note(text: str) -> str: + """Say whether hermes shortened the value before the plugin ever saw it. + + The sanitizer leaves ``...[truncated N chars]`` on a clipped string. Its + first pass clips at 8000 characters and runs before the payload cap is + measured, so a long system prompt arrives clipped at every cap. + """ + if not text: + return "" + return " clipped by hermes" if "[truncated" in text[-40:] else " complete" + + +document = json.load(open(sys.argv[1])) +generations = document.get("generations") or [] +print(f"generations: {len(generations)}") + +for generation in generations: + usage = generation.get("usage") or {} + tags = generation.get("tags") or {} + metadata = generation.get("metadata") or {} + estimate = generation.get("context_token_estimate") or {} + print(f"\n--- {generation.get('generation_id')}") + print(f" model {generation.get('model')} response_model={generation.get('response_model')!r}") + print(f" agent {generation.get('agent_name')} version={generation.get('agent_version')!r}") + print(f" capture mode {metadata.get('agento11y.sdk.content_capture_mode')}") + print(f" window {generation.get('started_at')} -> {generation.get('completed_at')}") + print(f" stop_reason {generation.get('stop_reason')!r}") + print(f" error {json.dumps(generation.get('error'))}") + print(f" trace linked {mark(bool(generation.get('trace_id')))} ({generation.get('trace_id')})") + print(f" input parts {part_kinds(generation.get('input'))}") + print(f" output parts {part_kinds(generation.get('output'))}") + print(" tokens " + ", ".join(f"{k}={v}" for k, v in sorted(usage.items())) or " tokens none") + print(f" cache tokens {mark(any('cache' in k for k in usage))}") + print(f" framework tags {mark(tags.get('agento11y.framework.name') == 'hermes')}") + print(f" cwd/entrypoint {mark('cwd' in tags and 'entrypoint' in tags)}") + print(f" git.branch {mark('git.branch' in tags)} (only set when the cwd is a git checkout)") + print(f" hermes metadata {mark(all(f'hermes.{k}' in metadata for k in ('task_id', 'session_id', 'turn_id')))}") + prompt = generation.get("system_prompt") or "" + tools = generation.get("tools") or [] + print(f" system_prompt {mark(bool(prompt))} {len(prompt)} chars{clip_note(prompt)}") + # An empty list next to a non-zero count is a clipped request payload, not a + # hermes without tools, which is why the raw count is recorded beside it. + print(f" tools recorded {len(tools)} of hermes.tool_count={metadata.get('hermes.tool_count')}") + print( + f" sampling params max_tokens={generation.get('max_tokens')} temperature={generation.get('temperature')} " + f"top_p={generation.get('top_p')} tool_choice={generation.get('tool_choice')!r}" + ) + # True when any of the prompt, the tools or the params came from an earlier + # request in the session because this one arrived clipped. + print(f" facts reused {metadata.get('hermes.request_facts_reused')}") + print(f" parent gens {generation.get('parent_generation_ids')}") + print(f" token estimate system_prompt={estimate.get('system_prompt')} tools_total={estimate.get('tools_total')}") + +chain = [(g.get("generation_id"), (g.get("parent_generation_ids") or [None])[0]) for g in generations] +linked = sum(1 for _, parent in chain if parent) +print(f"\nchain: {linked} of {len(chain)} generations name a parent") +for generation_id, parent in chain: + print(f" {parent or '(root)'} -> {generation_id}") diff --git a/plugins/hermes/.agents/skills/e2e-test/scripts/check-install.py b/plugins/hermes/.agents/skills/e2e-test/scripts/check-install.py new file mode 100644 index 000000000..cebf620d0 --- /dev/null +++ b/plugins/hermes/.agents/skills/e2e-test/scripts/check-install.py @@ -0,0 +1,34 @@ +"""Report what was installed and which hooks the plugin registers. + +Run with the test install's interpreter. Loading the plugin manager here also +proves the entry point resolves, which `hermes plugins list` cannot show for a +pip-installed plugin. +""" + +from __future__ import annotations + +import logging +from importlib.metadata import entry_points, version + +logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s %(message)s") + +print("hermes-agent:", version("hermes-agent")) +print("plugin:", version("grafana-agento11y-hermes")) +for ep in entry_points(group="hermes_agent.plugins"): + if ep.name == "agento11y": + print("entry point:", ep.name, "->", ep.value) + +from hermes_cli import plugins # noqa: E402 # ty: ignore[unresolved-import] - only in the test venv + +plugins.discover_plugins(force=True) +manager = plugins.get_plugin_manager() + +entry = getattr(manager, "_plugins", {}).get("agento11y") +print("registry entry:", "present" if entry is not None else "MISSING (check config.yaml plugins.enabled)") + +registered = [] +for hook in sorted(plugins.VALID_HOOKS): + handlers = getattr(manager, "_hooks", {}).get(hook) or [] + if any("agento11y" in getattr(fn, "__module__", "") for fn in handlers): + registered.append(hook) +print("hooks registered:", ", ".join(registered) or "NONE") diff --git a/plugins/hermes/.agents/skills/e2e-test/scripts/mock-provider.py b/plugins/hermes/.agents/skills/e2e-test/scripts/mock-provider.py new file mode 100644 index 000000000..0a3923576 --- /dev/null +++ b/plugins/hermes/.agents/skills/e2e-test/scripts/mock-provider.py @@ -0,0 +1,203 @@ +"""Scripted OpenAI-compatible endpoint for the failure and retry paths. + +MOCK_SCRIPT is a comma-separated list, one entry per completion request; the +last entry repeats when the list runs out. + + 429 rate limited, with Retry-After: hermes retries and re-fires + pre_api_request under the SAME api_request_id + 500 server error, also retried + 401 auth error, not retried + empty 200 with an empty SSE body, which hermes treats as a retryable + provider fault and gives up on after three attempts + scratchpad content with an unterminated and + finish_reason=length, which reaches hermes' thinking-budget- + exhausted path + tool a call to the read-only ``skills_list`` tool, so hermes executes + a tool and comes back for a second API call. Pair it as + ``tool,ok`` to get a two-call session, which is the only way to + reach the paths that need more than one request in a session. + ok a normal assistant reply + +Every step honours the request's own ``stream`` flag: a streaming request gets +SSE and a non-streaming one gets a plain body. Hermes always prefers the +streaming path, and reads a non-streamed body as an empty stream. +""" + +from __future__ import annotations + +import json +import os +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any + +E2E_DIR = os.environ.get("E2E_DIR", "/tmp/agento11y-hermes-e2e") +SCRIPT = (os.environ.get("MOCK_SCRIPT") or "ok").split(",") +LOG = os.environ.get("MOCK_LOG") or os.path.join(E2E_DIR, "mock.log") + +_calls = {"n": 0} + + +def log(line: str) -> None: + with open(LOG, "a") as handle: + handle.write(line + "\n") + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - stdlib signature + pass + + def _send(self, code: int, body: dict[str, Any], extra_headers: dict[str, str] | None = None) -> None: + raw = json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + for key, value in (extra_headers or {}).items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(raw) + + def _send_stream(self, chunks: list[dict[str, Any]]) -> None: + payload = "".join("data: " + json.dumps(chunk) + "\n\n" for chunk in chunks) + "data: [DONE]\n\n" + raw = payload.encode() + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def _send_sse(self, text: str, finish_reason: str) -> None: + base = {"id": "chatcmpl-mock", "object": "chat.completion.chunk", "created": 0, "model": "mock-model"} + self._send_stream( + [ + {**base, "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}}]}, + {**base, "choices": [{"index": 0, "delta": {"content": text}}]}, + { + **base, + "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}], + "usage": {"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15}, + }, + ] + ) + + def _send_sse_tool_call(self, name: str, arguments: str) -> None: + base = {"id": "chatcmpl-mock", "object": "chat.completion.chunk", "created": 0, "model": "mock-model"} + call = {"index": 0, "id": "call_mock_1", "type": "function", "function": {"name": name, "arguments": ""}} + self._send_stream( + [ + { + **base, + "choices": [{"index": 0, "delta": {"role": "assistant", "content": "", "tool_calls": [call]}}], + }, + { + **base, + "choices": [ + {"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": arguments}}]}} + ], + }, + { + **base, + "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}], + "usage": {"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15}, + }, + ] + ) + + def _send_tool_call(self, name: str, arguments: str) -> None: + call = {"id": "call_mock_1", "type": "function", "function": {"name": name, "arguments": arguments}} + self._send( + 200, + { + "id": "chatcmpl-mock", + "object": "chat.completion", + "created": 0, + "model": "mock-model", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": None, "tool_calls": [call]}, + "finish_reason": "tool_calls", + } + ], + "usage": {"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15}, + }, + ) + + def do_GET(self) -> None: # noqa: N802 - stdlib handler naming + if self.path.rstrip("/").endswith("/models"): + self._send(200, {"object": "list", "data": [{"id": "mock-model", "object": "model"}]}) + return + self._send(404, {"error": {"message": "not found"}}) + + def do_POST(self) -> None: # noqa: N802 - stdlib handler naming + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) + try: + parsed = json.loads(body or b"{}") + except Exception: + parsed = {} + streaming = bool(parsed.get("stream")) + + # Hermes opens with a ``/api/show`` model probe. Only a completion + # request may advance the script, otherwise every entry is one call + # later than written and a scripted failure never reaches the turn. + if "completions" not in self.path: + log(f"PROBE path={self.path} bytes={len(body)}") + self._send(200, {"model": "mock-model"}) + return + + step = SCRIPT[min(_calls["n"], len(SCRIPT) - 1)].strip() + _calls["n"] += 1 + log(f"CALL {_calls['n']} path={self.path} step={step} stream={streaming} bytes={len(body)}") + + if step == "429": + self._send(429, {"error": {"message": "mock rate limit", "type": "rate_limit_error"}}, {"Retry-After": "1"}) + return + if step == "500": + self._send(500, {"error": {"message": "mock server error", "type": "server_error"}}) + return + if step == "401": + self._send(401, {"error": {"message": "mock invalid key", "type": "authentication_error"}}) + return + if step == "empty": + raw = b"data: [DONE]\n\n" + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + + if step == "tool": + if streaming: + self._send_sse_tool_call("skills_list", "{}") + else: + self._send_tool_call("skills_list", "{}") + return + + text, finish = "MOCK-REPLY-OK", "stop" + if step == "scratchpad": + text, finish = "thinking, and never closing the tag", "length" + + if streaming: + self._send_sse(text, finish) + return + + self._send( + 200, + { + "id": "chatcmpl-mock", + "object": "chat.completion", + "created": 0, + "model": "mock-model", + "choices": [{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": finish}], + "usage": {"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15}, + }, + ) + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8799 + HTTPServer(("127.0.0.1", port), Handler).serve_forever() diff --git a/plugins/hermes/.agents/skills/e2e-test/scripts/otlp-sink.py b/plugins/hermes/.agents/skills/e2e-test/scripts/otlp-sink.py new file mode 100644 index 000000000..4d4c63054 --- /dev/null +++ b/plugins/hermes/.agents/skills/e2e-test/scripts/otlp-sink.py @@ -0,0 +1,78 @@ +"""Local OTLP/HTTP receiver that decodes and logs span and metric names. + +Answers "did the plugin export it" without involving a backend. Needed because +sampling on the receiving end (Adaptive Traces) can drop short internal spans, +so a span missing from a trace store says nothing about the exporter. + +Requires the OTel proto package, which the plugin already depends on. +""" + +from __future__ import annotations + +import gzip +import os +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any + +from opentelemetry.proto.collector.metrics.v1 import metrics_service_pb2 +from opentelemetry.proto.collector.trace.v1 import trace_service_pb2 + +E2E_DIR = os.environ.get("E2E_DIR", "/tmp/agento11y-hermes-e2e") +LOG = os.environ.get("SINK_LOG") or os.path.join(E2E_DIR, "otlp-sink.log") + + +def log(line: str) -> None: + with open(LOG, "a") as handle: + handle.write(line + "\n") + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - stdlib signature + pass + + def do_POST(self) -> None: # noqa: N802 - stdlib handler naming + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) + if self.headers.get("Content-Encoding") == "gzip": + raw = gzip.decompress(raw) + try: + if self.path.endswith("/v1/traces"): + request = trace_service_pb2.ExportTraceServiceRequest() + request.ParseFromString(raw) + for resource_spans in request.resource_spans: + resource = {a.key: a.value.string_value for a in resource_spans.resource.attributes} + for scope_spans in resource_spans.scope_spans: + for span in scope_spans.spans: + attrs = sorted(a.key for a in span.attributes) + # Ids, because a tool span is expected to sit under + # the generation span of the call that asked for it, + # and this is the one view no sampling can hide. + log( + f"SPAN {span.name} | status={span.status.code} " + f"| trace={span.trace_id.hex()} span={span.span_id.hex()} " + f"parent={span.parent_span_id.hex() or '(root)'} " + f"| service={resource.get('service.name')} | attrs={attrs}" + ) + elif self.path.endswith("/v1/metrics"): + request = metrics_service_pb2.ExportMetricsServiceRequest() + request.ParseFromString(raw) + for resource_metrics in request.resource_metrics: + for scope_metrics in resource_metrics.scope_metrics: + for metric in scope_metrics.metrics: + log(f"METRIC {metric.name}") + else: + log(f"OTHER {self.path} bytes={len(raw)}") + except Exception as exc: + log(f"DECODE-ERROR {self.path}: {exc!r}") + self.send_response(200) + self.send_header("Content-Type", "application/x-protobuf") + self.send_header("Content-Length", "0") + self.end_headers() + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8801 + HTTPServer(("127.0.0.1", port), Handler).serve_forever() diff --git a/plugins/hermes/.agents/skills/e2e-test/scripts/probe-plugin/__init__.py b/plugins/hermes/.agents/skills/e2e-test/scripts/probe-plugin/__init__.py new file mode 100644 index 000000000..35e41c36e --- /dev/null +++ b/plugins/hermes/.agents/skills/e2e-test/scripts/probe-plugin/__init__.py @@ -0,0 +1,80 @@ +"""Hermes plugin that records every hook invocation to a JSONL file. + +Ground truth for what the running hermes build passes each hook. The agento11y +plugin's assumptions are checked against this, not against the hook docs, which +lag the call sites. Values are shrunk so one file read stays cheap: strings clip +at 400 characters, containers at 20-40 entries, nesting at depth 3. + +Set HOOKDUMP_FILE to move the output. +""" + +from __future__ import annotations + +import json +import os +import time +from typing import Any + +HOOKS = ( + "pre_api_request", + "post_api_request", + "api_request_error", + "pre_llm_call", + "post_llm_call", + "pre_tool_call", + "post_tool_call", + "on_session_start", + "on_session_end", + "on_session_finalize", +) + +OUT = os.environ.get("HOOKDUMP_FILE") or os.path.join( + os.environ.get("E2E_DIR", "/tmp/agento11y-hermes-e2e"), "hooks.jsonl" +) + + +def _shrink(value: Any, depth: int = 0) -> Any: + if depth > 3: + return "" + if value is None or isinstance(value, (bool, int, float)): + return value + if isinstance(value, str): + return value if len(value) <= 400 else value[:400] + f"...<{len(value)} chars>" + if isinstance(value, dict): + return {str(k): _shrink(v, depth + 1) for k, v in list(value.items())[:40]} + if isinstance(value, (list, tuple)): + return [_shrink(v, depth + 1) for v in list(value)[:20]] + # Objects (hermes passes assistant messages as objects): keep the fields a + # telemetry plugin would read. + out: dict[str, Any] = {"__type__": type(value).__name__} + for attr in ("role", "content", "tool_calls", "model", "id", "usage"): + if hasattr(value, attr): + out[attr] = _shrink(getattr(value, attr), depth + 1) + return out + + +def _record(hook: str, kwargs: dict[str, Any]) -> None: + row = { + "t": time.time(), + "hook": hook, + "keys": sorted(kwargs), + "payload": {key: _shrink(value) for key, value in kwargs.items()}, + } + with open(OUT, "a") as handle: + handle.write(json.dumps(row, default=str) + "\n") + + +def _make(hook: str): + def handler(**kwargs: Any) -> None: + try: + _record(hook, kwargs) + except Exception as exc: # a probe must never break the agent loop + with open(OUT, "a") as handle: + handle.write(json.dumps({"hook": hook, "error": repr(exc)}) + "\n") + + return handler + + +def register(ctx) -> None: + for hook in HOOKS: + ctx.register_hook(hook, _make(hook)) diff --git a/plugins/hermes/.agents/skills/e2e-test/scripts/probe-plugin/plugin.yaml b/plugins/hermes/.agents/skills/e2e-test/scripts/probe-plugin/plugin.yaml new file mode 100644 index 000000000..351debf17 --- /dev/null +++ b/plugins/hermes/.agents/skills/e2e-test/scripts/probe-plugin/plugin.yaml @@ -0,0 +1,15 @@ +name: hookdump +version: "1.0.0" +description: "Test probe: appends every hook payload it receives to hooks.jsonl." +author: grafana-agento11y-hermes tests +hooks: + - pre_api_request + - post_api_request + - api_request_error + - pre_llm_call + - post_llm_call + - pre_tool_call + - post_tool_call + - on_session_start + - on_session_end + - on_session_finalize diff --git a/plugins/hermes/.agents/skills/e2e-test/scripts/run-hermes.sh b/plugins/hermes/.agents/skills/e2e-test/scripts/run-hermes.sh new file mode 100755 index 000000000..da02ca092 --- /dev/null +++ b/plugins/hermes/.agents/skills/e2e-test/scripts/run-hermes.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Run one hermes one-shot turn against the test install, with a deliberately +# built environment. +# +# usage: run-hermes.sh [extra hermes args...] +# +# modes +# full generations + OTel, content capture left at the plugin default +# metadata same, with AGENTO11Y_CONTENT_CAPTURE_MODE=metadata_only +# legacy-env retired SIGIL_* names only, plus the branded OTLP alias, so the +# compat shim and the endpoint override both get exercised +# gen-only generations only, no OTLP endpoint (expect a null trace_id) +# otel-only OTel only, no generation credentials (expect spans, no records) +# sink OTel only, pointed at the local OTLP sink: no Grafana account +# needed, and it shows exactly which spans and metrics are emitted +# none no telemetry env at all (expect one warning and no data) +# +# env +# E2E_DIR where setup.sh installed everything, default +# /tmp/agento11y-hermes-e2e +# AGENTO11Y_ENV_FILE KEY=VALUE file with the AGENTO11Y_* / OTEL_* block from +# your stack's Agent Observability setup page. Falls back +# to the AGENTO11Y_*/OTEL_* values already in the shell. +# MODEL / PROVIDER default claude-haiku-4-5-20251001 on anthropic +# AGENT_NAME default hermes-e2e; the mode becomes the agent version, +# so runs are trivially separable in queries +# +# The environment is built with env -i, so a knob set in the calling shell does +# not reach hermes unless it is named in PASSTHROUGH below. Each inherited value +# is echoed, because a silently dropped knob makes a run look like a pass: +# AGENTO11Y_HERMES_SAMPLE_RATE 0 records nothing +# AGENTO11Y_HERMES_MAX_CHARS per-string cap on tool args and results +# AGENTO11Y_HERMES_OTEL_AUTO false leaves provider installation alone +# HERMES_PLUGIN_PAYLOAD_MAX_CHARS hermes's own cap, which decides how much +# of the system prompt and the tool schemas +# reach the hooks at all +set -uo pipefail + +MODE="${1:?usage: run-hermes.sh [args...]}"; shift +PROMPT="${1:?usage: run-hermes.sh [args...]}"; shift + +E2E_DIR="${E2E_DIR:-/tmp/agento11y-hermes-e2e}" +MODEL="${MODEL:-claude-haiku-4-5-20251001}" +PROVIDER="${PROVIDER:-anthropic}" +AGENT_NAME="${AGENT_NAME:-hermes-e2e}" +SINK_PORT="${SINK_PORT:-8801}" + +read_setting() { + # $1 = variable name. The env file wins over the ambient shell, because the + # ambient shell often carries a mix of current and retired names. + local name="$1" value="" + if [ -n "${AGENTO11Y_ENV_FILE:-}" ] && [ -f "$AGENTO11Y_ENV_FILE" ]; then + value=$(grep -E "^${name}=" "$AGENTO11Y_ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"') + fi + [ -z "$value" ] && value="$(printenv "$name" 2>/dev/null || true)" + printf '%s' "$value" +} + +GEN_ENDPOINT=$(read_setting AGENTO11Y_ENDPOINT) +TENANT=$(read_setting AGENTO11Y_AUTH_TENANT_ID) +TOKEN=$(read_setting AGENTO11Y_AUTH_TOKEN) +OTLP_ENDPOINT=$(read_setting OTEL_EXPORTER_OTLP_ENDPOINT) +[ -z "$OTLP_ENDPOINT" ] && OTLP_ENDPOINT=$(read_setting AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT) +OTLP_HEADERS=$(read_setting OTEL_EXPORTER_OTLP_HEADERS) + +GENERATIONS=( + "AGENTO11Y_ENDPOINT=$GEN_ENDPOINT" + "AGENTO11Y_PROTOCOL=http" + "AGENTO11Y_AUTH_MODE=basic" + "AGENTO11Y_AUTH_TENANT_ID=$TENANT" + "AGENTO11Y_AUTH_TOKEN=$TOKEN" +) +OTEL=( + "OTEL_EXPORTER_OTLP_ENDPOINT=$OTLP_ENDPOINT" + "OTEL_EXPORTER_OTLP_HEADERS=$OTLP_HEADERS" +) + +COMMON=( + "PATH=/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin" + "HOME=$HOME" + "TERM=dumb" + "E2E_DIR=$E2E_DIR" + "HERMES_HOME=$E2E_DIR/home" + "AGENTO11Y_DEBUG=true" + "AGENTO11Y_AGENT_NAME=$AGENT_NAME" + "AGENTO11Y_AGENT_VERSION=$MODE" +) +PASSTHROUGH=( + AGENTO11Y_HERMES_SAMPLE_RATE + AGENTO11Y_HERMES_MAX_CHARS + AGENTO11Y_HERMES_OTEL_AUTO + HERMES_PLUGIN_PAYLOAD_MAX_CHARS +) +for name in "${PASSTHROUGH[@]}"; do + value="$(printenv "$name" 2>/dev/null || true)" + if [ -n "$value" ]; then + COMMON+=("$name=$value") + echo "run-hermes: inherited $name=$value" >&2 + fi +done + +case "$MODE" in + full) ENVV=("${COMMON[@]}" "${GENERATIONS[@]}" "${OTEL[@]}") ;; + metadata) ENVV=("${COMMON[@]}" "${GENERATIONS[@]}" "${OTEL[@]}" "AGENTO11Y_CONTENT_CAPTURE_MODE=metadata_only") ;; + legacy-env) ENVV=("${COMMON[@]}" + "SIGIL_ENDPOINT=$GEN_ENDPOINT" "SIGIL_PROTOCOL=http" "SIGIL_AUTH_MODE=basic" + "SIGIL_TENANT_ID=$TENANT" "SIGIL_AUTH_TOKEN=$TOKEN" + "AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT=$OTLP_ENDPOINT") ;; + gen-only) ENVV=("${COMMON[@]}" "${GENERATIONS[@]}") ;; + otel-only) ENVV=("${COMMON[@]}" "${OTEL[@]}") ;; + sink) ENVV=("${COMMON[@]}" + "OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:$SINK_PORT" + "OTEL_EXPORTER_OTLP_INSECURE=true") ;; + none) ENVV=("${COMMON[@]}") ;; + *) echo "run-hermes: unknown mode '$MODE'" >&2; exit 2 ;; +esac + +rm -f "$E2E_DIR/hooks.jsonl" +exec env -i "${ENVV[@]}" \ + "$E2E_DIR/.venv/bin/hermes" -m "$MODEL" --provider "$PROVIDER" -z "$PROMPT" "$@" diff --git a/plugins/hermes/.agents/skills/e2e-test/scripts/run-mock.sh b/plugins/hermes/.agents/skills/e2e-test/scripts/run-mock.sh new file mode 100755 index 000000000..675806d98 --- /dev/null +++ b/plugins/hermes/.agents/skills/e2e-test/scripts/run-mock.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Drive one hermes turn against the scripted mock provider. +# +# usage: run-mock.sh [extra hermes args...] +# e.g. run-mock.sh "429,429,ok" "reply with OK" +# run-mock.sh "empty" "reply with OK" # retry exhaustion +# run-mock.sh "scratchpad" "reply with OK" # thinking-budget path +# +# The mock needs its own HERMES_HOME because $HERMES_HOME/.env overrides the +# process env, so the real provider key would otherwise win. +set -uo pipefail + +SCRIPT="${1:?usage: run-mock.sh [args...]}"; shift +PROMPT="${1:?usage: run-mock.sh [args...]}"; shift + +E2E_DIR="${E2E_DIR:-/tmp/agento11y-hermes-e2e}" +MOCK_PORT="${MOCK_PORT:-8799}" +AGENT_NAME="${AGENT_NAME:-hermes-e2e}" +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +read_setting() { + local name="$1" value="" + if [ -n "${AGENTO11Y_ENV_FILE:-}" ] && [ -f "$AGENTO11Y_ENV_FILE" ]; then + value=$(grep -E "^${name}=" "$AGENTO11Y_ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"') + fi + [ -z "$value" ] && value="$(printenv "$name" 2>/dev/null || true)" + printf '%s' "$value" +} + +# The setup page hands out the standard name, but a stored credential file may +# hold the branded alias instead. Without this fallback the mock runs get no +# OTLP endpoint, no provider is installed, and every record comes back with a +# null trace_id, which hides the span half of a failure. +OTLP_ENDPOINT=$(read_setting OTEL_EXPORTER_OTLP_ENDPOINT) +[ -z "$OTLP_ENDPOINT" ] && OTLP_ENDPOINT=$(read_setting AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT) + +MOCK_HOME="$E2E_DIR/home-mock" +mkdir -p "$MOCK_HOME/plugins" +cp -R "$SKILL_DIR/scripts/probe-plugin" "$MOCK_HOME/plugins/hookdump" +cat > "$MOCK_HOME/config.yaml" <<'YAML' +model: mock-model +plugins: + enabled: + - agento11y + - hookdump +YAML +printf 'OPENAI_API_KEY=mock-key\nOPENAI_BASE_URL=http://127.0.0.1:%s/v1\n' "$MOCK_PORT" > "$MOCK_HOME/.env" +chmod 600 "$MOCK_HOME/.env" + +pkill -f "mock-provider.py $MOCK_PORT" 2>/dev/null +rm -f "$E2E_DIR/mock.log" "$E2E_DIR/hooks.jsonl" +E2E_DIR="$E2E_DIR" MOCK_SCRIPT="$SCRIPT" MOCK_LOG="$E2E_DIR/mock.log" \ + nohup "$E2E_DIR/.venv/bin/python" "$SKILL_DIR/scripts/mock-provider.py" "$MOCK_PORT" \ + >"$E2E_DIR/mock.out" 2>&1 & +sleep 1 + +env -i \ + PATH=/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin \ + HOME="$HOME" TERM=dumb \ + E2E_DIR="$E2E_DIR" HERMES_HOME="$MOCK_HOME" \ + AGENTO11Y_DEBUG=true \ + AGENTO11Y_AGENT_NAME="$AGENT_NAME" \ + AGENTO11Y_AGENT_VERSION="mock-${SCRIPT//,/ }" \ + AGENTO11Y_ENDPOINT="$(read_setting AGENTO11Y_ENDPOINT)" \ + AGENTO11Y_PROTOCOL=http AGENTO11Y_AUTH_MODE=basic \ + AGENTO11Y_AUTH_TENANT_ID="$(read_setting AGENTO11Y_AUTH_TENANT_ID)" \ + AGENTO11Y_AUTH_TOKEN="$(read_setting AGENTO11Y_AUTH_TOKEN)" \ + OTEL_EXPORTER_OTLP_ENDPOINT="$OTLP_ENDPOINT" \ + OTEL_EXPORTER_OTLP_HEADERS="$(read_setting OTEL_EXPORTER_OTLP_HEADERS)" \ + "$E2E_DIR/.venv/bin/hermes" -m mock-model --provider openai-api -z "$PROMPT" "$@" +rc=$? + +pkill -f "mock-provider.py $MOCK_PORT" 2>/dev/null +echo "--- provider calls" +grep CALL "$E2E_DIR/mock.log" 2>/dev/null +exit $rc diff --git a/plugins/hermes/.agents/skills/e2e-test/scripts/setup.sh b/plugins/hermes/.agents/skills/e2e-test/scripts/setup.sh new file mode 100755 index 000000000..2f7654b24 --- /dev/null +++ b/plugins/hermes/.agents/skills/e2e-test/scripts/setup.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Build a throwaway hermes install with this plugin in it. +# +# The venv, isolated HERMES_HOME, and hook-probe plugin are created under +# $E2E_DIR (default /tmp/agento11y-hermes-e2e). Nothing touches ~/.hermes. +# +# usage: setup.sh [hermes-version] +set -euo pipefail + +E2E_DIR="${E2E_DIR:-/tmp/agento11y-hermes-e2e}" +HERMES_VERSION="${1:-${HERMES_VERSION:-}}" +PY_VERSION="${PY_VERSION:-3.13}" + +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$SKILL_DIR/../../.." && pwd)" + +if ! command -v uv >/dev/null; then + echo "setup: uv is required" >&2 + exit 1 +fi + +mkdir -p "$E2E_DIR" +cd "$E2E_DIR" + +if [ ! -d .venv ]; then + uv venv --python "$PY_VERSION" +fi + +spec="hermes-agent[anthropic]" +[ -n "$HERMES_VERSION" ] && spec="hermes-agent[anthropic]==$HERMES_VERSION" + +VIRTUAL_ENV="$E2E_DIR/.venv" uv pip install --quiet "$spec" +VIRTUAL_ENV="$E2E_DIR/.venv" uv pip install --quiet "$REPO_ROOT" + +# Isolated hermes home. The plugin is an entry point, so only config.yaml +# decides whether it loads; `hermes plugins enable` never sees pip plugins. +HERMES_HOME="$E2E_DIR/home" +mkdir -p "$HERMES_HOME/plugins" +cp -R "$SKILL_DIR/scripts/probe-plugin" "$HERMES_HOME/plugins/hookdump" + +cat > "$HERMES_HOME/config.yaml" <<'YAML' +model: claude-haiku-4-5-20251001 +plugins: + enabled: + - agento11y + - hookdump +YAML + +# hermes reads $HERMES_HOME/.env and it OVERRIDES the process environment, +# so the provider key goes here and telemetry env stays on the command line. +if [ -n "${ANTHROPIC_API_KEY:-}" ]; then + printf 'ANTHROPIC_API_KEY=%s\n' "$ANTHROPIC_API_KEY" > "$HERMES_HOME/.env" + chmod 600 "$HERMES_HOME/.env" +else + echo "setup: ANTHROPIC_API_KEY is unset — write the provider key to $HERMES_HOME/.env before running" >&2 +fi + +echo "ready: $E2E_DIR" +# HERMES_HOME matters here: without it the plugin manager scans the real +# ~/.hermes, which is both wrong and often unreadable under a sandbox. +env -i PATH="$PATH" HOME="$HOME" HERMES_HOME="$HERMES_HOME" \ + "$E2E_DIR/.venv/bin/python" "$SKILL_DIR/scripts/check-install.py" diff --git a/plugins/hermes/.agents/skills/e2e-test/scripts/show-hooks.py b/plugins/hermes/.agents/skills/e2e-test/scripts/show-hooks.py new file mode 100644 index 000000000..3bcd7c825 --- /dev/null +++ b/plugins/hermes/.agents/skills/e2e-test/scripts/show-hooks.py @@ -0,0 +1,60 @@ +"""Summarize a hooks.jsonl written by the probe plugin. + +Prints the hook sequence, then the kwargs each hook carried, then the fields the +plugin needs but cannot get from a hook argument (they live in the sanitized +request body). Use it to check what the running hermes build passes before +trusting any doc. + +usage: show-hooks.py [path-to-hooks.jsonl] +""" + +from __future__ import annotations + +import json +import os +import sys + +default_path = os.path.join(os.environ.get("E2E_DIR", "/tmp/agento11y-hermes-e2e"), "hooks.jsonl") +path = sys.argv[1] if len(sys.argv) > 1 else default_path +rows = [json.loads(line) for line in open(path)] + +print("== sequence") +for row in rows: + payload = row.get("payload", {}) + bits = [row["hook"]] + request_id = str(payload.get("api_request_id") or "") + if request_id: + bits.append("id=..." + request_id[-10:]) + for key in ("tool_name", "status", "finish_reason", "status_code", "retry_count", "retryable"): + if payload.get(key) not in (None, ""): + bits.append(f"{key}={payload[key]}") + if payload.get("error"): + bits.append("error=" + json.dumps(payload["error"])[:80]) + print(" " + " | ".join(str(b) for b in bits)) + +print("\n== kwargs per hook") +seen: set[str] = set() +for row in rows: + if row["hook"] in seen: + continue + seen.add(row["hook"]) + print(f" {row['hook']}: {', '.join(row['keys'])}") + +print("\n== fields the plugin has to dig out of request.body") +for row in rows: + if row["hook"] != "pre_api_request": + continue + payload = row["payload"] + history = payload.get("conversation_history") or [] + roles = [m.get("role") for m in history if isinstance(m, dict)] + body = (payload.get("request") or {}).get("body") if isinstance(payload.get("request"), dict) else None + print(" conversation_history roles:", roles, "(a system role here would feed system_prompt)") + print(" system_prompt kwarg:", repr(payload.get("system_prompt"))) + print(" max_tokens kwarg:", repr(payload.get("max_tokens"))) + if isinstance(body, dict): + print(" request.body keys:", sorted(body)) + print(" request.body.max_tokens:", body.get("max_tokens")) + print(" request.body has system:", "system" in body) + tools = body.get("tools") + print(" request.body tools:", len(tools) if isinstance(tools, list) else tools) + break diff --git a/plugins/hermes/.agents/skills/e2e-test/scripts/show-spans.py b/plugins/hermes/.agents/skills/e2e-test/scripts/show-spans.py new file mode 100644 index 000000000..183db8d8d --- /dev/null +++ b/plugins/hermes/.agents/skills/e2e-test/scripts/show-spans.py @@ -0,0 +1,52 @@ +"""Print spans from a trace exported as JSON by a trace-store CLI. + +Feed it the JSON of a single trace (OTLP/JSON shape: ``{"trace": {"resourceSpans": +[...]}}`` or a bare ``{"resourceSpans": [...]}``). Shows resource attributes, span +names, ids, durations, status and every attribute, which is how the generation +and tool spans get checked attribute by attribute. + +The span and parent ids are printed because a tool span is started inside the +context of the generation that asked for it, so the parent of an +``execute_tool`` span should be the ``generateText`` span above it. The parent +span has already ended by then, which is legal and leaves the child's window +reaching past its parent's end. + +usage: show-spans.py +""" + +from __future__ import annotations + +import json +import sys + + +def attrs(items: list | None) -> dict[str, object]: + out: dict[str, object] = {} + for item in items or []: + value = item.get("value") or {} + for key in ("stringValue", "intValue", "doubleValue", "boolValue"): + if key in value: + out[item["key"]] = value[key] + break + else: + out[item["key"]] = json.dumps(value) + return out + + +document = json.load(open(sys.argv[1])) +root = document.get("trace", document) + +for resource_spans in root.get("resourceSpans", []): + resource = attrs(resource_spans.get("resource", {}).get("attributes")) + print("RESOURCE:", {key: resource[key] for key in sorted(resource)}) + for scope_spans in resource_spans.get("scopeSpans", []): + print(" scope:", scope_spans.get("scope", {}).get("name")) + for span in scope_spans.get("spans", []): + duration = (int(span["endTimeUnixNano"]) - int(span["startTimeUnixNano"])) / 1e9 + print(f" SPAN {span['name']} kind={span.get('kind')} dur={duration:.3f}s status={span.get('status')}") + print(f" span_id={span.get('spanId')} parent_span_id={span.get('parentSpanId') or '(root)'}") + span_attrs = attrs(span.get("attributes")) + for key in sorted(span_attrs): + print(f" {key} = {str(span_attrs[key])[:300]}") + for event in span.get("events") or []: + print(" EVENT", event.get("name"), json.dumps(attrs(event.get("attributes")))[:300]) diff --git a/plugins/hermes/.agents/skills/e2e-test/scripts/verify-backend.sh b/plugins/hermes/.agents/skills/e2e-test/scripts/verify-backend.sh new file mode 100755 index 000000000..d3b28a1dd --- /dev/null +++ b/plugins/hermes/.agents/skills/e2e-test/scripts/verify-backend.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Pull what the backend stored for a run and check it field by field. +# +# usage: verify-backend.sh [conversation-id] +# With no argument, picks the newest conversation whose id looks like a hermes +# session id (YYYYMMDD_HHMMSS_hex). +# +# env +# GCX_CONTEXT required: the gcx context pointing at the stack you exported to +# AGENT_NAME agent name used by the run, default hermes-e2e +# E2E_DIR where the JSON dumps are written, default /tmp/agento11y-hermes-e2e +# +# Needs the gcx CLI. Exporting is asynchronous, so allow a few seconds after a +# run before expecting records. +set -uo pipefail + +: "${GCX_CONTEXT:?set GCX_CONTEXT to the gcx context for your stack}" +E2E_DIR="${E2E_DIR:-/tmp/agento11y-hermes-e2e}" +AGENT_NAME="${AGENT_NAME:-hermes-e2e}" +SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GCX=(gcx --context "$GCX_CONTEXT") + +CONV="${1:-}" +if [ -z "$CONV" ]; then + CONV=$("${GCX[@]}" agento11y conversations list --limit 25 2>/dev/null \ + | grep -oE '^[0-9]{8}_[0-9]{6}_[0-9a-f]+' | head -1) +fi +if [ -z "$CONV" ]; then + echo "verify-backend: no hermes-shaped conversation found; wait a few seconds and retry" >&2 + exit 1 +fi + +echo "== conversation $CONV" +"${GCX[@]}" agento11y conversations get "$CONV" -o json > "$E2E_DIR/conversation.json" || exit 1 +python3 "$SKILL_DIR/scripts/check-generations.py" "$E2E_DIR/conversation.json" + +echo +echo "== agent catalog entry ($AGENT_NAME)" +"${GCX[@]}" agento11y agents get "$AGENT_NAME" -o json 2>/dev/null \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(json.dumps({k: d.get(k) for k in ("declared_version_latest","generation_count","tool_count","system_prompt_prefix","token_estimate","models")}, indent=1))' + +# Every metric query looks back an hour instead of asking for the value now. An +# instant query only sees a series that got a sample inside the lookback window, +# so a run that finished ten minutes ago reads as "No data" and looks like a +# plugin that exports no metrics. +WINDOW="${METRICS_WINDOW:-1h}" +echo +echo "== metrics (client-side, emitted by the plugin, last $WINDOW)" +"${GCX[@]}" metrics query "count by (__name__) (last_over_time({__name__=~\"gen_ai_client.*\", gen_ai_agent_name=\"$AGENT_NAME\"}[$WINDOW]))" 2>&1 | head -14 +echo +echo "== token usage by type (last $WINDOW)" +"${GCX[@]}" metrics query "sum by (gen_ai_token_type) (last_over_time(gen_ai_client_token_usage_sum{gen_ai_agent_name=\"$AGENT_NAME\"}[$WINDOW]))" 2>&1 | head -10 +echo +echo "== cost (computed backend-side from model + tokens, last $WINDOW)" +"${GCX[@]}" metrics query "sum by (gen_ai_request_model) (last_over_time(agento11y_generation_cost_usd_total{gen_ai_agent_name=\"$AGENT_NAME\"}[$WINDOW]))" 2>&1 | head -10 + +echo +echo "== tool execution spans" +"${GCX[@]}" traces query "{resource.service.name=\"hermes\" && name=~\"execute_tool.*\"}" --since 1h --limit 10 2>&1 | head -8 +echo "(fewer spans here than tool calls usually means sampling on the receiving end;" +echo " confirm the exporter with the sink mode instead of assuming a plugin bug)" + +echo +echo "next: fetch a trace and inspect attributes" +echo " gcx --context $GCX_CONTEXT traces get -o json > $E2E_DIR/trace.json" +echo " python3 $SKILL_DIR/scripts/show-spans.py $E2E_DIR/trace.json" diff --git a/plugins/hermes/.gitignore b/plugins/hermes/.gitignore new file mode 100644 index 000000000..1608f4dec --- /dev/null +++ b/plugins/hermes/.gitignore @@ -0,0 +1,4 @@ +/build/ +.coverage +.coverage.* +.ruff_cache/ diff --git a/plugins/hermes/CHANGELOG.md b/plugins/hermes/CHANGELOG.md new file mode 100644 index 000000000..77debeb4b --- /dev/null +++ b/plugins/hermes/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +## Unreleased + +- Upgrade to Python SDK 0.17.x and default to `metadata_only`. Shared secret redaction protects captured content and tool-execution spans. +- Make automatic user/repo/branch tags opt-in and remove unconditional `cwd` tagging. + +## [0.10.0] - 2026-08-16 + +- Wrap every hook handler in a fail-open guard (b9ec134) + +## [0.9.0] - 2026-08-16 + +- Update the end-to-end tests for the new capture and fix false passes +- Record max_tokens, temperature, top_p and tool_choice from request body +- implement system prompt capture mode for hook payloads +- Tag tool spans and link them to the generation that requested them +- Document content capture mode +- Add end-to-end tests and update the hermes notes from them +- update hermes version compatibility +- Update Dependabot +- Update README.md +- Update README.md + +## [0.6.0] - 2026-08-15 + +- Publish releases to PyPI +- Point setup docs at the Agent Observability setup page +- Switch to the agento11y SDK and rename to grafana-agento11y-hermes + +## [0.5.0] - 2026-08-15 + +- Manage the project with uv and take the version from git tags + +## [0.4.0] - 2026-06-07 + +- bump sigil-sdk to 0.8.0 +- Send plugin User-Agent on generation export +- fix Grafana Cloud path +- add screenshot + +## [0.3.0] - 2026-06-07 + +- Derive OTLP auth headers from Sigil creds when unset + +## [0.2.0] - 2026-06-07 + +- Let the SDK derive tool-execution content capture +- Add SIGIL_HERMES_AGENT_VERSION +- sigil-sdk 0.5.0 +- Rename project from hermes-plugin-sigil to sigil-hermes +- llms.txt: point the token step at the in-stack setup URL +- Make the install verify recipe actually work +- Change URL pattern for AI Observability +- Clarify Grafana AI Observability plugin instructions (#2) +- Update README to correct plugin description +- Add llms.txt +- hooks: scope recorder→assistant pairing to this turn + +## [0.1.0] - 2026-05-01 + +- otel: drop SIGIL_OTEL_* schema, use standard OTEL_* envs +- config: adopt canonical SIGIL_* env-var schema +- hooks: move all tool work to post_tool_call +- hooks: bound generation span and duration to the LLM call window +- hooks: drop redundant set_result(input=...) at pre-hook time +- hooks: catch prep errors when closing pending generation recorders +- hooks: thread cfg.max_chars into _redact.safe_value +- redact: cap before materialization in safe_value +- initial commit diff --git a/plugins/hermes/README.md b/plugins/hermes/README.md new file mode 100644 index 000000000..cb982a1e9 --- /dev/null +++ b/plugins/hermes/README.md @@ -0,0 +1,116 @@ +# grafana-agento11y-hermes + +[Grafana Agent Observability](https://grafana.com/docs/grafana-cloud/machine-learning/agent-observability/) plugin for [Hermes Agent](https://github.com/NousResearch/hermes-agent). + +## Install + +The shared launcher does not support Hermes. `agento11y login`, its shared `config.env`, and its local mode do not configure this plugin. + +Install into the Python environment that runs Hermes, not an unrelated system Python. Python 3.11 or newer is required. Hermes 0.16.0 is the supported floor; the plugin was tested against Hermes 0.19.0. + +From the repository root, with Hermes's Python environment active: + +```sh +python -m pip install ./plugins/hermes +``` + +The package is `grafana-agento11y-hermes`. Its `hermes_agent.plugins` entry point is `agento11y = grafana_agento11y_hermes`. + +The privacy behavior documented below is unreleased. Published PyPI `0.10.0` defaults to full content and truncates payloads without shared secret redaction. Install from source to use metadata-only defaults and shared redaction. + +Add `agento11y` to `plugins.enabled` in `~/.hermes/config.yaml`, preserving other entries: + +```yaml +plugins: + enabled: + - agento11y +``` + +On Hermes 0.19.0, `hermes plugins enable` and `hermes plugins list` do not see pip-installed plugins. Enable through YAML and verify exported telemetry instead. + +If upgrading from `hermes-plugin-sigil`, uninstall that distribution first to avoid registering two plugins. Replace the old `sigil` enabled key with `agento11y`. Retired `SIGIL_*` variables remain compatibility inputs where supported; use `AGENTO11Y_*` for new configuration. + +For agent-assisted setup, use [llms.txt](llms.txt). + +## Configure the two channels + +Copy endpoints and credentials from your stack's Agent Observability setup page: + +```text +https://.grafana.net/a/grafana-agento11y-app/setup +``` + +Create a token and copy the environment block. Do not guess endpoint regions or authorization headers. Both channels can use the setup token, but each has its own endpoint: + +| Channel | Configuration | Data | +| --- | --- | --- | +| Generations | `AGENTO11Y_ENDPOINT`, `AGENTO11Y_PROTOCOL=http`, `AGENTO11Y_AUTH_MODE=basic`, `AGENTO11Y_AUTH_TENANT_ID`, `AGENTO11Y_AUTH_TOKEN` | One generation per LLM API call, including usage and timing | +| OpenTelemetry | `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS` | Generation and tool-execution spans, plus metrics | + +Tool executions have no separate generation-ingest record. Their arguments and results can also occur inside generation messages when content capture permits them. + +The channels are independently optional. Generation export activates with a token or an explicit non-`none` auth mode. An endpoint alone does not activate it. OTel setup requires the base OTLP endpoint or its `AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT` alias. The standard endpoint wins. HTTP exporters append `/v1/traces` and `/v1/metrics`. + +With neither channel configured, telemetry is disabled. To disable generations, remove generation credentials and set `AGENTO11Y_AUTH_MODE=none`. To disable plugin OTel setup, remove both base endpoint names and set `AGENTO11Y_HERMES_OTEL_AUTO=false`. Host-owned providers remain the host's responsibility. + +Set variables in the environment that starts Hermes. Hermes loads `~/.hermes/.env` with override enabled, so values there beat shell exports. The plugin does not read the launcher's `~/.config/agento11y/config.env`. + +## Privacy + +The plugin uses Python SDK 0.17.x for content capture and secret redaction. + +- `metadata_only` is the default. Message structure, tool names, model, usage, timing, IDs, and sampling parameters can leave the machine. Prompt text, responses, system prompts, tool schemas, tool I/O, and detailed error text do not. +- Set `AGENTO11Y_CONTENT_CAPTURE_MODE=full` only when you want content exported. `no_tool_content` still exports generation content. `full_with_metadata_spans` sends content only through generation ingest. +- `default`, an empty value, and unknown modes resolve to `metadata_only`; Hermes currently falls back without a warning. +- Shared secret redaction sanitizes exported content, including tool-execution spans. Structural truncation is not secret redaction. Pattern matching is not a guarantee that all secrets or personal data are removed. +- Prompt redaction defaults to on. `AGENTO11Y_REDACT_INPUT_MESSAGES=false` disables only user-prompt redaction; invalid values keep it on. Assistant text and errors use lightweight patterns. System prompts and tool payloads also use key/value patterns. Email addresses are redacted. +- No automatic `cwd` tag is emitted. Automatic user, repository, and branch tags are off by default. + +Opt into automatic client tags with `AGENTO11Y_AUTO_CODING_AGENT_TAGS=true`. Narrow them with `AGENTO11Y_AUTO_CODING_AGENT_TAGS_NAMES=user,repo`; accepted names are `user`, `repo`, `branch`, or `all`. The allowlist alone enables nothing. Explicit `AGENTO11Y_TAGS` values win over automatic values. + +These tags reach generations, spans, and metric labels. User identities can be personal data, and branch names increase metric cardinality. Hermes caches its client for the process, so automatic values do not follow later directory or branch changes. Capture modes do not remove custom tags or metadata; never put secrets there. + +See [Content Capture Modes](../../docs/concepts/content-capture-modes.md) and [Tags and Metadata](../../docs/concepts/tags-and-metadata.md). + +## Other settings + +| Variable | Default | Purpose | +| --- | --- | --- | +| `AGENTO11Y_AGENT_NAME` | `hermes` | Agent identity | +| `OTEL_SERVICE_NAME` | `hermes` | OTel service identity | +| `AGENTO11Y_DEBUG` | `false` | Diagnostic logging | +| `AGENTO11Y_HERMES_SAMPLE_RATE` | `1.0` | Fraction of calls recorded, from `0.0` to `1.0`; not a privacy control | +| `AGENTO11Y_HERMES_MAX_CHARS` | `12000` | Per-string bound on tool payloads | +| `AGENTO11Y_HERMES_OTEL_AUTO` | `true` | Install missing OTel providers; never replace host-owned providers | +| `AGENTO11Y_HERMES_ERROR_FLUSH_TIMEOUT` | `2.0` | Maximum wait in seconds for the error-path flush | +| `AGENTO11Y_HEADERS` | unset | Extra generation-export headers | + +## Verify and troubleshoot + +With permission to send telemetry, start interactive Hermes with `AGENTO11Y_DEBUG=true`. Check `~/.hermes/logs/agent.log` for client initialization and installed OTel providers. A host-owned provider does not produce an installation message. + +Run one turn and check generations in Agent Observability and traces/metrics in their respective data sources. One working channel does not prove the other works. `hermes -z` disables logging, so missing log lines in one-shot mode do not prove failure. + +Hermes can clip request payloads before hooks run. The plugin reuses cached request facts where possible; `hermes.request_facts_reused` marks that reuse. A tool inventory may be stale after `tool_search`. Sampling parameters are not borrowed from a different model. `HERMES_PLUGIN_PAYLOAD_MAX_CHARS` can reduce envelope truncation, but cannot undo Hermes's per-string clipping. + +Generations use synchronous mode because released hooks do not provide a reliable first-token signal. No time-to-first-token histogram is promised. Tool spans can end after their already-ended parent generation span. + +## Development + +From the repository root: + +```sh +mise run format:py:plugin-hermes +mise run lint:py:plugin-hermes +mise run typecheck:py:plugin-hermes +mise run test:py:plugin-hermes +mise run build:py:plugin-hermes +``` + +The build task checks the wheel and source distribution. Root `mise run check` includes this artifact check. CI tests Python 3.11, 3.12, 3.13, and 3.14 with branch coverage enabled and a 99% minimum. + +Real-Hermes end-to-end testing is optional, not part of the normal checks. Read [.agents/skills/e2e-test/SKILL.md](.agents/skills/e2e-test/SKILL.md) for a credential-free loopback recipe. A local telemetry sink alone does not make the model provider local. + +## License + +Copyright 2026 Grafana Labs. [Apache-2.0](../../LICENSE). diff --git a/plugins/hermes/llms.txt b/plugins/hermes/llms.txt new file mode 100644 index 000000000..0d8b0c4df --- /dev/null +++ b/plugins/hermes/llms.txt @@ -0,0 +1,97 @@ +# grafana-agento11y-hermes: agent setup guide + +Source: https://github.com/grafana/agento11y/tree/main/plugins/hermes +Read the README there before installing, including its warning about privacy +differences between the source and published PyPI `0.10.0`. + +## Scope + +Install `grafana-agento11y-hermes` in the Python environment that runs Hermes. +The `hermes_agent.plugins` entry point is `agento11y = grafana_agento11y_hermes`. +There is no `agento11y hermes` launcher. Shared launcher login, `config.env`, +local viewer, and local mode do not apply. Do not send the user through those flows. + +## Explain privacy before enabling export + +The plugin uses SDK 0.17.x and defaults to `metadata_only`. +Structure, tool names, model, usage, timing, IDs, and +sampling parameters may leave the machine. Prompts, responses, system prompts, +tool descriptions/schemas, tool I/O, and detailed errors do not. + +`full` opts into content. `no_tool_content` still exports generation content; +`full_with_metadata_spans` exports content through generation ingest but not +spans. `default` and invalid modes fall back to `metadata_only`. + +Shared secret redaction sanitizes exported content and tool-execution spans. +It is pattern matching, not complete personal-data protection. Truncation and +sampling are not redaction. Prompt redaction defaults to on. +`AGENTO11Y_REDACT_INPUT_MESSAGES=false` disables only user-prompt redaction; +invalid values keep it on. Assistant text and errors use lightweight patterns. +System prompts and tool payloads also use key/value patterns. Email addresses +are redacted. + +Automatic user/repo/branch tags are off by default. No automatic `cwd` is sent. +`AGENTO11Y_AUTO_CODING_AGENT_TAGS=true` enables automatic client tags. +`AGENTO11Y_AUTO_CODING_AGENT_TAGS_NAMES=user,repo` narrows the enabled names; +`branch` and `all` are also accepted. The list alone enables nothing. +Explicit `AGENTO11Y_TAGS` values win over automatic values. +Explain that user tags may contain personal data and branch tags add metric +series. Hermes freezes these values when its process-wide client is built. +Capture modes do not remove custom tags or metadata. Keep secrets out of both. + +## Install and enable + +1. Find the interpreter behind `hermes`; do not assume the shell's Python is correct. +2. With that environment active, run `python -m pip install ./plugins/hermes` + from the repository root. Ask before installing or changing files. +3. Add `agento11y` to `plugins.enabled` in `~/.hermes/config.yaml`, preserving + other plugins. On Hermes 0.19.0, the enable/list CLI misses pip plugins. +4. If replacing `hermes-plugin-sigil`, uninstall it and replace the enabled + `sigil` key. Prefer current `AGENTO11Y_*` variables over supported legacy aliases. + +Python 3.11+ is required. Hermes 0.16.0 is the floor; the plugin was tested +against 0.19.0. Do not assume newer hook contracts from upstream HEAD. + +## Configure only the destinations the user approves + +The plugin has independent generation and OpenTelemetry channels: + +- Generations use `AGENTO11Y_ENDPOINT`, `AGENTO11Y_PROTOCOL=http`, + `AGENTO11Y_AUTH_MODE=basic`, `AGENTO11Y_AUTH_TENANT_ID`, and `AGENTO11Y_AUTH_TOKEN`. + A token or explicit non-`none` auth mode activates this channel; an endpoint alone does not. +- OTel uses `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_HEADERS`. + The base endpoint activates setup. `AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT` + is an alias; the standard name wins. HTTP exporters append the signal paths. + Tool executions are spans and metrics, not separate generation-ingest records. + +For Cloud, ask the user to open +`https://.grafana.net/a/grafana-agento11y-app/setup`, create a token, +and copy the environment block into their chosen secret store or environment. +Do not ask them to paste a live token into chat. Never guess endpoint regions, +construct credentials from placeholders, or repeat tokens in summaries. +The setup token can serve both channels. + +Ask where settings should live before editing anything. Hermes reads its process +environment and `~/.hermes/.env`; that file overrides shell exports. +It does not read the shared launcher's config file. Preserve existing OTLP settings. + +With no channels configured, telemetry is disabled. For generation export off, +remove credentials and set `AGENTO11Y_AUTH_MODE=none`. For plugin OTel setup off, +remove both base endpoint names and set `AGENTO11Y_HERMES_OTEL_AUTO=false`. +Host-owned providers remain the host's responsibility. + +## Verify only with permission + +Use interactive `AGENTO11Y_DEBUG=true hermes`, then inspect +`~/.hermes/logs/agent.log`. One-shot `hermes -z` disables logging. +Run a turn only after the user approves the provider and telemetry destinations. +Check generation arrival separately from traces and metrics. + +For optional credential-free testing, read `.agents/skills/e2e-test/SKILL.md` +under the plugin directory and inspect its scripts first. Use an explicit +LOOPBACK model provider. Route both telemetry channels to loopback receivers, +or explicitly disable each unused channel. A telemetry sink does not disable +paid model calls. Do not run the Cloud verification scripts by default. + +Plugin-specific knobs, limitations, and troubleshooting are in README.md. +Contributor constraints and check commands are in the repository-root AGENTS.md. diff --git a/plugins/hermes/pyproject.toml b/plugins/hermes/pyproject.toml new file mode 100644 index 000000000..66bc7a3a7 --- /dev/null +++ b/plugins/hermes/pyproject.toml @@ -0,0 +1,78 @@ +[build-system] +requires = ["hatchling>=1.18"] +build-backend = "hatchling.build" + +[project] +name = "grafana-agento11y-hermes" +version = "0.10.0" +description = "Grafana Agent Observability plugin for Hermes Agent. Records LLM calls and tool executions as generations and OTel traces+metrics." +readme = "README.md" +license = { text = "Apache-2.0" } +authors = [{ name = "Alexander Akhmetov" }] +requires-python = ">=3.11" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Libraries", +] +dependencies = [ + "agento11y>=0.17,<0.18", + "opentelemetry-sdk>=1.27", + "opentelemetry-exporter-otlp-proto-http>=1.27", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-cov>=6.0", + "ruff>=0.16.3", + "ty==0.0.73", +] + +[project.entry-points."hermes_agent.plugins"] +agento11y = "grafana_agento11y_hermes" + +[project.urls] +Homepage = "https://github.com/grafana/agento11y/tree/main/plugins/hermes" +Repository = "https://github.com/grafana/agento11y" + +[tool.hatch.build.targets.wheel] +packages = ["src/grafana_agento11y_hermes"] + +[tool.hatch.build.targets.sdist] +include = [ + "src/grafana_agento11y_hermes", + "README.md", + "CHANGELOG.md", +] + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] +ignore = ["E501"] + +[tool.ty.environment] +# The lowest version we support, so ty catches use of a 3.12+ feature. +python-version = "3.11" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.coverage.run] +source = ["grafana_agento11y_hermes"] +branch = true + +[tool.coverage.report] +show_missing = true +skip_covered = true +fail_under = 99 diff --git a/plugins/hermes/scripts/check-package.py b/plugins/hermes/scripts/check-package.py new file mode 100644 index 000000000..4743b57e9 --- /dev/null +++ b/plugins/hermes/scripts/check-package.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import argparse +import email.parser +import importlib.metadata +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +import tomllib +import zipfile +from pathlib import Path + +DISTRIBUTION = "grafana-agento11y-hermes" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def check_installed(expected: str) -> None: + import agento11y + + from grafana_agento11y_hermes._version import plugin_user_agent + + dist = importlib.metadata.distribution(DISTRIBUTION) + require(dist.version == expected, f"Installed version {dist.version} != {expected}") + entries = [ep for ep in dist.entry_points if ep.group == "hermes_agent.plugins" and ep.name == "agento11y"] + require(len(entries) == 1, "Expected one Hermes agento11y entry point") + module = entries[0].load() + require(callable(module.register), "Hermes register is not callable") + for loaded in (module, agento11y): + require( + Path(loaded.__file__).resolve().is_relative_to(Path(sys.prefix).resolve()), + "Import escaped installed environment", + ) + tokens = plugin_user_agent().split() + require(tokens[0] == f"agento11y-plugin-hermes/{expected}", "Plugin User-Agent version mismatch") + require( + tokens[1] == f"agento11y-sdk-python/{importlib.metadata.version('agento11y')}", + "SDK User-Agent version mismatch", + ) + print(f"Installed entry point and User-Agent verified: {dist.version}") + + +def check_metadata(artifact: Path, expected: str) -> None: + if artifact.suffix == ".whl": + with zipfile.ZipFile(artifact) as wheel: + names = [name for name in wheel.namelist() if name.endswith(".dist-info/METADATA")] + require(len(names) == 1, "Expected one wheel METADATA") + data = wheel.read(names[0]) + else: + with tarfile.open(artifact) as sdist: + names = [name for name in sdist.getnames() if name.count("/") == 1 and name.endswith("/PKG-INFO")] + require(len(names) == 1, "Expected one sdist PKG-INFO") + stream = sdist.extractfile(names[0]) + if stream is None: + raise ValueError("Missing sdist metadata") + data = stream.read() + metadata = email.parser.BytesParser().parsebytes(data) + require(metadata["Name"] == DISTRIBUTION, f"Unexpected distribution in {artifact.name}") + require(metadata["Version"] == expected, f"Artifact version {metadata['Version']} != {expected}") + + +def check_build(expected: str | None) -> None: + root = Path(__file__).resolve().parents[1] + project = tomllib.loads((root / "pyproject.toml").read_text())["project"] + expected = expected or project["version"] + require(project["version"] == expected, f"Project version {project['version']} != {expected}") + with tempfile.TemporaryDirectory(prefix="hermes-package-") as temporary: + work = Path(temporary) + home = work / "home" + home.mkdir() + env = { + "PATH": os.environ["PATH"], + "HOME": str(home), + "TMPDIR": str(work), + "UV_CACHE_DIR": subprocess.check_output(["uv", "--no-config", "cache", "dir"], text=True).strip(), + } + + def uv(*args: str | Path, cwd: Path = work) -> None: + subprocess.run(["uv", "--no-config", *map(str, args)], cwd=cwd, env=env, check=True) + + requirements = work / "requirements.txt" + uv( + "export", + "--locked", + "--no-dev", + "--no-emit-project", + "--no-hashes", + "--python", + sys.executable, + "--output-file", + requirements, + cwd=root, + ) + dist = work / "dist" + uv("build", "--no-sources", "--python", sys.executable, "--sdist", "--wheel", "--out-dir", dist, root) + (wheel,) = dist.glob("*.whl") + (sdist,) = dist.glob("*.tar.gz") + check_metadata(wheel, expected) + check_metadata(sdist, expected) + rebuilt = work / "rebuilt" + uv("build", "--no-sources", "--python", sys.executable, "--wheel", "--out-dir", rebuilt, sdist) + (rebuilt_wheel,) = rebuilt.glob("*.whl") + check_metadata(rebuilt_wheel, expected) + checker = work / "check-package.py" + shutil.copyfile(__file__, checker) + for index, artifact in enumerate((wheel, rebuilt_wheel)): + venv = work / f"venv-{index}" + uv("venv", "--python", sys.executable, venv) + python = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + uv("pip", "install", "--python", python, "--requirements", requirements, artifact) + subprocess.run([str(python), "-I", str(checker), "--installed", expected], cwd=work, env=env, check=True) + print("Wheel and rebuilt sdist verified outside the checkout") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--installed", metavar="VERSION") + parser.add_argument("--expected-version") + args = parser.parse_args() + if args.installed: + check_installed(args.installed) + else: + check_build(args.expected_version) diff --git a/plugins/hermes/scripts/run-check.sh b/plugins/hermes/scripts/run-check.sh new file mode 100644 index 000000000..0c4f7bb0b --- /dev/null +++ b/plugins/hermes/scripts/run-check.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." +home=$(mktemp -d) +trap 'rm -rf "$home"' EXIT +uv_bin=$(command -v uv) +cache=$(uv --no-config cache dir) +pythons=$(uv --no-config python dir) + +# Provider keys, telemetry credentials, user config, and PYTHONPATH must not reach checks. +# Expand the script's variables only in the clean child shell. +# shellcheck disable=SC2016 +env -i PATH="$PATH" HOME="$home" TMPDIR="$home" UV_CACHE_DIR="$cache" UV_PYTHON_INSTALL_DIR="$pythons" \ + bash -eu -o pipefail -c ' + uv_bin=$1 + mode=$2 + python=$3 + run() { "$uv_bin" --no-config run --locked --isolated --no-env-file --python "$python" "$@"; } + case "$mode" in + format) run ruff format .; run ruff check --fix . ;; + lint) run ruff format --check .; run ruff check . ;; + typecheck) run ty check ;; + test) run python -m pytest --cov ;; + build) run python scripts/check-package.py ;; + *) echo "Unknown check: $mode" >&2; exit 2 ;; + esac + ' bash "$uv_bin" "${1:?expected format, lint, typecheck, test, or build}" "${2:-3.11}" diff --git a/plugins/hermes/src/grafana_agento11y_hermes/__init__.py b/plugins/hermes/src/grafana_agento11y_hermes/__init__.py new file mode 100644 index 000000000..65ce915e3 --- /dev/null +++ b/plugins/hermes/src/grafana_agento11y_hermes/__init__.py @@ -0,0 +1,82 @@ +"""grafana-agento11y-hermes: Grafana Agent Observability plugin for Hermes Agent. + +Records every LLM API call (`pre_api_request`/`post_api_request`) as a +generation and every tool invocation (`post_tool_call`) as a tool execution. +On `on_session_end`, flushes the SDK's HTTP exporter and any OTel providers the +plugin installed. + +Configuration is the canonical ``AGENTO11Y_*`` schema for the generations +channel and the standard OpenTelemetry ``OTEL_*`` schema for the OTel channel. +See README: + - Generations: ``AGENTO11Y_ENDPOINT`` / ``AGENTO11Y_PROTOCOL`` / + ``AGENTO11Y_AUTH_*`` + - OTel: ``OTEL_EXPORTER_OTLP_ENDPOINT`` / + ``OTEL_EXPORTER_OTLP_HEADERS`` / ``OTEL_SERVICE_NAME`` + - Plugin-only: ``AGENTO11Y_HERMES_SAMPLE_RATE`` / + ``AGENTO11Y_HERMES_MAX_CHARS`` / ``AGENTO11Y_HERMES_OTEL_AUTO`` + +The plugin fails open: missing credentials, SDK errors, exporter failures, and +network errors all become silent no-ops after at most one warning log. The +hermes agent loop is never blocked or interrupted by telemetry issues. +""" + +from __future__ import annotations + +from ._compat import apply_legacy_env +from ._hooks import ( + on_api_request_error, + on_post_api_request, + on_post_llm_call, + on_post_tool_call, + on_pre_api_request, + on_pre_llm_call, + on_session_end, + on_session_finalize, +) + + +def register(ctx) -> None: + # Runs before anything reads config, so the SDK sees only AGENTO11Y_* names. + apply_legacy_env() + + # LEGACY: pre_llm_call / post_llm_call are turn-scoped and serve only the + # fallback for hermes older than v2026.6.5 (PyPI 0.16.0), which sends no + # api_request_id. On current hermes the API-request hooks carry both the + # input messages and the assistant message, so a generation opens and + # closes within them. + # + # We deliberately do not register pre_tool_call: post_tool_call is the only + # hook of the pair carrying the result, the status and duration_ms, so a + # recorder opened in pre would sit open across the call for nothing. + # + # api_request_error closes the generation for a call that failed. It covers + # the retryable provider failures, but not every retry path: some re-enter + # pre_api_request with the same api_request_id and fire no hook at all, + # which is why on_pre_api_request also closes whatever a repeated id + # displaces. + ctx.register_hook("pre_llm_call", on_pre_llm_call) + ctx.register_hook("post_llm_call", on_post_llm_call) + ctx.register_hook("pre_api_request", on_pre_api_request) + ctx.register_hook("post_api_request", on_post_api_request) + ctx.register_hook("api_request_error", on_api_request_error) + # + # on_session_end fires per completed turn, so a turn that dies on a + # provider error never reaches it. on_session_finalize fires once at CLI + # exit either way, and is the only session hook the interactive failure + # path gets. + ctx.register_hook("post_tool_call", on_post_tool_call) + ctx.register_hook("on_session_end", on_session_end) + ctx.register_hook("on_session_finalize", on_session_finalize) + + +__all__ = [ + "register", + "on_pre_llm_call", + "on_post_llm_call", + "on_pre_api_request", + "on_post_api_request", + "on_api_request_error", + "on_post_tool_call", + "on_session_end", + "on_session_finalize", +] diff --git a/plugins/hermes/src/grafana_agento11y_hermes/_client.py b/plugins/hermes/src/grafana_agento11y_hermes/_client.py new file mode 100644 index 000000000..147e3c3ec --- /dev/null +++ b/plugins/hermes/src/grafana_agento11y_hermes/_client.py @@ -0,0 +1,192 @@ +"""Lazy SDK client construction. + +The client is built on first hook invocation. If neither the generations nor +the OTel channel is configured, the plugin is fully no-op. If construction +fails, the failure is cached and handlers never retry. + +Transport and auth use SDK resolution. Capture defaults to metadata_only, like +sibling coding-agent plugins. Content capture requires an explicit mode. +""" + +from __future__ import annotations + +import logging +import os +import threading +from typing import Any + +from . import _config, _otel, _tags + +logger = logging.getLogger(__name__) + +_INIT_FAILED = object() +_CLIENT: Any = None +_CONFIG: _config.PluginConfig | None = None +_LOCK = threading.Lock() + + +def _generation_headers(cfg: _config.PluginConfig) -> dict[str, str]: + """Headers for the generation export: user ``AGENTO11Y_HEADERS`` plus our User-Agent. + + Setting headers explicitly suppresses the SDK's own ``AGENTO11Y_HEADERS`` + lookup, so we merge that env-derived dict back in. A user-supplied + ``User-Agent`` (via ``AGENTO11Y_HEADERS``) wins over the plugin default. Auth + headers are still layered on top by the SDK's resolver. + """ + from ._version import plugin_user_agent + + headers = dict(cfg.export_headers) + if not any(key.lower() == "user-agent" for key in headers): + headers["User-Agent"] = plugin_user_agent() + return headers + + +def _to_client_config(cfg: _config.PluginConfig): + """Keep SDK transport resolution and apply plugin privacy defaults.""" + from agento11y import ClientConfig, ContentCaptureMode, GenerationExportConfig + from agento11y.redaction import SecretRedactionOptions, create_secret_redaction_sanitizer + + raw_mode = ( + os.environ.get("AGENTO11Y_CONTENT_CAPTURE_MODE", "").strip() + or os.environ.get("SIGIL_CONTENT_CAPTURE_MODE", "").strip() + ).lower() + try: + mode = ContentCaptureMode(raw_mode) + except ValueError: + mode = ContentCaptureMode.METADATA_ONLY + if mode == ContentCaptureMode.DEFAULT: + mode = ContentCaptureMode.METADATA_ONLY + redact_inputs = ( + os.environ.get("AGENTO11Y_REDACT_INPUT_MESSAGES", "").strip() + or os.environ.get("SIGIL_REDACT_INPUT_MESSAGES", "").strip() + ).lower() not in {"0", "false", "no", "off"} + overrides: dict[str, Any] = { + "tags": _tags.client_tags(), + "content_capture": mode, + "generation_sanitizer": create_secret_redaction_sanitizer( + SecretRedactionOptions(redact_input_messages=redact_inputs) + ), + } + + if cfg.generations_configured: + return ClientConfig( + generation_export=GenerationExportConfig(headers=_generation_headers(cfg)), + **overrides, + ) + + return ClientConfig( + generation_export=GenerationExportConfig(protocol="none"), + **overrides, + ) + + +def _get_client(create_if_missing: bool = True) -> Any: + """Return the cached client or ``None`` if init has failed or cannot run.""" + global _CLIENT, _CONFIG + + if _CLIENT is _INIT_FAILED: + return None + if _CLIENT is not None: + return _CLIENT + if not create_if_missing: + return None + + with _LOCK: + if _CLIENT is _INIT_FAILED: + return None + if _CLIENT is not None: + return _CLIENT + + cfg = _config.load() + if not (cfg.generations_configured or cfg.otel_configured): + logger.warning( + "grafana-agento11y-hermes: no channel configured. Set AGENTO11Y_AUTH_TOKEN " + "(with AGENTO11Y_ENDPOINT/AGENTO11Y_PROTOCOL/AGENTO11Y_AUTH_*) for generations, " + "or OTEL_EXPORTER_OTLP_ENDPOINT for traces+metrics. Telemetry disabled." + ) + _CLIENT = _INIT_FAILED + return None + + # OTel setup is independent of the SDK client. Fine if it returns False, + # the generations channel can still work. + _otel.setup_if_needed(cfg) + + try: + from agento11y import Client + + override = _to_client_config(cfg) + _CLIENT = Client() if override is None else Client(override) + _CONFIG = cfg + logger.info( + "grafana-agento11y-hermes: client initialized (generations=%s, otel=%s)", + "configured" if cfg.generations_configured else "unconfigured", + "configured" if cfg.otel_configured else "unconfigured", + ) + return _CLIENT + except Exception as exc: + logger.warning("grafana-agento11y-hermes: failed to initialize client: %s", exc) + _CLIENT = _INIT_FAILED + return None + + +def _get_plugin_config() -> _config.PluginConfig | None: + """Return the resolved plugin config, or ``None`` if the client never initialized.""" + return _CONFIG + + +def _flush_otel(timeout_millis: int | None = None) -> None: + """Force-flush OTel providers we installed. No-op for host-owned providers.""" + _otel.force_flush(timeout_millis) + + +def _flush_channels(otel_timeout_millis: int | None = None) -> None: + """Drain both channels. ``Client.flush()`` covers generations only.""" + client = _get_client(create_if_missing=False) + if client is not None: + try: + client.flush() + except Exception as exc: + logger.warning("grafana-agento11y-hermes: client.flush failed: %s", exc) + _flush_otel(otel_timeout_millis) + + +def flush_bounded(timeout: float) -> bool: + """Flush both channels, giving up after ``timeout`` seconds. + + For the paths that end the process without a session-end hook. Hermes + one-shot mode fires no session hook when a turn dies on a provider error, + and then exits through ``hermes_cli/main.py`` ``_exit_after_oneshot``, which + calls ``os._exit`` and so skips the SDK's own atexit flush. Without a flush + here the failed generation is recorded and then dropped. + + The flush runs on a daemon thread and the wait is bounded, because a + blocking flush against an unreachable endpoint would otherwise stall the + hermes loop, which the fail-open invariant forbids. Losing the record on + timeout is the same outcome as not flushing at all. + + Returns True when the flush finished inside the timeout. + """ + if timeout <= 0: + return False + + done = threading.Event() + + def run() -> None: + try: + _flush_channels(otel_timeout_millis=int(timeout * 1000)) + finally: + done.set() + + threading.Thread(target=run, name="agento11y-hermes-flush", daemon=True).start() + if done.wait(timeout): + return True + logger.debug("grafana-agento11y-hermes: flush did not finish within %ss", timeout) + return False + + +def _reset_for_tests() -> None: + global _CLIENT, _CONFIG + with _LOCK: + _CLIENT = None + _CONFIG = None + _otel._reset_for_tests() diff --git a/plugins/hermes/src/grafana_agento11y_hermes/_coerce.py b/plugins/hermes/src/grafana_agento11y_hermes/_coerce.py new file mode 100644 index 000000000..909abae37 --- /dev/null +++ b/plugins/hermes/src/grafana_agento11y_hermes/_coerce.py @@ -0,0 +1,70 @@ +"""Type coercion for whatever hermes put on a hook payload. + +Hermes mirrors provider shapes rather than normalizing them, so a field can +arrive as a string, a number, a typed content block or a list of them. These +helpers are generic: they hold no hook semantics, which is why both ``_hooks`` +and ``_request`` can read them without one importing the other. +""" + +from __future__ import annotations + +import json +from typing import Any + + +def coerce_text(content: Any) -> str: + """Best-effort conversion of a message ``content`` field to a string. + + Content can be a string or a list of typed blocks + (``{"type": "text", "text": "..."}``). We collapse list blocks into + newline-joined text. + """ + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + chunks: list[str] = [] + for block in content: + if isinstance(block, str): + chunks.append(block) + elif isinstance(block, dict): + if isinstance(block.get("text"), str): + chunks.append(block["text"]) + elif isinstance(block.get("content"), str): + chunks.append(block["content"]) + else: + chunks.append(json.dumps(block, default=str)) + else: + chunks.append(repr(block)) + return "\n".join(c for c in chunks if c) + return str(content) + + +def as_int(value: Any) -> int: + """Integer value, or 0 when it cannot be converted. + + Token usage is built inside ``set_result``'s argument list, so a provider + that reports a count as text would abort the close before it and export a + generation with no input, output, usage or model at all. + """ + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def as_optional_int(value: Any) -> int | None: + """Integer value, or ``None`` when absent or unconvertible.""" + try: + return int(value) + except (TypeError, ValueError): + return None + + +def as_optional_float(value: Any) -> float | None: + """Float value, or ``None`` when absent or unconvertible.""" + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/plugins/hermes/src/grafana_agento11y_hermes/_compat.py b/plugins/hermes/src/grafana_agento11y_hermes/_compat.py new file mode 100644 index 000000000..8cb90813f --- /dev/null +++ b/plugins/hermes/src/grafana_agento11y_hermes/_compat.py @@ -0,0 +1,108 @@ +"""Promote supported legacy settings before plugin and SDK configuration is read.""" + +from __future__ import annotations + +import logging +import os +from collections.abc import MutableMapping + +logger = logging.getLogger(__name__) + +# Keep plugin aliases local: SDK-private rename tables are not an API. +_RENAMES = { + f"SIGIL_{suffix}": f"AGENTO11Y_{suffix}" + for suffix in ( + "AGENT_NAME", + "AGENT_VERSION", + "ATTEMPT", + "AUTH_MODE", + "AUTH_TENANT_ID", + "AUTH_TOKEN", + "CONTENT_CAPTURE_MODE", + "DEBUG", + "ENDPOINT", + "EXPERIMENT_ID", + "GRAFANA_URL", + "HEADERS", + "INGEST_ACTOR", + "INSECURE", + "PROTOCOL", + "REDACT_INPUT_MESSAGES", + "SERVICE_ACCOUNT_TOKEN", + "SUITE_ID", + "SUITE_VERSION", + "TAGS", + "TEST_CASE_ID", + "TRAJECTORY_ID", + "USE_EXPERIMENTAL_OTEL", + "USER_ID", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "HERMES_AGENT_VERSION", + "HERMES_MAX_CHARS", + "HERMES_OTEL_AUTO", + "HERMES_SAMPLE_RATE", + "HERMES_ERROR_FLUSH_TIMEOUT", + ) +} +_RENAMES.update( + { + "SIGIL_API_ENDPOINT": "AGENTO11Y_ENDPOINT", + "SIGIL_TENANT_ID": "AGENTO11Y_AUTH_TENANT_ID", + } +) + +_applied = False + + +def renames() -> dict[str, str]: + """Return a copy so callers cannot change alias precedence.""" + return dict(_RENAMES) + + +def apply_legacy_env(env: MutableMapping[str, str] | None = None) -> list[str]: + """Promote any set legacy var to its ``AGENTO11Y_*`` name. + + Returns the old names that were promoted, for tests. Runs its body once per + process; later calls return an empty list. Pass ``env`` to act on a dict + instead of ``os.environ``, which also bypasses the once-only guard. + """ + global _applied + + target: MutableMapping[str, str] + if env is None: + if _applied: + return [] + _applied = True + target = os.environ + else: + target = env + + promoted = [] + for old, new in renames().items(): + value = target.get(old) + if value is None: + continue + if (target.get(new) or "").strip(): + logger.warning( + "grafana-agento11y-hermes: %s and %s are both set, using %s", + old, + new, + new, + ) + continue + target[new] = value + promoted.append(old) + + if promoted: + logger.warning( + "grafana-agento11y-hermes: applied %d renamed env %s for now. Rename %s.", + len(promoted), + "var" if len(promoted) == 1 else "vars", + ", ".join(f"{old} to {new}" for old, new in sorted((o, renames()[o]) for o in promoted)), + ) + return promoted + + +def _reset_for_tests() -> None: + global _applied + _applied = False diff --git a/plugins/hermes/src/grafana_agento11y_hermes/_config.py b/plugins/hermes/src/grafana_agento11y_hermes/_config.py new file mode 100644 index 000000000..bbc6e938c --- /dev/null +++ b/plugins/hermes/src/grafana_agento11y_hermes/_config.py @@ -0,0 +1,175 @@ +"""Plugin-specific configuration for grafana-agento11y-hermes. + +Transport, auth, agent identity, debug, and content-capture-mode resolution +are owned by the SDK's ``Client()`` constructor. See the canonical +``AGENTO11Y_*`` schema (``AGENTO11Y_ENDPOINT``, ``AGENTO11Y_PROTOCOL``, +``AGENTO11Y_AUTH_*``, ``AGENTO11Y_AGENT_NAME``, ``AGENTO11Y_DEBUG``, +``AGENTO11Y_CONTENT_CAPTURE_MODE``). + +OTel exporter and resource resolution follow the standard OpenTelemetry env +schema (``OTEL_EXPORTER_OTLP_ENDPOINT``, ``OTEL_EXPORTER_OTLP_HEADERS``, +``OTEL_SERVICE_NAME``, ``OTEL_RESOURCE_ATTRIBUTES``); the OTLP HTTP exporters +read these themselves. The one exception is +``AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT``, the branded alias the sibling +plugins accept, which this module resolves into ``otel_endpoint_override`` for +``_otel.py`` to pass on. + +This module resolves plugin-specific knobs under the ``AGENTO11Y_HERMES_*`` +prefix (matching the ``AGENTO11Y_PI_*`` / ``AGENTO11Y_COPILOT_*`` convention +used by sibling plugins) and tracks two presence flags driving channel +decisions in ``_client.py`` and ``_otel.py``. + +As a convenience it also derives OTLP auth headers from the generations +basic-auth pair (``AGENTO11Y_AUTH_TENANT_ID`` + ``AGENTO11Y_AUTH_TOKEN``). +``_otel.py`` applies these only when the user has not set +``OTEL_EXPORTER_OTLP_HEADERS`` (nor the per-signal overrides). Auth is all they +cover; the endpoint comes from the two endpoint vars above. +""" + +from __future__ import annotations + +import base64 +import logging +import os +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class PluginConfig: + sample_rate: float = 1.0 + max_chars: int = 12000 + otel_auto: bool = True + error_flush_timeout: float = 2.0 + generations_configured: bool = False + otel_configured: bool = False + otel_endpoint_override: str = "" + otel_auth_headers: dict[str, str] = field(default_factory=dict) + export_headers: dict[str, str] = field(default_factory=dict) + + +def _env(name: str) -> str: + return os.environ.get(name, "").strip() + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.environ.get(name, "").strip().lower() + if not raw: + return default + return raw in {"1", "true", "yes", "on"} + + +def _env_float(name: str, default: float) -> float: + raw = os.environ.get(name, "").strip() + if not raw: + return default + try: + return float(raw) + except ValueError: + logger.warning("grafana-agento11y-hermes: invalid %s=%r, using default %s", name, raw, default) + return default + + +def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name, "").strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + logger.warning("grafana-agento11y-hermes: invalid %s=%r, using default %s", name, raw, default) + return default + + +def _generations_configured() -> bool: + if _env("AGENTO11Y_AUTH_TOKEN"): + return True + mode = _env("AGENTO11Y_AUTH_MODE").lower() + return bool(mode) and mode != "none" + + +def _otel_endpoint_override() -> str: + """The branded OTLP endpoint, but only when the standard env is unset. + + ``AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT`` is the branded alias the sibling + plugins accept, so setups carrying only that name reach us too. The OTLP + exporters know the standard name only, so such an install would otherwise + run with the OTel channel silently off. + + Returned separately from ``otel_configured`` because ``_otel.py`` has to + pass this value to the exporters explicitly. Empty when the standard env is + set, so the exporters keep reading it themselves. + """ + if _env("OTEL_EXPORTER_OTLP_ENDPOINT"): + return "" + return _env("AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT") + + +def _otel_configured() -> bool: + return bool(_env("OTEL_EXPORTER_OTLP_ENDPOINT") or _env("AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT")) + + +def _parse_kv_csv(raw: str) -> dict[str, str]: + """Parse ``key=value,key=value`` like the SDK's own header parser.""" + out: dict[str, str] = {} + for part in raw.split(","): + part = part.strip() + if not part or "=" not in part: + continue + key, value = part.split("=", 1) + key = key.strip() + if key: + out[key] = value.strip() + return out + + +def _export_headers() -> dict[str, str]: + """Extra generation-export headers from ``AGENTO11Y_HEADERS``. + + The SDK reads ``AGENTO11Y_HEADERS`` only when no headers are set on the + config. Since the plugin sets ``GenerationExportConfig.headers`` explicitly + to inject its User-Agent (see ``_client``), that lookup is suppressed, so we + mirror it here and merge the result back in. + """ + return _parse_kv_csv(_env("AGENTO11Y_HEADERS")) + + +def _otel_auth_headers() -> dict[str, str]: + """Basic-auth headers derived from the generations credentials, for OTLP. + + Mirrors the SDK's ``basic`` mode: ``Authorization: Basic base64(tenant:token)`` + plus ``X-Scope-OrgID: tenant``. ``_otel.py`` uses these only when the user has + not set ``OTEL_EXPORTER_OTLP_HEADERS`` (nor the per-signal overrides). + + Returns an empty dict when either value is missing, or when + ``AGENTO11Y_AUTH_MODE`` is explicitly ``bearer``. The token is then a bearer + token, not a basic password, so deriving basic auth from it would be wrong. + """ + if _env("AGENTO11Y_AUTH_MODE").lower() == "bearer": + return {} + tenant = _env("AGENTO11Y_AUTH_TENANT_ID") + token = _env("AGENTO11Y_AUTH_TOKEN") + if not (tenant and token): + return {} + creds = base64.b64encode(f"{tenant}:{token}".encode()).decode() + return {"Authorization": f"Basic {creds}", "X-Scope-OrgID": tenant} + + +def load() -> PluginConfig: + """Resolve plugin-specific env vars to a config. + + Always returns a config. Channel decisions are driven by + ``generations_configured`` / ``otel_configured`` rather than ``None``. + """ + return PluginConfig( + sample_rate=_env_float("AGENTO11Y_HERMES_SAMPLE_RATE", 1.0), + max_chars=_env_int("AGENTO11Y_HERMES_MAX_CHARS", 12000), + otel_auto=_env_bool("AGENTO11Y_HERMES_OTEL_AUTO", True), + error_flush_timeout=_env_float("AGENTO11Y_HERMES_ERROR_FLUSH_TIMEOUT", 2.0), + generations_configured=_generations_configured(), + otel_configured=_otel_configured(), + otel_endpoint_override=_otel_endpoint_override(), + otel_auth_headers=_otel_auth_headers(), + export_headers=_export_headers(), + ) diff --git a/plugins/hermes/src/grafana_agento11y_hermes/_errors.py b/plugins/hermes/src/grafana_agento11y_hermes/_errors.py new file mode 100644 index 000000000..b63231b62 --- /dev/null +++ b/plugins/hermes/src/grafana_agento11y_hermes/_errors.py @@ -0,0 +1,36 @@ +"""Sentinel exceptions for the SDK's call-error channel. + +``GenerationRecorder.set_call_error`` takes an ``Exception``. A string reaches +``span.record_exception`` inside ``end()`` and raises ``TypeError`` under +``ContentCaptureMode.FULL``, leaking the span. +Synthesizing an exception from a message is what the first-party plugins do. + +The SDK picks ``error.category`` from the exception: it reads ``status_code`` +off it (429 to ``rate_limit``, 401/403 to ``auth_error``, 5xx to +``server_error``) and otherwise scans ``str(error)``. Both sentinels carry +``status_code`` and keep a short message so the scan finds nothing to +misread. +""" + +from __future__ import annotations + + +class ProviderCallError(Exception): + """An LLM API call hermes reported as failed.""" + + def __init__(self, error_type: str = "", status_code: int | None = None) -> None: + super().__init__(error_type or "api_request_error") + self.status_code = status_code + + +class SupersededAttempt(ProviderCallError): + """An attempt a retry displaced before its ``post_api_request`` fired. + + Hermes assigns ``api_request_id`` above its retry loop, so a second + ``pre_api_request`` for an id means the first attempt was abandoned. It was + a real provider call, so it is exported, but marked rather than reported as + a successful generation with no output. + """ + + def __init__(self) -> None: + super().__init__("superseded_by_retry") diff --git a/plugins/hermes/src/grafana_agento11y_hermes/_hooks.py b/plugins/hermes/src/grafana_agento11y_hermes/_hooks.py new file mode 100644 index 000000000..80e487d81 --- /dev/null +++ b/plugins/hermes/src/grafana_agento11y_hermes/_hooks.py @@ -0,0 +1,1135 @@ +"""Hermes plugin hook handlers. + +All handlers fail open: ``_fail_open`` wraps every one of them, so an exception +is logged and the hermes loop continues. If the SDK client cannot be +constructed (missing creds, SDK error), every handler short-circuits via +``client is None``. +""" + +from __future__ import annotations + +import functools +import json +import logging +import os +import random +import secrets +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from typing import Any + +from opentelemetry import context as otel_context +from opentelemetry import trace as otel_trace + +from . import _client, _errors, _redact, _request, _state, _tags +from ._coerce import as_int, as_optional_int, coerce_text + +logger = logging.getLogger(__name__) + +# Cap on an error message before it reaches the span status and the payload. +# Independent of AGENTO11Y_HERMES_MAX_CHARS, which bounds tool I/O. +_ERROR_MAX_CHARS = 2000 + +# LEGACY: cleared only by the test reset. Delete with the rest of the +# pre-v2026.6.5 fallback. +_WARNED_LEGACY_HERMES = False +_WARNED_DEPRECATED_VERSION = False +_LOGGED_TRUNCATED_REQUEST = False +# Set on the first request that carries an api_request_id. Nothing reads the +# turn-scoped convo bookkeeping on that path, so its writers stop once we know +# which hermes we are on. +_SAW_REQUEST_ID = False + +# The fields of a capture that belong to the model rather than to the agent. +_SAMPLING_FIELDS = ("max_tokens", "temperature", "top_p", "tool_choice") + + +def _fail_open(handler: Callable[..., None]) -> Callable[..., None]: + """Catch unhandled exceptions from every handler registered by ``__init__``. + + Inner ``try`` blocks let handlers continue after a failure, for example to + close a recorder after losing a tool result. This guard only keeps uncaught + exceptions from reaching the hermes loop. + """ + + # Read through getattr because the annotation is Callable, which covers + # callables that carry no __name__. + name = getattr(handler, "__name__", "hook") + + @functools.wraps(handler) + def wrapper(*args: Any, **kwargs: Any) -> None: + try: + handler(*args, **kwargs) + except Exception as exc: + logger.warning("grafana-agento11y-hermes: %s failed: %s", name, exc) + + return wrapper + + +def _warn_legacy_hermes_once() -> None: + """Warn that hermes is too old to pair requests exactly. + + hermes v2026.6.5, which is ``hermes-agent`` 0.16.0 on PyPI, added + ``api_request_id`` and the input/output messages to the API-request hooks. + Older builds fall back to matching on ``api_call_count`` and recovering + output from ``post_llm_call``, which cannot tell apart two requests running + concurrently in one session. + """ + global _WARNED_LEGACY_HERMES + if _WARNED_LEGACY_HERMES: + return + _WARNED_LEGACY_HERMES = True + logger.warning( + "grafana-agento11y-hermes: this hermes does not send api_request_id. " + "Using the legacy matching path, which mis-attributes concurrent " + "requests in one session. Upgrade to hermes v2026.6.5 (PyPI 0.16.0) or newer." + ) + + +def _warn_deprecated_version_once() -> None: + global _WARNED_DEPRECATED_VERSION + if _WARNED_DEPRECATED_VERSION: + return + _WARNED_DEPRECATED_VERSION = True + logger.warning( + "grafana-agento11y-hermes: AGENTO11Y_HERMES_AGENT_VERSION is deprecated. " + "Rename it to AGENTO11Y_AGENT_VERSION, which also sets the agent_version " + "metric dimension." + ) + + +def _log_truncated_request_once() -> None: + """Note that the request payload arrived clipped, once per process. + + Hermes sanitizes every hook payload against + ``HERMES_PLUGIN_PAYLOAD_MAX_CHARS`` (50000 by default) and, past the cap, + replaces the whole request envelope with a preview carrying no body. The + plugin does not raise that variable itself: a telemetry plugin must not + change what the host hands its other plugins and every tool subprocess. + + At INFO because hermes sets ``agent.log`` to INFO, so a DEBUG line reaches + no log in a default install. One-shot ``-z`` disables logging outright + right after plugin discovery, where no level helps. + """ + global _LOGGED_TRUNCATED_REQUEST + if _LOGGED_TRUNCATED_REQUEST: + return + _LOGGED_TRUNCATED_REQUEST = True + logger.info( + "grafana-agento11y-hermes: hermes truncated the request payload, so the system " + "prompt and tool schemas come from an earlier request in this session, where " + "there is one. Raising HERMES_PLUGIN_PAYLOAD_MAX_CHARS recovers the tool " + "schemas; a system prompt over 8000 chars stays clipped at any value." + ) + + +def _reset_for_tests() -> None: + global _WARNED_LEGACY_HERMES, _WARNED_DEPRECATED_VERSION, _SAW_REQUEST_ID + global _LOGGED_TRUNCATED_REQUEST + _WARNED_LEGACY_HERMES = False + _WARNED_DEPRECATED_VERSION = False + _LOGGED_TRUNCATED_REQUEST = False + _SAW_REQUEST_ID = False + + +def _agent_name() -> str: + """Default agent name when the SDK can't resolve one from env/context. + + The SDK reads ``AGENTO11Y_AGENT_NAME`` itself; this fallback only kicks in + when neither env nor a context override is set. + """ + return os.environ.get("AGENTO11Y_AGENT_NAME", "").strip() or "hermes" + + +def _effective_version() -> str: + """Version stamped on every generation as ``effective_version``. + + One variable, ``AGENTO11Y_AGENT_VERSION``: the SDK already reads it for + ``agent_version``, which is the metric dimension, and the first-party + plugins mirror it into ``effective_version`` too. + ``AGENTO11Y_HERMES_AGENT_VERSION`` is the deprecated fallback, kept so + existing installs keep working. + """ + version = os.environ.get("AGENTO11Y_AGENT_VERSION", "").strip() + if version: + return version + legacy = os.environ.get("AGENTO11Y_HERMES_AGENT_VERSION", "").strip() + if legacy: + _warn_deprecated_version_once() + return legacy + + +def _error_fields(error: Any) -> tuple[str, str, int | None]: + """Split whatever hermes put on the error hook into (type, message, status). + + The status code is only read here as a fallback for the hook's own + ``status_code`` kwarg. It decides ``error.category``, so it is worth + looking for in the payload rather than losing the whole classification. + """ + if error is None: + return "", "", None + if isinstance(error, dict): + return ( + str(error.get("type") or ""), + str(error.get("message") or ""), + as_optional_int(error.get("status_code") or error.get("status")), + ) + if isinstance(error, BaseException): + return type(error).__name__, str(error), as_optional_int(getattr(error, "status_code", None)) + if isinstance(error, str): + return "", error, None + return "", str(error), None + + +def _convo_key(task_id: str, session_id: str) -> tuple[str, str]: + """Key for the running conversation history. + + Hermes does not pass ``task_id`` to ``pre_llm_call`` but does to + ``pre_api_request`` — keying on session_id only is the only way both hooks + address the same bucket. Wrapped in a tuple so the type matches what + ``_state`` expects. + """ + return ("", session_id or "") + + +def _should_sample() -> bool: + """Return True if this trace should be recorded under AGENTO11Y_HERMES_SAMPLE_RATE. + + A pre-hook that returns False simply skips ``start_generation`` and + never stores a recorder, so the matching post-hook becomes a natural + no-op (``gen_pop`` returns None). Tool sampling is checked at + ``post_tool_call`` time directly. + """ + cfg = _client._get_plugin_config() + if cfg is None or cfg.sample_rate >= 1.0: + return True + if cfg.sample_rate <= 0.0: + return False + return random.random() < cfg.sample_rate + + +def _split_system_prompt(messages: Any) -> tuple[str, list[dict]]: + """Pull system messages out into a single prompt string, return remaining messages.""" + if not isinstance(messages, list): + return "", [] + system_parts: list[str] = [] + rest: list[dict] = [] + for msg in messages: + if not isinstance(msg, dict): + continue + if msg.get("role") == "system": + text = coerce_text(msg.get("content")) + if text: + system_parts.append(text) + else: + rest.append(msg) + return "\n\n".join(system_parts), rest + + +def _serialize_tool_calls(tool_calls: Any) -> list[dict[str, Any]]: + if not tool_calls: + return [] + out: list[dict[str, Any]] = [] + for tc in tool_calls: + if isinstance(tc, dict): + tc_id = tc.get("id", "") + fn = tc.get("function") or {} + name = fn.get("name") if isinstance(fn, dict) else None + arguments = fn.get("arguments") if isinstance(fn, dict) else None + else: + tc_id = getattr(tc, "id", "") + fn = getattr(tc, "function", None) + name = getattr(fn, "name", None) if fn is not None else None + arguments = getattr(fn, "arguments", None) if fn is not None else None + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except Exception: + pass + out.append({"id": tc_id or "", "name": name or "", "arguments": arguments}) + return out + + +def _to_sdk_message(msg: dict[str, Any]): + from agento11y import ( + Message, + MessageRole, + Part, + ToolCall, + ToolResult, + text_part, + tool_call_part, + tool_result_part, + ) + + cfg = _client._get_plugin_config() + max_chars = cfg.max_chars if cfg is not None else 12000 + role = msg.get("role") + if role == "user": + text = coerce_text(msg.get("content")) + return Message(role=MessageRole.USER, parts=[text_part(text)] if text else []) + if role == "tool": + tool_call_id = msg.get("tool_call_id") or "" + content = _redact.truncate_text(coerce_text(msg.get("content")), max_chars) + return Message( + role=MessageRole.TOOL, + parts=[tool_result_part(ToolResult(tool_call_id=_redact.redact_record(tool_call_id), content=content))], + ) + if role == "assistant": + parts: list[Part] = [] + text = coerce_text(msg.get("content")) + if text: + parts.append(text_part(text)) + for tc in _serialize_tool_calls(msg.get("tool_calls")): + input_json = b"" + if tc.get("arguments") is not None: + try: + input_json = json.dumps(_redact.safe_value(tc["arguments"], max_chars=max_chars)).encode() + except Exception: + input_json = b"" + parts.append( + tool_call_part( + ToolCall( + name=_redact.redact_record(tc.get("name", "")), + id=_redact.redact_record(tc.get("id", "")), + input_json=input_json, + ) + ) + ) + return Message(role=MessageRole.ASSISTANT, parts=parts) + # Unknown role (e.g. "system" should already be filtered out): drop. + return None + + +def _to_sdk_messages(messages: Any) -> list: + if not isinstance(messages, list): + return [] + out = [] + for msg in messages: + if not isinstance(msg, dict): + continue + if msg.get("role") == "system": + continue # handled via system_prompt + sdk_msg = _to_sdk_message(msg) + if sdk_msg is not None: + out.append(sdk_msg) + return out + + +def _assistant_to_sdk_messages(assistant_message: Any) -> list: + if assistant_message is None: + return [] + + if isinstance(assistant_message, dict): + content = assistant_message.get("content") + tool_calls = assistant_message.get("tool_calls") + else: + content = getattr(assistant_message, "content", None) + tool_calls = getattr(assistant_message, "tool_calls", None) + + msg_dict = {"role": "assistant", "content": content, "tool_calls": tool_calls} + sdk_msg = _to_sdk_message(msg_dict) + return [sdk_msg] if sdk_msg is not None else [] + + +def _span_context_of(recorder: Any) -> Any: + """The recorder's span context, or ``None``. Never raises. + + Today's ``GenerationRecorder`` always carries a span, so this is a guard + against a future recorder that does not, or a host tracer whose span + answers ``get_span_context`` differently. Losing the link is acceptable; + losing the generation is not. + """ + try: + span = getattr(recorder, "span", None) + return None if span is None else span.get_span_context() + except Exception: + return None + + +def _build_token_usage(usage: Any): + from agento11y import TokenUsage + + if not isinstance(usage, dict): + return TokenUsage() + + input_tokens = as_int(usage.get("input_tokens") or usage.get("prompt_tokens")) + output_tokens = as_int(usage.get("output_tokens") or usage.get("completion_tokens")) + total_tokens = as_int(usage.get("total_tokens")) + cache_read = as_int(usage.get("cache_read_tokens") or usage.get("cache_read_input_tokens")) + cache_write = as_int( + usage.get("cache_write_tokens") + or usage.get("cache_creation_input_tokens") + or usage.get("cache_write_input_tokens") + ) + reasoning = as_int(usage.get("reasoning_tokens")) + + return TokenUsage( + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + cache_read_input_tokens=cache_read, + cache_write_input_tokens=cache_write, + reasoning_tokens=reasoning, + ) + + +@_fail_open +def on_pre_llm_call( + *, + task_id: str = "", + session_id: str = "", + conversation_history: Any = None, + user_message: Any = None, + **_: Any, +) -> None: + """Capture the start-of-turn conversation so request-scoped hooks have an input. + + Hermes does not pass ``messages`` to ``pre_api_request``. ``pre_llm_call`` + is the only hook that receives the actual conversation as + ``conversation_history`` (the message list at the start of the turn). + We snapshot it here and extend in-place as the tool-calling loop runs. + + LEGACY: current hermes passes the messages to ``pre_api_request`` itself, + so once we have seen an ``api_request_id`` nothing reads this bucket. + """ + if _SAW_REQUEST_ID: + return + if not isinstance(conversation_history, list): + return + convo = list(conversation_history) + if ( + isinstance(user_message, str) + and user_message + and not any(isinstance(m, dict) and m.get("role") == "user" and m.get("content") == user_message for m in convo) + ): + convo.append({"role": "user", "content": user_message}) + key = _convo_key(task_id, session_id) + _state.convo_set(key, convo) + # Snapshot the assistant-message count BEFORE post_tool_call extends the + # running convo with synthesized tool-call messages. ``_close_pending_for_session`` + # uses this to peel this turn's assistant outputs off the final history. + start_asst_count = sum(1 for m in conversation_history if isinstance(m, dict) and m.get("role") == "assistant") + _state.turn_start_asst_count_set(key, start_asst_count) + + +@_fail_open +def on_post_llm_call( + *, + task_id: str = "", + session_id: str = "", + turn_id: str = "", + conversation_history: Any = None, + assistant_response: Any = None, + **_: Any, +) -> None: + """Close all pending recorders for this turn with outputs from the final convo. + + ``conversation_history`` here is the FINAL state of the turn — includes + every assistant message (with content/tool_calls) and every tool result. + We pair the new assistant messages with our pending recorders in order. + + Also ends the turn's generation chain, so the next turn starts a new one. + This hook does not fire for an interrupted turn, hence the session sweep in + ``on_session_end`` and the cap on the map itself. + """ + _close_pending_for_session(session_id or "", conversation_history) + key = _convo_key(task_id, session_id) + _state.convo_clear(key) + _state.turn_start_asst_count_clear(key) + _state.turn_last_gen_clear(turn_id) + + +def _prefer_cached(current: Any, current_clipped: bool, cached: Any, cached_clipped: bool) -> bool: + """True when the session's copy of a field beats what this request carried. + + Empty loses to anything, and a value hermes shortened in place loses to a + complete one. Between two shortened copies the longer one kept more of the + same prompt or tool list, so length decides. Two complete reads leave the + current one in place: it is the fresher inventory when ``tool_search`` + swapped the toolset. + """ + if not cached: + return False + if not current: + return True + if not current_clipped: + return False + return not cached_clipped or len(cached) > len(current) + + +def _request_facts( + session_id: str, + model: str, + request: Any, + request_messages: Any, + system_prompt: Any, + tool_count: int, +) -> tuple[_request.RequestFacts, bool]: + """Read this request, filling what it lost from the session's best capture. + + Merging field by field rather than wholesale keeps a partial read winning + where it has data. What comes out is never worse than what was cached, so + storing it back cannot degrade the capture the next request borrows from. + + The sampling params only come from a capture made on the same model. They + are resolved per model profile, so a session that fell back to another + provider would otherwise report the first model's cap and temperature on + the second model's generations. Each model keeps its own cached params so + a one-turn fallback cannot erase the primary model's capture. + + Returns the facts and whether any field came from the cache. + """ + facts = _request.parse(request, system_prompt=system_prompt, request_messages=request_messages) + if facts.truncated and request is not None: + _log_truncated_request_once() + if tool_count and len(facts.tools) < tool_count: + # Third signal for a clipped tool list, independent of the two markers + # hermes leaves in the payload: this count is raw and always accurate. + facts.tools_clipped = True + + reused = False + entry = _state.session_facts_get(session_id, model) + if entry is not None: + _, cached = entry + if _prefer_cached( + facts.system_prompt, + facts.system_prompt_clipped, + cached.system_prompt, + cached.system_prompt_clipped, + ): + facts.system_prompt = cached.system_prompt + facts.system_prompt_clipped = cached.system_prompt_clipped + reused = True + if _prefer_cached(facts.tools, facts.tools_clipped, cached.tools, cached.tools_clipped): + # Copied, so one list is not shared by every generation of the + # session and by the cache entry behind them. + facts.tools = list(cached.tools) + facts.tools_clipped = cached.tools_clipped + reused = True + for name in _SAMPLING_FIELDS: + if getattr(facts, name) is None and getattr(cached, name) is not None: + setattr(facts, name, getattr(cached, name)) + reused = True + + # Stored on every request. The merge above leaves the prompt and the toolset + # no worse than the cache already held, and a request that resolved only + # sampling params is exactly the one a later clipped request needs. + _state.session_facts_put(session_id, model, facts) + return facts, reused + + +@_fail_open +def on_pre_api_request( + *, + task_id: str = "", + session_id: str = "", + model: str = "", + provider: str = "", + conversation_history: Any = None, + api_request_id: str = "", + turn_id: str = "", + messages: Any = None, + api_call_count: int = 0, + max_tokens: Any = None, + request: Any = None, + request_messages: Any = None, + system_prompt: Any = None, + tool_count: int = 0, + **_: Any, +) -> None: + global _SAW_REQUEST_ID + if api_request_id: + _SAW_REQUEST_ID = True + client = _client._get_client() + if client is None: + return + + # Coerced once, because the count is both compared against a length and + # recorded. A provider that reports it as text would otherwise abort the + # open and cost the whole generation, not just the count. + tools_expected = as_int(tool_count) + # Read the request before the sampling gate. The payloads that arrive + # readable are the earliest of a session, so skipping those would leave + # every sampled-in request with an empty capture behind it. Parsing costs + # well under a millisecond and touches nothing but the cache. + facts, facts_reused = _request_facts(session_id, model, request, request_messages, system_prompt, tools_expected) + # post_tool_call carries neither model nor provider, so remember them + # for the tool executions that follow this request. + _state.session_model_put(session_id, model or "", provider or "") + if not _should_sample(): + return + + from agento11y import GenerationStart, ModelRef + + # hermes v2026.6.5+ passes the input messages here as + # ``conversation_history`` (agent/conversation_loop.py). ``messages`` is + # accepted only because older builds and our own tests used that name. + if not isinstance(conversation_history, list): + conversation_history = messages + if not isinstance(conversation_history, list): + # LEGACY: no messages on the hook, so use the running history that + # pre_llm_call captured and post_tool_call extends. + _warn_legacy_hermes_once() + conversation_history = _state.convo_get(_convo_key(task_id, session_id)) + # ``conversation_history`` carries no system message on any supported + # hermes: it is the agent's running convo, and the system prompt is + # prepended to the separate list that goes on the wire. The split is + # kept for ``non_system``, which is the input, and as the last resort + # for the prompt itself. + history_system_prompt, non_system = _split_system_prompt(conversation_history) + sdk_messages = _to_sdk_messages(non_system) + + resolved_system_prompt = facts.system_prompt or history_system_prompt + # The body is what hermes actually put on the wire; the ``max_tokens`` + # kwarg beside it arrives as None on every supported release. + resolved_max_tokens = facts.max_tokens + if resolved_max_tokens is None: + resolved_max_tokens = as_optional_int(max_tokens) + + # Stamp started_at on both seed and GenState. The seed timestamp is + # what the SDK uses for the span's start_time; GenState carries it so + # the close path can compute completed_at = started_at + api_duration. + started_at = datetime.now(UTC) + # Assign the id here rather than letting the SDK mint one inside end(): + # the tool executions this call asks for run while it is still unknown + # otherwise. Same shape as the SDK's own framework handler. + generation_id = f"gen_{secrets.token_hex(8)}" + parent_generation_id = _state.turn_last_gen_get(turn_id) + start = GenerationStart( + id=generation_id, + parent_generation_ids=[parent_generation_id] if parent_generation_id else [], + model=ModelRef(provider=provider or "unknown", name=model or "unknown"), + conversation_id=session_id or task_id or "", + agent_name=_agent_name(), + effective_version=_effective_version(), + system_prompt=resolved_system_prompt, + started_at=started_at, + max_tokens=resolved_max_tokens, + temperature=facts.temperature, + top_p=facts.top_p, + tool_choice=facts.tool_choice, + tools=facts.tools, + # The framework tags and the rest of the built-ins ride on the + # ClientConfig instead, which is the only channel that also reaches + # spans and metrics. The SDK merges them in under these. + tags=_tags.seed_tags(), + metadata={ + "hermes.api_call_count": as_int(api_call_count), + "hermes.task_id": task_id, + "hermes.session_id": session_id, + "hermes.turn_id": turn_id, + # Counted by hermes rather than read out of the payload, so it + # stays accurate when the schemas do not: an empty ``tools`` next + # to a non-zero count reads as a clipped payload rather than a + # hermes with no tools. + "hermes.tool_count": tools_expected, + # True when hermes clipped this request's payload and at least + # one field above came from an earlier request in the session. + # A swapped toolset makes such a field stale, so the record + # says which ones to trust. + "hermes.request_facts_reused": facts_reused, + }, + ) + recorder = client.start_generation(_redact.redact_record(start)) + recorder.__enter__() + # Input is stashed on GenState and threaded into set_result at + # close-time, so set_result is only called once. + state = _state.GenState( + recorder=recorder, + input_messages=sdk_messages, + system_prompt=resolved_system_prompt, + session_id=session_id, + generation_id=generation_id, + turn_id=turn_id, + started_at=started_at, + ) + if api_request_id: + # Keyed on the request id, which post_tool_call also carries. A + # retry overwrites the link, so the tools of a request always point + # at the attempt that was live when they ran. + _state.gen_link_put( + api_request_id, + _state.GenLink( + generation_id=generation_id, + span_context=_span_context_of(recorder), + session_id=session_id, + turn_id=turn_id, + ), + ) + displaced = _state.req_put(api_request_id, state) + if displaced is not None: + # A retry reused the id, so the earlier attempt was abandoned + # mid-flight. Close it as its own failed generation. + _finish_generation( + displaced, + assistant_message=None, + usage=None, + finish_reason="", + response_model="", + api_duration=None, + call_error=_errors.SupersededAttempt(), + ) + else: + # LEGACY: infer the pair from the call counter. + _warn_legacy_hermes_once() + _state.gen_put((task_id, session_id, as_int(api_call_count)), state) + + +@_fail_open +def on_api_request_error( + *, + api_request_id: str = "", + error: Any = None, + status_code: Any = None, + **_: Any, +) -> None: + """Close the generation for an API call that failed, carrying the error. + + Only some retry paths fire this hook. The rest re-enter + ``pre_api_request`` with the same ``api_request_id``, where displacement + closes the abandoned attempt instead. The two compose: whichever fires + first pops the state, and the other finds nothing. + + Flushes before returning. On a one-shot run this hook is the last one that + fires: hermes emits no session-end hook when the turn dies on a provider + error, and exits through ``os._exit``, which skips the SDK's atexit flush. + """ + if not api_request_id: + return + # Read the payload before popping. ``error`` is whatever hermes built, + # so if reading it raises, the state is still in the map for + # displacement or on_session_end to close rather than orphaned here. + error_type, message, payload_status = _error_fields(error) + state = _state.req_pop(api_request_id) + if state is None: + return + _finish_generation( + state, + assistant_message=None, + usage=None, + finish_reason="", + response_model="", + api_duration=None, + call_error=_errors.ProviderCallError( + _redact.redact_prose(error_type), as_optional_int(status_code) or payload_status + ), + call_error_message=_redact.truncate_prose(message, _ERROR_MAX_CHARS), + ) + cfg = _client._get_plugin_config() + if cfg is not None: + _client.flush_bounded(cfg.error_flush_timeout) + + +@_fail_open +def on_post_api_request( + *, + task_id: str = "", + session_id: str = "", + api_call_count: int = 0, + api_request_id: str = "", + model: str = "", + usage: Any = None, + finish_reason: str = "", + response_model: str = "", + assistant_message: Any = None, + api_duration: float | None = None, + **_: Any, +) -> None: + """Close the generation for this API call. + + hermes v2026.6.5+ passes ``api_request_id`` and ``assistant_message`` here + (agent/conversation_loop.py), so the pair is exact and the output is in + hand: set the result and close immediately. This hook fires at most once + per id and always after the retry loop, so the state it pops belongs to the + attempt that was kept; the discarded ones close earlier, in + ``on_pre_api_request`` or ``on_api_request_error``. + + LEGACY: without ``api_request_id`` the output is not available yet, so only + the partial fields are stashed and the close is deferred to post_llm_call, + which is the first hook carrying the assistant content. + """ + if api_request_id: + state = _state.req_pop(api_request_id) + if state is None: + return + _finish_generation( + state, + assistant_message=assistant_message, + usage=usage, + finish_reason=finish_reason, + response_model=response_model or model or "", + api_duration=api_duration, + ) + # Chain the turn's next call onto this one. Recorded here rather than at + # open time because this hook only sees the attempt hermes kept, so a + # superseded retry never becomes a parent. + _state.turn_last_gen_put(state.turn_id, state.generation_id, state.session_id) + return + + # as_int, not int(): the two hooks have to agree on the key even when a + # provider reports the counter as text, and this one runs outside any + # narrower guard. + state = _state.gen_get((task_id, session_id, as_int(api_call_count))) + if state is None: + return + state.usage = usage + state.finish_reason = finish_reason or "" + state.response_model = response_model or model or "" + if isinstance(api_duration, (int, float)) and api_duration >= 0: + state.api_duration = float(api_duration) + + +def _finish_generation( + state: Any, + *, + assistant_message: Any, + usage: Any, + finish_reason: str, + response_model: str, + api_duration: float | None, + call_error: Exception | None = None, + call_error_message: str = "", +) -> None: + """Set the result on one recorder and close it. + + Pins ``completed_at`` to ``started_at + api_duration`` when hermes gave us a + duration, so the span and the ``gen_ai.client.operation.duration`` metric + cover the LLM call rather than the recorder's lifetime. That matters on the + legacy path, where the close can happen long after the call. + + The two error arguments feed different sinks. ``call_error_message`` is the + readable text in the exported payload; ``call_error`` stamps the span's + ``error.type`` / ``error.category`` and the failure metric. Setting the + message through ``set_result`` first keeps it, because the SDK only derives + ``call_error`` from the exception when the field is still empty. + """ + recorder = state.recorder + try: + sdk_output = _assistant_to_sdk_messages(assistant_message) if assistant_message is not None else [] + duration = api_duration if api_duration is not None else state.api_duration + completed_at: datetime | None = None + if state.started_at is not None and duration is not None: + completed_at = state.started_at + timedelta(seconds=duration) + recorder.set_result( + input=state.input_messages, + output=sdk_output, + usage=_build_token_usage(usage), + stop_reason=_redact.redact_record(finish_reason or ""), + response_model=_redact.redact_record(response_model or ""), + started_at=state.started_at, + completed_at=completed_at, + call_error=call_error_message, + ) + except Exception as exc: + logger.warning("grafana-agento11y-hermes: set_result failed: %s", exc) + if call_error is not None: + try: + recorder.set_call_error(call_error) + except Exception as exc: + logger.warning("grafana-agento11y-hermes: set_call_error failed: %s", exc) + try: + recorder.__exit__(None, None, None) + except Exception as exc: + logger.warning("grafana-agento11y-hermes: recorder __exit__ failed: %s", exc) + + +def _close_pending_for_session(session_id: str, conversation_history: Any) -> None: + """LEGACY: drain deferred recorders, assigning outputs from the final convo. + + Only reachable on hermes older than v2026.6.5, which sends no + ``api_request_id``. Called from ``post_llm_call`` (normal path) and + ``on_session_end`` (interrupt safety). Walks the new portion of + ``conversation_history`` to find assistant messages in order and pairs them + with stored GenStates by api_call_count. + """ + pending = _state.gen_pop_session(session_id or "") + if not pending: + return + + asst_messages: list[dict] = [] + if isinstance(conversation_history, list): + for msg in conversation_history: + if isinstance(msg, dict) and msg.get("role") == "assistant": + asst_messages.append(msg) + + # Slice off prior turns' assistants using the count snapshotted at + # pre_llm_call time. Falling back to 0 keeps tests that skip pre_llm_call + # working — they pass single-turn histories where everything is "new". + start_count = _state.turn_start_asst_count_get(_convo_key("", session_id or "")) or 0 + new_asst = asst_messages[start_count:] + + # End-anchor: pair the LAST n_new pending recorders with the n_new new + # assistant messages. Hermes increments api_call_count on every iteration, + # including discarded retries (incomplete , invalid- + # response retries), so pending can have more entries than there are kept + # assistants. Anchoring from the end is correct because post_llm_call only + # fires when ``final_response`` is set, so the LAST iteration was kept; leading + # discards leave their recorder with no output rather than stealing a + # message from a successful call or a prior turn. + n_new = len(new_asst) + pair_offset = max(0, len(pending) - n_new) + + for idx, ((_, _, _api_call_count), gen_state) in enumerate(pending): + new_idx = idx - pair_offset + asst = new_asst[new_idx] if 0 <= new_idx < n_new else None + _finish_generation( + gen_state, + assistant_message=asst, + usage=gen_state.usage, + finish_reason=gen_state.finish_reason, + response_model=gen_state.response_model, + api_duration=None, + ) + + +def _start_tool_execution_under(client: Any, start: Any, link: Any) -> Any: + """Start the tool execution inside the requesting generation's span context. + + The SDK's ``start_tool_execution`` starts its span from the ambient OTel + context, so attaching the generation's context around the call is what makes + the tool span a child of it and puts both in one trace. The Go plugins get + this for free, because their ``StartGeneration`` returns a context; the + Python recorder never activates its span, so we do it by hand. + + The generation span has already ended by then, since we close it in + ``post_api_request`` and the tools it asked for run after. OTel allows a + child of an ended span and the ids are right, but the child outlives the + parent's end, which a Go-produced trace does not do. + + Without a usable parent this is exactly ``client.start_tool_execution``, so + the tool is still recorded as its own root span. + """ + span_context = getattr(link, "span_context", None) + if span_context is None or not getattr(span_context, "is_valid", False): + return client.start_tool_execution(start) + + token = otel_context.attach(otel_trace.set_span_in_context(otel_trace.NonRecordingSpan(span_context))) + try: + return client.start_tool_execution(start) + finally: + otel_context.detach(token) + + +def _stamp_parent_generation(recorder: Any, link: Any) -> None: + """Name the requesting generation on the tool span. Never raises. + + ``NoopToolExecutionRecorder``, returned for an empty tool name, has no span. + + Speculative: ``llms.txt`` lists + ``agento11y.generation.parent_generation_ids`` under the attributes carried + by generation *and* tool spans, but no SDK writes it on an ``execute_tool`` + span and no first-party plugin sets it there. It is one attribute, and the + trace parenting above stands on its own if the UI ignores it. + """ + generation_id = getattr(link, "generation_id", "") + if not generation_id: + return + try: + span = getattr(recorder, "span", None) + if span is None: + return + span.set_attribute("agento11y.generation.parent_generation_ids", [generation_id]) + except Exception as exc: + logger.debug("grafana-agento11y-hermes: parent generation attribute failed: %s", exc) + + +@_fail_open +def on_post_tool_call( + *, + tool_name: str = "", + args: Any = None, + result: Any = None, + task_id: str = "", + session_id: str = "", + tool_call_id: str = "", + api_request_id: str = "", + duration_ms: int | None = None, + status: str = "", + error_type: str = "", + error_message: str = "", + **_: Any, +) -> None: + """Record the tool execution and extend the running convo for the next call. + + All work is done here, not split with pre_tool_call. post_tool_call + (``_emit_post_tool_call_hook``, ``model_tools.py:974`` in hermes 0.19.0) is + the only hook of the pair carrying the result, the status and + ``duration_ms``, so a recorder opened in pre would sit open across the tool + call for nothing. Opening and closing in post also leaves no key to + mismatch between the two hooks, which would leak a recorder. + + Order: append the synthesized assistant tool-call message, then the tool + result, so the next ``pre_api_request``'s input chain reads + ``user → assistant(tool_calls) → tool``. Then start, set_result, and close + the tool execution recorder, using ``duration_ms`` from hermes to backdate + the span's started_at so its duration reflects the tool's wallclock time. + + ``api_request_id`` names the LLM call that asked for this tool, which is + what puts the tool span in that generation's trace. See + ``_start_tool_execution_under``. + + ``status`` ``blocked`` and ``cancelled`` stay unrecorded, matching the + first-party plugins, which only map ``error``. + """ + convo_key = _convo_key(task_id, session_id) + # LEGACY: the running convo only feeds pre-v2026.6.5 hermes. + if tool_call_id and not _SAW_REQUEST_ID: + try: + args_str = json.dumps(args) if args is not None else "{}" + except Exception: + args_str = "{}" + _state.convo_append( + convo_key, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": tool_call_id, + "type": "function", + "function": {"name": tool_name or "", "arguments": args_str}, + } + ], + }, + ) + try: + content = result if isinstance(result, str) else json.dumps(result, default=str) + except Exception: + content = repr(result) + _state.convo_append( + convo_key, + {"role": "tool", "tool_call_id": tool_call_id, "content": content}, + ) + + client = _client._get_client() + if client is None: + return + if not _should_sample(): + return + + if str(status).lower() in {"blocked", "cancelled"}: + return + + from agento11y import ToolExecutionStart + + completed_at = datetime.now(UTC) + if isinstance(duration_ms, (int, float)) and duration_ms >= 0: + started_at = completed_at - timedelta(milliseconds=float(duration_ms)) + else: + started_at = completed_at + + # Leave include_content at its default. The SDK resolves tool-content + # capture from the mode: forced on under full, forced off under + # metadata_only / full_with_metadata_spans, and the seed is honored + # under no_tool_content. Pinning it True kept args/results in the span + # even when the user set no_tool_content. + request_model, request_provider = _state.session_model_get(session_id) + start = ToolExecutionStart( + tool_name=tool_name or "", + tool_call_id=tool_call_id or "", + tool_type="function", + conversation_id=session_id or task_id or "", + agent_name=_agent_name(), + request_model=request_model, + request_provider=request_provider, + started_at=started_at, + ) + link = _state.gen_link_get(api_request_id) + recorder = _start_tool_execution_under(client, _redact.redact_record(start), link) + recorder.__enter__() + cfg = _client._get_plugin_config() + # cfg is non-None here: _get_client() above only returns a client after + # _CONFIG was populated by `_client._get_client()`. + max_chars = cfg.max_chars if cfg is not None else 12000 + try: + _stamp_parent_generation(recorder, link) + # Before set_result, matching the first-party plugins. The recorder + # has no call-error channel, so set_exec_error is the only way a + # failed tool reaches the span and the failure metric. + if str(status).lower() == "error": + recorder.set_exec_error( + Exception( + _redact.truncate_prose( + str(error_message or error_type or "tool returned error"), + _ERROR_MAX_CHARS, + ) + ) + ) + try: + recorder.set_result( + arguments=_redact.safe_value(args, max_chars=max_chars, parse_json_strings=True), + result=_redact.safe_value(result, max_chars=max_chars, parse_json_strings=True), + completed_at=completed_at, + ) + except Exception as exc: + logger.warning("grafana-agento11y-hermes: tool set_result failed: %s", exc) + finally: + try: + recorder.__exit__(None, None, None) + except Exception as exc: + logger.warning("grafana-agento11y-hermes: tool recorder __exit__ failed: %s", exc) + + +@_fail_open +def on_session_end(*, session_id: str = "", **_: Any) -> None: + # Interrupt safety. A request whose post_api_request never fired (the user + # interrupted, or the call errored into api_request_error) would leak its + # recorder. Close it with the input and timing we already have; there is no + # output to recover. + if session_id: + for state in _state.req_pop_session(session_id): + _finish_generation( + state, + assistant_message=None, + usage=None, + finish_reason="", + response_model="", + api_duration=None, + ) + # Drop the model, which every pre_api_request rewrites anyway. + # + # The request capture is deliberately left alone. This hook fires at + # the end of every ``run_conversation``, which is once per user message + # (agent/turn_finalizer.py in hermes 0.19.0), not once per session. + # Clearing here would empty the cache exactly when turn 2 needs it: its + # first request already carries the grown history, so its payload is + # clipped and there would be nothing left to fall back to. The entry is + # keyed by session and bounded by an LRU in ``_state`` instead. + _state.session_model_clear(session_id) + + # No tool of this session can fire from here on, so the links it would have + # read go too. post_llm_call clears the turn chain of a completed turn; this + # covers the turns that were interrupted. + if session_id: + _state.gen_link_pop_session(session_id) + _state.turn_last_gen_clear_session(session_id) + + # LEGACY: same safety for the deferred path, where post_llm_call only fires + # on a successful turn (agent/turn_finalizer.py:481 in hermes 0.19.0, + # ``if final_response and not interrupted``). + if session_id: + _close_pending_for_session(session_id, None) + + # flush() leaves the singleton client open so subsequent hermes sessions + # in the same process keep working. shutdown() would set _closed=True and + # every future start_generation/start_tool_execution call would raise. + # Unbounded on purpose: this hook fires while hermes still owns the loop, + # not on the way out, so the SDK's own timeouts are the right bound. + # _flush_channels also drains the OTel pipeline, which Client.flush() does + # not touch. + _client._flush_channels() + + +@_fail_open +def on_session_finalize(*, session_id: str = "", **_: Any) -> None: + """CLI exit. Same work as ``on_session_end``, which does not always fire. + + Interactive hermes fires ``on_session_end`` per completed turn and + ``on_session_finalize`` once at exit. A turn that died on a provider error + reaches exit having fired only the latter, so registering both is what + makes the interactive failure path flush through a hook rather than through + the SDK's atexit handler. Both firing on a normal exit is harmless: the + second flush drains an empty queue. + """ + on_session_end(session_id=session_id) diff --git a/plugins/hermes/src/grafana_agento11y_hermes/_otel.py b/plugins/hermes/src/grafana_agento11y_hermes/_otel.py new file mode 100644 index 000000000..06619bd9b --- /dev/null +++ b/plugins/hermes/src/grafana_agento11y_hermes/_otel.py @@ -0,0 +1,256 @@ +"""OpenTelemetry TracerProvider + MeterProvider auto-setup. + +The SDK does not own OTel; applications must install providers. This plugin +acts as the application setup for hermes users who haven't wired OTel +themselves. + +OTel exporter and resource configuration follow the OpenTelemetry env-var +schema (``OTEL_EXPORTER_OTLP_ENDPOINT``, ``OTEL_EXPORTER_OTLP_HEADERS``, +``OTEL_SERVICE_NAME``, ``OTEL_RESOURCE_ATTRIBUTES``). The OTLP HTTP exporters +read these themselves; the plugin only fills in ``service.name=hermes`` when +the user hasn't set one, the generations basic-auth headers derived in +``_config`` when no OTLP header env is set, and the endpoint when it was given +under the branded ``AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT`` name that the +exporters do not read. + +If the host application has already installed a non-proxy provider, the plugin +leaves it untouched and uses the host's setup. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +from . import _config + +logger = logging.getLogger(__name__) + +_INSTALLED_TRACER_PROVIDER: Any = None +_INSTALLED_METER_PROVIDER: Any = None +_SETUP_DONE = False + + +def _is_proxy_tracer_provider(provider: Any) -> bool: + from opentelemetry.trace import ProxyTracerProvider + + return isinstance(provider, ProxyTracerProvider) + + +def _is_proxy_meter_provider(provider: Any) -> bool: + # Public default is the proxy provider. The class is in `_internal` — + # reaching into a private name is brittle but the only correct check. + try: + from opentelemetry.metrics._internal import _ProxyMeterProvider + except ImportError: + return False + return isinstance(provider, _ProxyMeterProvider) + + +def _auth_source(derived: bool) -> str: + """Log suffix naming where the exporter's auth actually came from. + + ``derived`` is whether ``_exporter_headers`` handed the exporter our + credentials-derived headers. It says nothing about whether those headers + exist: with ``OTEL_EXPORTER_OTLP_HEADERS`` set, the env wins and the derived + ones are never passed. + """ + return " (auth from AGENTO11Y_AUTH_*)" if derived else "" + + +def _build_resource(): + from opentelemetry.sdk.resources import Resource + + # Resource.create() merges OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES + # from env, but explicit attrs win on key collision. Only set service.name + # when the user hasn't, so OTEL_SERVICE_NAME still takes effect. + attrs: dict[str, str] = {} + if not os.environ.get("OTEL_SERVICE_NAME") and "service.name=" not in os.environ.get( + "OTEL_RESOURCE_ATTRIBUTES", "" + ): + attrs["service.name"] = "hermes" + return Resource.create(attrs) + + +def _exporter_headers(signal_env: str, fallback_headers: dict[str, str]) -> dict[str, str] | None: + """Headers kwarg for an OTLP exporter, or ``None`` to let it read env itself. + + Returns the credentials-derived fallback only when the user set neither the + generic ``OTEL_EXPORTER_OTLP_HEADERS`` nor the signal-specific override + (``signal_env``). Passing ``headers=`` would otherwise clobber the user's + explicit env config, since the exporter's kwarg wins over the env var. + """ + if not fallback_headers: + return None + if os.environ.get("OTEL_EXPORTER_OTLP_HEADERS", "").strip(): + return None + if os.environ.get(signal_env, "").strip(): + return None + return dict(fallback_headers) + + +def _signal_endpoint(base: str, signal_path: str) -> str: + """Append an OTLP signal path to a base endpoint, per the OTel spec. + + Only used for ``otel_endpoint_override``. The exporter's ``endpoint`` kwarg + is the signal URL, not the base, so the path the exporter would have + appended for ``OTEL_EXPORTER_OTLP_ENDPOINT`` has to be added here. + """ + return base.rstrip("/") + signal_path + + +def _exporter_kwargs(cfg: _config.PluginConfig, signal_headers_env: str, signal_path: str) -> dict[str, Any]: + """Exporter kwargs, empty when every value comes from the standard envs.""" + kwargs: dict[str, Any] = {} + if cfg.otel_endpoint_override: + kwargs["endpoint"] = _signal_endpoint(cfg.otel_endpoint_override, signal_path) + headers = _exporter_headers(signal_headers_env, cfg.otel_auth_headers) + if headers is not None: + kwargs["headers"] = headers + return kwargs + + +def _install_tracer_provider(cfg: _config.PluginConfig) -> tuple[Any, bool]: + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + # OTLPSpanExporter() reads OTEL_EXPORTER_OTLP_ENDPOINT (appending + # /v1/traces), OTEL_EXPORTER_OTLP_HEADERS, and OTEL_EXPORTER_OTLP_INSECURE + # itself. We pass a value only where the standard env cannot supply it. + kwargs = _exporter_kwargs(cfg, "OTEL_EXPORTER_OTLP_TRACES_HEADERS", "/v1/traces") + exporter = OTLPSpanExporter(**kwargs) + provider = TracerProvider(resource=_build_resource()) + provider.add_span_processor(BatchSpanProcessor(exporter)) + return provider, "headers" in kwargs + + +def _install_meter_provider(cfg: _config.PluginConfig) -> tuple[Any, bool]: + from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + + kwargs = _exporter_kwargs(cfg, "OTEL_EXPORTER_OTLP_METRICS_HEADERS", "/v1/metrics") + exporter = OTLPMetricExporter(**kwargs) + reader = PeriodicExportingMetricReader(exporter) + return MeterProvider(resource=_build_resource(), metric_readers=[reader]), "headers" in kwargs + + +def setup_if_needed(plugin_cfg: _config.PluginConfig) -> bool: + """Install TracerProvider and MeterProvider when missing. + + Returns True when at least one provider is in place after the call (host + already had one, or this function installed one). Returns False when + auto-setup is disabled and no provider exists. + + Idempotent — safe to call repeatedly. Each provider is considered + independently: the host can own one and let the plugin install the other. + """ + global _INSTALLED_TRACER_PROVIDER, _INSTALLED_METER_PROVIDER, _SETUP_DONE + + if _SETUP_DONE: + return _INSTALLED_TRACER_PROVIDER is not None or _INSTALLED_METER_PROVIDER is not None or _has_any_provider() + + if not plugin_cfg.otel_configured: + # No OTel endpoint env → only set up if the host has its own provider. + _SETUP_DONE = True + return _has_any_provider() + + from opentelemetry import metrics, trace + + current_tracer = trace.get_tracer_provider() + current_meter = metrics.get_meter_provider() + + needs_tracer = _is_proxy_tracer_provider(current_tracer) + needs_meter = _is_proxy_meter_provider(current_meter) + + if not (needs_tracer or needs_meter): + _SETUP_DONE = True + return True + + if not plugin_cfg.otel_auto: + if needs_tracer or needs_meter: + logger.warning( + "grafana-agento11y-hermes: AGENTO11Y_HERMES_OTEL_AUTO=false and no provider is configured " + "for %s — telemetry is disabled.", + "TracerProvider+MeterProvider" + if (needs_tracer and needs_meter) + else ("TracerProvider" if needs_tracer else "MeterProvider"), + ) + _SETUP_DONE = True + return not (needs_tracer and needs_meter) + + try: + if needs_tracer: + provider, derived_auth = _install_tracer_provider(plugin_cfg) + trace.set_tracer_provider(provider) + _INSTALLED_TRACER_PROVIDER = provider + logger.info( + "grafana-agento11y-hermes: installed TracerProvider with OTLP HTTP exporter%s", + _auth_source(derived_auth), + ) + if needs_meter: + provider, derived_auth = _install_meter_provider(plugin_cfg) + metrics.set_meter_provider(provider) + _INSTALLED_METER_PROVIDER = provider + logger.info( + "grafana-agento11y-hermes: installed MeterProvider with OTLP HTTP exporter%s", + _auth_source(derived_auth), + ) + except Exception as exc: + logger.warning("grafana-agento11y-hermes: failed to set up OTel providers: %s", exc) + _SETUP_DONE = True + return False + + _SETUP_DONE = True + return True + + +def _has_any_provider() -> bool: + from opentelemetry import metrics, trace + + return not _is_proxy_tracer_provider(trace.get_tracer_provider()) or not _is_proxy_meter_provider( + metrics.get_meter_provider() + ) + + +def force_flush(timeout_millis: int | None = None) -> None: + """Flush the providers we installed. Skip ones owned by the host. + + ``timeout_millis`` bounds each provider's flush. Left unset, both use the + OTel SDK default of 30s. + """ + kwargs = {} if timeout_millis is None else {"timeout_millis": timeout_millis} + if _INSTALLED_TRACER_PROVIDER is not None: + try: + _INSTALLED_TRACER_PROVIDER.force_flush(**kwargs) + except Exception as exc: + logger.warning("grafana-agento11y-hermes: TracerProvider force_flush failed: %s", exc) + if _INSTALLED_METER_PROVIDER is not None: + try: + _INSTALLED_METER_PROVIDER.force_flush(**kwargs) + except Exception as exc: + logger.warning("grafana-agento11y-hermes: MeterProvider force_flush failed: %s", exc) + + +def _reset_for_tests() -> None: + """Clear cached install state and shut down any providers we installed. + + Shutdown is needed even in tests — the providers' background export threads + otherwise keep firing retries against localhost long after the test ended. + """ + global _INSTALLED_TRACER_PROVIDER, _INSTALLED_METER_PROVIDER, _SETUP_DONE + if _INSTALLED_TRACER_PROVIDER is not None: + try: + _INSTALLED_TRACER_PROVIDER.shutdown() + except Exception: + pass + if _INSTALLED_METER_PROVIDER is not None: + try: + _INSTALLED_METER_PROVIDER.shutdown() + except Exception: + pass + _INSTALLED_TRACER_PROVIDER = None + _INSTALLED_METER_PROVIDER = None + _SETUP_DONE = False diff --git a/plugins/hermes/src/grafana_agento11y_hermes/_redact.py b/plugins/hermes/src/grafana_agento11y_hermes/_redact.py new file mode 100644 index 000000000..492aaf489 --- /dev/null +++ b/plugins/hermes/src/grafana_agento11y_hermes/_redact.py @@ -0,0 +1,151 @@ +"""Structural payload redaction. + +Ported from the langfuse plugin's ``_safe_value`` family. Applies a depth limit +of 4, caps dict/list size at 50 entries, and truncates strings to a +caller-supplied ``max_chars``. Shared SDK secret patterns run before truncation. The aim +is to bound the size and shape of arbitrary tool I/O before it reaches the +generation exporter. + +Callers thread their resolved plugin ``max_chars`` (from +``AGENTO11Y_HERMES_MAX_CHARS``) into every entry call. There is no env fallback +inside this module. +""" + +from __future__ import annotations + +import itertools +import json +from dataclasses import fields, is_dataclass, replace +from enum import Enum +from typing import Any + +from agento11y import Generation +from agento11y.redaction import SecretRedactionOptions, create_secret_redaction_sanitizer, redact_secret_text + +_MAX_DEPTH = 4 +_MAX_ENTRIES = 50 +_PROSE_SANITIZER = create_secret_redaction_sanitizer(SecretRedactionOptions(redact_input_messages=False)) + + +def redact_prose(value: str) -> str: + # Reuse the SDK's lightweight error policy without importing its private engine. + return _PROSE_SANITIZER(Generation(call_error=value)).call_error + + +def truncate_prose(value: str, max_chars: int) -> str: + return _truncate(redact_prose(value), max_chars) + + +def truncate_text(value: str, max_chars: int) -> str: + return _truncate(redact_secret_text(value), max_chars) + + +def _truncate(value: str, max_chars: int) -> str: + if len(value) <= max_chars: + return value + return value[:max_chars] + f"... [truncated {len(value) - max_chars} chars]" + + +def maybe_parse_json_string(value: str, max_chars: int) -> Any: + """If ``value`` looks like JSON, return the parsed object; otherwise return as-is. + + Refuses to parse strings longer than ``max_chars`` — without this guard a + multi-megabyte tool result would be fully decoded (allocating the entire + parse tree) before any size cap kicked in. + """ + if len(value) > max_chars: + return value + stripped = value.strip() + if len(stripped) < 2 or stripped[0] not in "{[": + return value + try: + parsed, idx = json.JSONDecoder().raw_decode(stripped) + except Exception: + return value + # Unreachable while the prefix check above holds: a value starting with + # "{" or "[" decodes to a dict or a list or not at all. Kept so relaxing + # that check cannot silently start returning scalars from here. + if not isinstance(parsed, (dict, list)): + return value + + trailing = stripped[idx:].strip() + if not trailing: + return parsed + + hint_key = "_hint" if trailing.startswith("[Hint:") else "_trailing_text" + if isinstance(parsed, dict): + merged = dict(parsed) + key = hint_key if hint_key not in merged else "_trailing_text" + merged[key] = trailing + return merged + + return {"data": parsed, hint_key: trailing} + + +def redact_record(value: Any) -> Any: + """Copy SDK records, including fields outside the generation sanitizer's scope.""" + if isinstance(value, Enum): + return value + if isinstance(value, str): + return redact_secret_text(value) + if isinstance(value, bytes): + return redact_secret_text(value.decode("utf-8", errors="replace")).encode("utf-8") + if is_dataclass(value) and not isinstance(value, type): + return replace(value, **{field.name: redact_record(getattr(value, field.name)) for field in fields(value)}) + if isinstance(value, dict): + return {redact_record(key): redact_record(item) for key, item in value.items()} + if isinstance(value, list): + return [redact_record(item) for item in value] + return value + + +def safe_value( + value: Any, + *, + max_chars: int, + depth: int = 0, + parse_json_strings: bool = False, +) -> Any: + """Return a structurally-bounded copy of ``value`` safe to record. + + ``max_chars`` is required from the caller — every recursive descent uses + the same cap so a 50x50 nested dict only needs the value resolved once. + """ + shaped = _safe_value(value, max_chars, depth, parse_json_strings) + # Key/value patterns need the encoded object, not isolated string values. + redacted = redact_secret_text(json.dumps(shaped)) + try: + return json.loads(redacted) + except json.JSONDecodeError: + # Regex replacements can break JSON containing escaped quotes. + return "[REDACTED:invalid-json]" + + +def _safe_value(value: Any, max_chars: int, depth: int, parse_json_strings: bool) -> Any: + if depth > _MAX_DEPTH: + return "" + if value is None or isinstance(value, (int, float, bool)): + return value + if isinstance(value, bytes): + return {"type": "bytes", "len": len(value)} + if isinstance(value, str): + if parse_json_strings: + parsed = maybe_parse_json_string(value, max_chars) + if parsed is not value: + return _safe_value(parsed, max_chars, depth, True) + return truncate_text(value, max_chars) + if isinstance(value, dict): + # Iterate via islice so we never materialize the full items() list — + # a multi-million-entry dict would otherwise allocate before the cap. + return { + redact_secret_text(str(k)): _safe_value(v, max_chars, depth + 1, parse_json_strings) + for k, v in itertools.islice(value.items(), _MAX_ENTRIES) + } + if isinstance(value, (list, tuple)): + # Sequence slicing returns a view-sized copy — no full materialization. + return [_safe_value(v, max_chars, depth + 1, parse_json_strings) for v in value[:_MAX_ENTRIES]] + if isinstance(value, set): + return [_safe_value(v, max_chars, depth + 1, parse_json_strings) for v in itertools.islice(value, _MAX_ENTRIES)] + if hasattr(value, "__dict__"): + return _safe_value(vars(value), max_chars, depth + 1, parse_json_strings) + return truncate_text(repr(value), max_chars) diff --git a/plugins/hermes/src/grafana_agento11y_hermes/_request.py b/plugins/hermes/src/grafana_agento11y_hermes/_request.py new file mode 100644 index 000000000..4d5cffce4 --- /dev/null +++ b/plugins/hermes/src/grafana_agento11y_hermes/_request.py @@ -0,0 +1,298 @@ +"""Facts read out of the provider request that ``pre_api_request`` carries. + +Hermes passes the call it is about to make as ``{"method": ..., "body": +}``. The body is the literal provider payload, unnormalized, +so where each field sits follows ``api_mode``: + +- system prompt: ``body["system"]`` under ``anthropic_messages`` (a string or a + content-block list) and ``bedrock_converse`` (blocks with no ``type`` key), + ``body["instructions"]`` under ``codex_responses``, and nowhere on the body + under ``chat_completions``, where the leading ``request_messages`` entry is + the only copy +- output limit: ``max_tokens``, or ``max_completion_tokens`` on the OpenAI + routes that reject that name, ``max_output_tokens`` under + ``codex_responses``, ``inferenceConfig.maxTokens`` under ``bedrock_converse`` +- tools: ``body["tools"]``, or ``body["toolConfig"]["tools"]`` wrapped in a + ``toolSpec`` envelope under ``bedrock_converse`` + +The body is not raw. Hermes runs every hook payload through +``_sanitize_hook_payload`` against ``HERMES_PLUGIN_PAYLOAD_MAX_CHARS`` (50000 +by default), in three passes: + +1. unconditional: a string over 8000 chars gains a ``...[truncated N chars]`` + suffix, a list or dict over 200 entries gains a ``{"_truncated_items": N}`` + sentinel +2. still over the cap: the same, at 1000 chars and 50 entries +3. still over the cap: the whole envelope is replaced by ``{"_truncated": + True, "original_type": ..., "preview": ...}``, which has no ``body`` key + +Hermes's own system prompt and tool schemas cross that cap on ordinary +sessions, so a degraded payload is the normal case, not the exception. +``parse`` reports what it could not read, distinguishing a lost body +(``truncated``) from a field hermes shortened in place (``system_prompt_clipped``, +``tools_clipped``), and never raises. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from typing import Any + +from ._coerce import as_optional_float, as_optional_int, coerce_text + +logger = logging.getLogger(__name__) + +# Roles that carry the system prompt when it travels as a message. GPT-5 and +# Codex models take it as ``developer`` rather than ``system``. +_SYSTEM_ROLES = frozenset({"system", "developer"}) + +# What hermes leaves behind when it shortens a value in place. +_CLIPPED_TEXT = re.compile(r"\.\.\.\[truncated \d+ chars\]") +_CLIP_SENTINEL = "_truncated_items" +# Hermes stops recursing at depth 8, so nothing it clipped sits below that. +_MAX_SCAN_DEPTH = 8 + + +@dataclass(slots=True) +class RequestFacts: + """What one ``pre_api_request`` payload yielded, with gaps left empty.""" + + system_prompt: str = "" + # list[agento11y.ToolDefinition]. Left untyped so this module needs no + # import-time dependency on the SDK. + tools: list = field(default_factory=list) + max_tokens: int | None = None + temperature: float | None = None + top_p: float | None = None + tool_choice: str | None = None + # Hermes collapsed the envelope, so the provider body was unreadable. + truncated: bool = False + # Hermes shortened this field in place: the value is present but partial, + # so a complete earlier capture of it is worth more. + system_prompt_clipped: bool = False + tools_clipped: bool = False + + +def parse( + request: Any, + *, + system_prompt: Any = None, + request_messages: Any = None, +) -> RequestFacts: + """Read one ``pre_api_request`` payload into a ``RequestFacts``. + + ``system_prompt`` is the kwarg hermes added after 0.20.1 and no released + version sends; it is preferred where it exists because it is the unclipped + text. ``request_messages`` is the other unsanitized kwarg, and the only + place a ``chat_completions`` system prompt appears. + + A body hermes reshaped in a way this does not expect is missing data, not + an error, so nothing here propagates an exception to the hook. + """ + facts = RequestFacts() + + body: Any = {} + try: + candidate = request.get("body") if isinstance(request, dict) else None + if not isinstance(request, dict) or request.get("_truncated") or not isinstance(candidate, dict): + # No readable body. The two unsanitized kwargs are still worth + # reading, so carry on against an empty one rather than returning. + facts.truncated = True + else: + body = candidate + except Exception as exc: + # The reads below are inside their own guards, but this one decides + # what they read from, so it cannot be left to them. + logger.debug("grafana-agento11y-hermes: could not read the request envelope: %s", exc) + facts.truncated = True + + inference: Any = {} + + try: + candidate = body.get("inferenceConfig") + if isinstance(candidate, dict): + inference = candidate + facts.system_prompt, facts.system_prompt_clipped = _system_prompt(body, system_prompt, request_messages) + facts.max_tokens = _output_cap( + (body, "max_tokens"), + (body, "max_completion_tokens"), + (body, "max_output_tokens"), + (inference, "maxTokens"), + ) + facts.temperature = as_optional_float(_first_present((body, "temperature"), (inference, "temperature"))) + facts.top_p = as_optional_float(_first_present((body, "top_p"), (inference, "topP"))) + facts.tool_choice = _tool_choice(body.get("tool_choice")) + except Exception as exc: + logger.debug("grafana-agento11y-hermes: could not read the request body: %s", exc) + + # Tools last, in their own block. The mapping runs through a private SDK + # path, so a break there must not also cost the sampling params above. + try: + raw_tools = body.get("tools") + if raw_tools is None: + config = body.get("toolConfig") + raw_tools = config.get("tools") if isinstance(config, dict) else None + facts.tools_clipped = _carries_clip_marker(raw_tools) + facts.tools = _tool_definitions(_flatten_tool_specs(raw_tools)) + except Exception as exc: + logger.debug("grafana-agento11y-hermes: could not read the request tools: %s", exc) + + return facts + + +def _system_prompt(body: dict, system_prompt: Any, request_messages: Any) -> tuple[str, bool]: + """Resolve the system prompt, and say whether hermes clipped what we took. + + The candidates are in preference order, except that a complete one beats a + clipped one ahead of it: hermes sanitizes the body but passes + ``system_prompt`` and ``request_messages`` as it built them. + """ + candidates = ( + system_prompt, + body.get("system"), + body.get("instructions"), + _leading_system_message(request_messages), + ) + clipped_text = "" + for raw in candidates: + text = coerce_text(raw) + if not text: + continue + if not _carries_clip_marker(raw): + return text, False + if not clipped_text: + clipped_text = text + return clipped_text, bool(clipped_text) + + +def _leading_system_message(messages: Any) -> Any: + """Content of a leading system message, which ``chat_completions`` needs. + + That mode puts no ``system`` key on the body at all, so the first message + is the only copy hermes sends. + """ + if not isinstance(messages, list) or not messages: + return None + first = messages[0] + if not isinstance(first, dict) or first.get("role") not in _SYSTEM_ROLES: + return None + return first.get("content") + + +def _first_present(*sources: tuple[dict, str]) -> Any: + """First ``(mapping, key)`` pair that holds a value other than ``None``. + + A present ``0`` or ``0.0`` is a real setting, so the search is on ``None`` + rather than on falsiness. + """ + for mapping, key in sources: + value = mapping.get(key) + if value is not None: + return value + return None + + +def _output_cap(*sources: tuple[dict, str]) -> int | None: + """First ``(mapping, key)`` pair holding a usable output limit. + + Walking on past a value that is unreadable or at or below zero, rather than + stopping at the first key present, is what hermes's own reader + ``_requested_output_cap_from_api_kwargs`` (``run_agent.py``) does. A zero + cap is not a setting the way a zero temperature is: it would cap the + response at nothing, and hermes never puts it on the wire. That reader + tries the same three names in the opposite order, which decides nothing + here, because each transport writes exactly one cap key. + """ + for mapping, key in sources: + cap = as_optional_int(mapping.get(key)) + if cap is not None and cap > 0: + return cap + return None + + +def _flatten_tool_specs(tools: Any) -> Any: + """Unwrap the ``bedrock_converse`` ``toolSpec`` envelope, pass the rest through. + + Converse nests each tool as ``{"toolSpec": {"name", "description", + "inputSchema": {"json": ...}}}``, which is the Anthropic flat shape with + two extra wrappers. Every other mode already sends a shape the SDK mapper + reads, and the clipped-list sentinel has no ``toolSpec``, so both fall + through untouched. + """ + if not isinstance(tools, list): + return tools + out = [] + for entry in tools: + spec = entry.get("toolSpec") if isinstance(entry, dict) else None + if not isinstance(spec, dict): + out.append(entry) + continue + schema = spec.get("inputSchema") + out.append( + { + "name": spec.get("name"), + "description": spec.get("description"), + "input_schema": schema.get("json") if isinstance(schema, dict) else schema, + } + ) + return out + + +def _tool_definitions(tools: Any) -> list: + """Map the request's tool list through the SDK's own request mapper. + + ``payload_mapping`` reads the OpenAI nested, Responses flat and Anthropic + flat shapes, and skips an entry with no name, which is what drops the + ``{"_truncated_items": N}`` sentinel hermes appends to a clipped list. It + is not a public export; the ``agento11y>=0.17,<0.18`` pin in + ``pyproject.toml`` is what keeps that coupling under review. + """ + from agento11y.payload_mapping import tool_definitions + + return tool_definitions(tools) + + +def _carries_clip_marker(value: Any, depth: int = 0) -> bool: + """True when hermes shortened this value or anything under it. + + Both markers survive into the body: the ``...[truncated N chars]`` suffix + on a clipped string and the ``{"_truncated_items": N}`` sentinel on a + clipped list or dict. The sentinel has to be read here, before the SDK + mapper drops it for having no name. + """ + if depth > _MAX_SCAN_DEPTH: + return False + if isinstance(value, str): + return bool(_CLIPPED_TEXT.search(value)) + if isinstance(value, dict): + if _CLIP_SENTINEL in value: + return True + return any(_carries_clip_marker(item, depth + 1) for item in value.values()) + if isinstance(value, list): + return any(_carries_clip_marker(item, depth + 1) for item in value) + return False + + +def _tool_choice(value: Any) -> str | None: + """Collapse a tool-choice object to the string ``GenerationStart`` takes. + + Anthropic and the Responses API send ``{"type": "auto"}`` or + ``{"type": "tool", "name": ...}``; chat completions sends the bare string + or ``{"type": "function", "function": {"name": ...}}``. A forced tool keeps + its name, as ``type:name``, because which tool was forced is the whole + content of that choice. + """ + if isinstance(value, str): + return value.strip() or None + if not isinstance(value, dict): + return None + choice = value.get("type") + if not isinstance(choice, str) or not choice: + return None + name = value.get("name") + if not isinstance(name, str): + function = value.get("function") + name = function.get("name") if isinstance(function, dict) else None + return f"{choice}:{name}" if isinstance(name, str) and name else choice diff --git a/plugins/hermes/src/grafana_agento11y_hermes/_state.py b/plugins/hermes/src/grafana_agento11y_hermes/_state.py new file mode 100644 index 000000000..cfce4f8ed --- /dev/null +++ b/plugins/hermes/src/grafana_agento11y_hermes/_state.py @@ -0,0 +1,355 @@ +"""In-flight recorder state. + +Two maps, one per pairing strategy. + +``_REQ_STATE`` is the current path. Hermes v2026.6.5 and later, which is +``hermes-agent`` 0.16.0 and later on PyPI, pass ``api_request_id`` to both +``pre_api_request`` and ``post_api_request``. It is one id per API call, so the +pre/post pair needs no inference. The id is not unique per hook invocation: +every retry re-fires ``pre_api_request`` with the same one. + +``_GEN_STATE`` and the convo maps below serve the legacy path, for hermes +builds older than v2026.6.5 that send no ``api_request_id``. There the pair is +inferred from ``(task_id, session_id, api_call_count)``, and output content has +to be recovered later from ``post_llm_call``. Delete everything marked LEGACY +when support for pre-v2026.6.5 hermes is dropped. + +Generation state carries the parsed input messages alongside the recorder, +because ``set_result(input=[], output=...)`` would clear the input we seeded. + +Two more maps outlive the recorders. ``_GEN_LINKS`` keeps each request's +generation id and span context so a tool execution can be recorded inside that +generation's trace, which happens after the generation itself has closed. +``_TURN_LAST_GEN`` keeps the last generation of each turn, which is the parent +of the turn's next one. + +Tool executions don't need cross-hook state. We only register +``post_tool_call``, do all the work there, and close immediately. +""" + +from __future__ import annotations + +import threading +from collections import OrderedDict +from dataclasses import dataclass, field, replace +from datetime import datetime +from typing import Any + +from ._request import RequestFacts + + +@dataclass(slots=True) +class GenState: + recorder: Any + input_messages: list = field(default_factory=list) + system_prompt: str = "" + # Set on the current path so a session drain can find this request's state. + session_id: str = "" + # Pre-assigned at pre_api_request, so tools running after the generation + # closes can still name it, and so the close can chain the next call of the + # turn onto it. + generation_id: str = "" + turn_id: str = "" + # LEGACY: partial fields filled in by post_api_request when ``set_result`` + # is deferred to post_llm_call to recover the assistant message. The + # current path closes in post_api_request and never reads these. + usage: Any = None + finish_reason: str = "" + response_model: str = "" + # Captured at pre_api_request and post_api_request so the generation span + # and gen_ai.client.operation.duration metric reflect the LLM call alone, + # not the close-deferred recorder lifetime that runs through tool execution + # and any subsequent calls in the same turn. + started_at: datetime | None = None + api_duration: float | None = None + + +@dataclass(slots=True) +class GenLink: + """What a tool execution needs to point back at the call that requested it. + + Outlives the generation: the recorder closes in ``post_api_request`` and the + tools it asked for run after that. + """ + + generation_id: str + span_context: Any = None + session_id: str = "" + turn_id: str = "" + + +# Current path: api_request_id -> state. +_REQ_STATE: dict[str, GenState] = {} +# api_request_id -> GenLink, read by post_tool_call. Entries are dropped per +# session at session end, but a session that never ends (interrupt, one-shot +# exit) would leak, so both this and _TURN_LAST_GEN are capped and drop +# oldest-first. Insertion order is the eviction order, which dict guarantees. +_GEN_LINKS: dict[str, GenLink] = {} +# turn_id -> (generation_id, session_id) of the last generation kept in that +# turn, which the next call of the turn names as its parent. +_TURN_LAST_GEN: dict[str, tuple[str, str]] = {} +_MAX_ENTRIES = 512 +# LEGACY: inferred key for hermes builds without api_request_id. +_GEN_STATE: dict[tuple[str, str, int], GenState] = {} +# Per-(task_id, session_id) running hermes-shaped message list. Populated by +# ``pre_llm_call`` from ``conversation_history`` and extended in-place as +# ``post_tool_call`` fires — so each ``pre_api_request`` snapshot reflects +# the messages going into THIS request, not the start-of-turn snapshot. +_CONVO_STATE: dict[tuple[str, str], list[dict]] = {} +# Count of ``role="assistant"`` messages present in ``conversation_history`` +# at ``pre_llm_call`` time. Used by ``_close_pending_for_session`` to slice +# off prior turns' assistants and pair only this turn's outputs to recorders. +# A live count from ``_CONVO_STATE`` is wrong — ``post_tool_call`` extends +# the running convo with synthesized assistant tool-call messages, which we +# don't want included. +_TURN_START_ASST_COUNT: dict[tuple[str, str], int] = {} +# Last (model, provider) seen on a ``pre_api_request``, per session. +# ``post_tool_call`` carries neither, so without this the tool duration metric +# reports an empty ``gen_ai.request.model``. +_SESSION_MODEL: dict[str, tuple[str, str]] = {} + + +# Best read of a ``pre_api_request`` body so far, per session. Hermes clips the +# payload once its system prompt and tool schemas cross +# ``HERMES_PLUGIN_PAYLOAD_MAX_CHARS``, which happens on an ordinary session, so +# only the earliest requests of a session tend to arrive readable and the rest +# are filled in from here. +# +# ``tools/tool_search.py`` can swap the toolset mid-session, so a reused entry +# can name tools the current request did not send. A stale inventory is still +# worth more than an empty one, and an entry never leaves its own session. +# +# Sampling params are cached per model, +# because they are the one part of a capture that another model does not share: +# a mid-session fallback resolves its own cap and temperature from its own +# profile. The system prompt and the toolset belong to the agent rather than to +# the model, so those carry across such a switch. +# +# Nothing clears an entry: ``on_session_end`` fires per turn, not per session, +# so clearing there would empty the cache exactly when the second turn needs +# it. The bound below is what keeps a long-lived process from growing, and +# hermes issues a new session id on ``/reset``, so a stale entry is never read. +@dataclass(slots=True) +class SessionRequestFacts: + shared: RequestFacts + models: OrderedDict[str, RequestFacts] = field(default_factory=OrderedDict) + + +_SESSION_REQUEST_FACTS: OrderedDict[str, SessionRequestFacts] = OrderedDict() +# Sessions kept, least-recently-used evicted first. An entry holds a full +# system prompt plus every tool schema, so this is the largest map here. +_SESSION_FACTS_MAX = 32 +_SESSION_FACTS_MODELS_MAX = 8 +_LOCK = threading.Lock() + + +def req_put(request_id: str, state: GenState) -> GenState | None: + """Store state for a request, returning any state it displaced. + + Hermes assigns ``api_request_id`` above its retry loop, so a second + ``pre_api_request`` for the same id means the first attempt was abandoned. + The caller closes what it gets back, otherwise that recorder leaks. + """ + with _LOCK: + previous = _REQ_STATE.get(request_id) + _REQ_STATE[request_id] = state + return previous + + +def req_pop(request_id: str) -> GenState | None: + with _LOCK: + return _REQ_STATE.pop(request_id, None) + + +def req_pop_session(session_id: str) -> list[GenState]: + """Pop every request-keyed state for a session, for interrupt cleanup.""" + with _LOCK: + matching = [(k, v) for k, v in _REQ_STATE.items() if v.session_id == session_id] + for k, _ in matching: + del _REQ_STATE[k] + return [v for _, v in matching] + + +def _evict_oldest(mapping: dict) -> None: + """Drop from the front until the map is back under the cap. Caller holds the lock.""" + while len(mapping) > _MAX_ENTRIES: + del mapping[next(iter(mapping))] + + +def gen_link_put(request_id: str, link: GenLink) -> None: + if not request_id: + return + with _LOCK: + _GEN_LINKS[request_id] = link + _evict_oldest(_GEN_LINKS) + + +def gen_link_get(request_id: str) -> GenLink | None: + if not request_id: + return None + with _LOCK: + return _GEN_LINKS.get(request_id) + + +def gen_link_pop_session(session_id: str) -> None: + """Drop every link for a session, once its tools can no longer fire.""" + with _LOCK: + for key in [k for k, v in _GEN_LINKS.items() if v.session_id == session_id]: + del _GEN_LINKS[key] + + +def turn_last_gen_put(turn_id: str, generation_id: str, session_id: str) -> None: + if not (turn_id and generation_id): + return + with _LOCK: + _TURN_LAST_GEN[turn_id] = (generation_id, session_id) + _evict_oldest(_TURN_LAST_GEN) + + +def turn_last_gen_get(turn_id: str) -> str: + if not turn_id: + return "" + with _LOCK: + entry = _TURN_LAST_GEN.get(turn_id) + return entry[0] if entry else "" + + +def turn_last_gen_clear(turn_id: str) -> None: + with _LOCK: + _TURN_LAST_GEN.pop(turn_id, None) + + +def turn_last_gen_clear_session(session_id: str) -> None: + with _LOCK: + for key in [k for k, v in _TURN_LAST_GEN.items() if v[1] == session_id]: + del _TURN_LAST_GEN[key] + + +def gen_put(key: tuple[str, str, int], state: GenState) -> None: + with _LOCK: + _GEN_STATE[key] = state + + +def gen_get(key: tuple[str, str, int]) -> GenState | None: + with _LOCK: + return _GEN_STATE.get(key) + + +def gen_pop(key: tuple[str, str, int]) -> GenState | None: + with _LOCK: + return _GEN_STATE.pop(key, None) + + +def gen_pop_session(session_id: str) -> list[tuple[tuple[str, str, int], GenState]]: + """Pop and return all GenStates for a session, sorted by api_call_count.""" + with _LOCK: + matching = [(k, v) for k, v in _GEN_STATE.items() if k[1] == session_id] + for k, _ in matching: + del _GEN_STATE[k] + matching.sort(key=lambda kv: kv[0][2]) + return matching + + +def convo_set(key: tuple[str, str], messages: list[dict]) -> None: + with _LOCK: + _CONVO_STATE[key] = list(messages) + + +def convo_get(key: tuple[str, str]) -> list[dict]: + with _LOCK: + return list(_CONVO_STATE.get(key) or []) + + +def convo_append(key: tuple[str, str], message: dict) -> None: + with _LOCK: + if key in _CONVO_STATE: + _CONVO_STATE[key].append(message) + + +def convo_clear(key: tuple[str, str]) -> None: + with _LOCK: + _CONVO_STATE.pop(key, None) + + +def turn_start_asst_count_set(key: tuple[str, str], count: int) -> None: + with _LOCK: + _TURN_START_ASST_COUNT[key] = count + + +def turn_start_asst_count_get(key: tuple[str, str]) -> int | None: + with _LOCK: + return _TURN_START_ASST_COUNT.get(key) + + +def turn_start_asst_count_clear(key: tuple[str, str]) -> None: + with _LOCK: + _TURN_START_ASST_COUNT.pop(key, None) + + +def session_model_put(session_id: str, model: str, provider: str) -> None: + if not session_id: + return + with _LOCK: + _SESSION_MODEL[session_id] = (model, provider) + + +def session_model_get(session_id: str) -> tuple[str, str]: + with _LOCK: + return _SESSION_MODEL.get(session_id or "", ("", "")) + + +def session_model_clear(session_id: str) -> None: + with _LOCK: + _SESSION_MODEL.pop(session_id, None) + + +def session_facts_put(session_id: str, model: str, facts: RequestFacts) -> None: + """Keep this capture for the session, under the model its params belong to.""" + if not session_id: + return + with _LOCK: + entry = _SESSION_REQUEST_FACTS.get(session_id) + if entry is None: + entry = SessionRequestFacts(shared=facts) + _SESSION_REQUEST_FACTS[session_id] = entry + entry.shared = facts + entry.models[model] = replace(facts, system_prompt="", tools=[]) + entry.models.move_to_end(model) + while len(entry.models) > _SESSION_FACTS_MODELS_MAX: + entry.models.popitem(last=False) + _SESSION_REQUEST_FACTS.move_to_end(session_id) + while len(_SESSION_REQUEST_FACTS) > _SESSION_FACTS_MAX: + _SESSION_REQUEST_FACTS.popitem(last=False) + + +def session_facts_get(session_id: str, model: str | None = None) -> tuple[str, RequestFacts] | None: + """Combine shared prompt/tools with one model's params; default to the latest model.""" + with _LOCK: + entry = _SESSION_REQUEST_FACTS.get(session_id or "") + if entry is None: + return None + _SESSION_REQUEST_FACTS.move_to_end(session_id) + if model is None: + model = next(reversed(entry.models)) + cached = entry.models.get(model) + if cached is not None: + entry.models.move_to_end(model) + return model, replace( + cached if cached is not None else RequestFacts(), + system_prompt=entry.shared.system_prompt, + system_prompt_clipped=entry.shared.system_prompt_clipped, + tools=list(entry.shared.tools), + tools_clipped=entry.shared.tools_clipped, + ) + + +def reset_for_tests() -> None: + with _LOCK: + _REQ_STATE.clear() + _GEN_LINKS.clear() + _TURN_LAST_GEN.clear() + _GEN_STATE.clear() + _CONVO_STATE.clear() + _TURN_START_ASST_COUNT.clear() + _SESSION_MODEL.clear() + _SESSION_REQUEST_FACTS.clear() diff --git a/plugins/hermes/src/grafana_agento11y_hermes/_tags.py b/plugins/hermes/src/grafana_agento11y_hermes/_tags.py new file mode 100644 index 000000000..ed99d304d --- /dev/null +++ b/plugins/hermes/src/grafana_agento11y_hermes/_tags.py @@ -0,0 +1,197 @@ +"""Framework identity and opt-in automatic client tags. + +Automatic tags become metric labels. Resolve them once per process, only when +requested, and leave explicit environment tags to the SDK. Read Git files +without subprocesses so exporter credentials never reach a child process. +""" + +from __future__ import annotations + +import configparser +import getpass +import os +import re +from urllib.parse import urlsplit + +_ENTRYPOINT = "hermes" +_FRAMEWORK_TAGS = { + "agento11y.framework.name": "hermes", + "agento11y.framework.source": "plugin", + "agento11y.framework.language": "python", +} +_TAG_KEYS = {"user": "user", "repo": "repo", "branch": "git.branch"} +_MAX_VALUE_LENGTH = 128 +# Depth of the walk from cwd towards the filesystem root, matching the +# first-party resolver. +_MAX_PARENTS = 6 +_GITDIR_LINE = re.compile(r"^gitdir:\s*(.+)$", re.MULTILINE) +_HEAD_REF = re.compile(r"^ref:\s*refs/heads/(.+)$") +_SHA = re.compile(r"^[0-9a-fA-F]{7,}$") + +_CACHED: dict[str, str] | None = None + + +def builtin_tags() -> dict[str, str]: + """The built-in tags, resolved on the first call and cached after. + + Keys that cannot be resolved are omitted rather than sent empty. + """ + global _CACHED + if _CACHED is None: + _CACHED = _resolve() + return dict(_CACHED) + + +def client_tags() -> dict[str, str]: + """Client labels, with explicit environment tags left for the SDK to merge.""" + explicit = _explicit_tags() + return {k: v for k, v in {**_FRAMEWORK_TAGS, **builtin_tags()}.items() if k not in explicit} + + +def seed_tags() -> dict[str, str]: + """No unconditional working directory or branch on generations.""" + return {} + + +def _resolve() -> dict[str, str]: + tags = {"entrypoint": _ENTRYPOINT} + explicit = _explicit_tags() + for name in _selected_tags(): + key = _TAG_KEYS[name] + if key in explicit: + continue + try: + if name == "user": + value = _env("USER_ID") or getpass.getuser() + else: + cwd = os.getcwd() + value = _git_repo(cwd) if name == "repo" else _git_branch(cwd) + value = value.strip()[:_MAX_VALUE_LENGTH] + if value: + tags[key] = value + except Exception: + # Telemetry must not interrupt the agent if account or Git lookup fails. + continue + return tags + + +def _env(suffix: str) -> str: + for prefix in ("AGENTO11Y_", "SIGIL_"): + value = os.environ.get(prefix + suffix, "").strip() + if value: + return value + return "" + + +def _selected_tags() -> list[str]: + if _env("AUTO_CODING_AGENT_TAGS").lower() not in {"1", "true", "yes", "on"}: + return [] + raw = _env("AUTO_CODING_AGENT_TAGS_NAMES") + names = {name.strip().lower() for name in raw.split(",")} + return [name for name in _TAG_KEYS if not raw or "all" in names or name in names] + + +def _explicit_tags() -> dict[str, str]: + tags = {} + for pair in _env("TAGS").split(","): + key, sep, value = pair.partition("=") + if sep and key.strip() and value.strip(): + tags[key.strip()] = value.strip() + return tags + + +def _git_repo(start: str) -> str: + git_dir = _find_git_dir(start) + if not git_dir: + return "" + common = _read_git_file(os.path.join(git_dir, "commondir")) + if common: + git_dir = os.path.normpath(os.path.join(git_dir, common)) + config = configparser.RawConfigParser(strict=False) + try: + config.read_string(_read_git_file(os.path.join(git_dir, "config"))) + remote = config.get('remote "origin"', "url", fallback="") + except configparser.Error: + remote = "" + repo = _repo_from_remote(remote) + if repo: + return repo + base = ( + os.path.basename(os.path.dirname(git_dir)) if os.path.basename(git_dir) == ".git" else os.path.basename(git_dir) + ) + return base.removesuffix(".git") + + +def _repo_from_remote(raw: str) -> str: + url = raw.strip() + if not url: + return "" + if "://" in url: + try: + parsed = urlsplit(url) + except ValueError: + return "" + path = parsed.path.rstrip("/") + if parsed.scheme == "file": + path = os.path.basename(path) + elif re.match(r"^[^/:]+:", url): + path = url.split(":", 1)[1].rstrip("/") + else: + path = os.path.basename(url.rstrip("/")) + return path.lstrip("/").removesuffix(".git") + + +def _read_git_file(path: str) -> str: + try: + with open(path, encoding="utf-8") as handle: + return handle.read().strip() + except (OSError, UnicodeError): + return "" + + +def _git_branch(start: str) -> str: + """Checked-out branch, the first 12 chars of a detached HEAD, or ``""``.""" + git_dir = _find_git_dir(start) + if not git_dir: + return "" + head = _read_git_file(os.path.join(git_dir, "HEAD")) + match = _HEAD_REF.match(head) + if match: + return match.group(1).strip() + return head[:12] if _SHA.match(head) else "" + + +def _find_git_dir(start: str) -> str: + current = start + for _ in range(_MAX_PARENTS): + resolved = _resolve_git_entry(os.path.join(current, ".git")) + if resolved: + return resolved + parent = os.path.dirname(current) + if parent == current: + break + current = parent + return "" + + +def _resolve_git_entry(path: str) -> str: + """The git directory ``path`` names: itself, or its ``gitdir:`` target. + + A linked worktree and a submodule both have ``.git`` as a file holding a + ``gitdir:`` pointer, so HEAD lives elsewhere. + """ + if os.path.isdir(path): + return path + content = _read_git_file(path) + match = _GITDIR_LINE.search(content) + if not match: + return "" + target = match.group(1).strip() + if not os.path.isabs(target): + target = os.path.join(os.path.dirname(path), target) + return os.path.normpath(target) + + +def _reset_for_tests() -> None: + global _CACHED + _CACHED = None diff --git a/plugins/hermes/src/grafana_agento11y_hermes/_version.py b/plugins/hermes/src/grafana_agento11y_hermes/_version.py new file mode 100644 index 000000000..6bc290290 --- /dev/null +++ b/plugins/hermes/src/grafana_agento11y_hermes/_version.py @@ -0,0 +1,44 @@ +"""Plugin identity for the generation-export User-Agent. + +The sibling Agent Observability plugins (claude-code, codex, copilot, cursor, +pi) tag their generation exports with a most-specific-first User-Agent so the +backend can attribute ingest traffic to the integration. We match that +convention: + + agento11y-plugin-hermes/ agento11y-sdk-python/ +""" + +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version + +_PLUGIN_PRODUCT = "agento11y-plugin-hermes" +_SDK_PRODUCT = "agento11y-sdk-python" + + +def _plugin_version() -> str: + try: + return version("grafana-agento11y-hermes") + except PackageNotFoundError: + return "dev" + + +def _sdk_user_agent() -> str: + """SDK product token, e.g. ``agento11y-sdk-python/0.14.0``. + + The SDK exposes this directly; the metadata fallback covers a build that + does not, using the same format the SDK uses. + """ + try: + from agento11y.version import user_agent + + return user_agent() + except Exception: + try: + return f"{_SDK_PRODUCT}/{version('agento11y')}" + except PackageNotFoundError: + return f"{_SDK_PRODUCT}/unknown" + + +def plugin_user_agent() -> str: + return f"{_PLUGIN_PRODUCT}/{_plugin_version()} {_sdk_user_agent()}" diff --git a/plugins/hermes/tests/__init__.py b/plugins/hermes/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/hermes/tests/conftest.py b/plugins/hermes/tests/conftest.py new file mode 100644 index 000000000..30150c10a --- /dev/null +++ b/plugins/hermes/tests/conftest.py @@ -0,0 +1,218 @@ +"""Fake SDK clients avoid network calls; state resets prevent test-order leaks.""" + +from __future__ import annotations + +import itertools +from collections.abc import Callable, Iterator +from typing import Any + +import pytest +from opentelemetry import trace + +from grafana_agento11y_hermes import _client, _hooks, _state, _tags + + +class FakeSpan: + """Stand-in for the OTel span the SDK recorders expose. + + Hands out a valid ``SpanContext`` so the parenting path in + ``on_post_tool_call`` behaves as it does against the real SDK, and records + attribute writes. + """ + + _next_id = itertools.count(1) + + def __init__(self) -> None: + ident = next(FakeSpan._next_id) + self.attributes: dict[str, Any] = {} + self._context = trace.SpanContext( + trace_id=ident, + span_id=ident, + is_remote=False, + trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED), + ) + + def get_span_context(self) -> trace.SpanContext: + return self._context + + def set_attribute(self, key: str, value: Any) -> None: + self.attributes[key] = value + + +class SdkExploded(RuntimeError): + """What an injected SDK failure raises. Distinct so a test can name it.""" + + +class FakeRecorder: + """Records lifecycle calls for assertions. + + ``raises`` names the methods that blow up instead of recording, which is how + ``test_fail_open`` drives every SDK failure the plugin has to survive. + """ + + def __init__(self, raises: frozenset[str] = frozenset()) -> None: + self.span = FakeSpan() + self.entered = False + self.exited = False + self.raises = raises + self.set_result_calls: list[dict[str, Any]] = [] + self.set_call_error_calls: list[Exception] = [] + self.set_exec_error_calls: list[Exception] = [] + # Method names in the order they were called, for tests that assert + # ordering rather than just occurrence. + self.calls: list[str] = [] + + def _maybe_raise(self, name: str) -> None: + if name in self.raises: + raise SdkExploded(f"{name} exploded") + + def __enter__(self) -> FakeRecorder: + self.entered = True + self._maybe_raise("__enter__") + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + self.exited = True + self._maybe_raise("__exit__") + return False + + def set_result(self, *args: Any, **kwargs: Any) -> None: + self.calls.append("set_result") + self.set_result_calls.append(dict(kwargs)) + self._maybe_raise("set_result") + + def set_call_error(self, error: Exception) -> None: + self.calls.append("set_call_error") + self.set_call_error_calls.append(error) + self._maybe_raise("set_call_error") + + def set_exec_error(self, error: Exception) -> None: + self.calls.append("set_exec_error") + self.set_exec_error_calls.append(error) + self._maybe_raise("set_exec_error") + + +class FakeClient: + """In-memory stand-in for ``agento11y.Client``. + + ``raises`` names the client and recorder methods that fail. Recorder names + are handed to every recorder this client hands out. + """ + + def __init__(self, *args: Any, raises: frozenset[str] = frozenset(), **kwargs: Any) -> None: + self.start_generation_calls: list[Any] = [] + self.start_tool_execution_calls: list[Any] = [] + self.flush_calls = 0 + self.shutdown_calls = 0 + self.raises = raises + self.init_args = args + self.init_kwargs = kwargs + self._next_gen_recorder: FakeRecorder | None = None + self._next_tool_recorder: FakeRecorder | None = None + + def _maybe_raise(self, name: str) -> None: + if name in self.raises: + raise SdkExploded(f"{name} exploded") + + def start_generation(self, start: Any) -> FakeRecorder: + self.start_generation_calls.append(start) + self._maybe_raise("start_generation") + rec = FakeRecorder(self.raises) + self._next_gen_recorder = rec + return rec + + def start_tool_execution(self, start: Any) -> FakeRecorder: + self.start_tool_execution_calls.append(start) + self._maybe_raise("start_tool_execution") + rec = FakeRecorder(self.raises) + self._next_tool_recorder = rec + return rec + + def flush(self) -> None: + self.flush_calls += 1 + self._maybe_raise("flush") + + def shutdown(self) -> None: + self.shutdown_calls += 1 + + +@pytest.fixture(autouse=True) +def reset_module_state() -> Iterator[None]: + _client._reset_for_tests() + _state.reset_for_tests() + _hooks._reset_for_tests() + _tags._reset_for_tests() + yield + _client._reset_for_tests() + _state.reset_for_tests() + _hooks._reset_for_tests() + _tags._reset_for_tests() + + +@pytest.fixture +def env_creds(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTO11Y_ENDPOINT", "http://localhost/api/v1/generations:export") + monkeypatch.setenv("AGENTO11Y_PROTOCOL", "http") + monkeypatch.setenv("AGENTO11Y_AUTH_MODE", "basic") + monkeypatch.setenv("AGENTO11Y_AUTH_TENANT_ID", "stack-1") + monkeypatch.setenv("AGENTO11Y_AUTH_TOKEN", "glc_secret") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost/otlp") + + +@pytest.fixture +def patch_client(monkeypatch: pytest.MonkeyPatch, env_creds: None) -> FakeClient: + import agento11y + + instances: list[FakeClient] = [] + + def factory(*args: Any, **kwargs: Any) -> FakeClient: + instance = FakeClient(*args, **kwargs) + instances.append(instance) + return instance + + monkeypatch.setattr(agento11y, "Client", factory) + # Skip the real OTel auto-setup — tests for that path are isolated. + from grafana_agento11y_hermes import _otel + + monkeypatch.setattr(_otel, "setup_if_needed", lambda cfg: True) + + client = _client._get_client() + assert isinstance(client, FakeClient), "fake client should have been constructed" + return client + + +@pytest.fixture +def failing_client(monkeypatch: pytest.MonkeyPatch, env_creds: None) -> Callable[..., FakeClient]: + """Build the singleton with named client and recorder methods raising ``SdkExploded``.""" + import agento11y + + from grafana_agento11y_hermes import _otel + + def build(*names: str) -> FakeClient: + raises = frozenset(names) + + def factory(*args: Any, **kwargs: Any) -> FakeClient: + return FakeClient(*args, raises=raises, **kwargs) + + monkeypatch.setattr(agento11y, "Client", factory) + monkeypatch.setattr(_otel, "setup_if_needed", lambda cfg: True) + client = _client._get_client() + assert isinstance(client, FakeClient), "fake client should have been constructed" + return client + + return build + + +class FakeContext: + """Stand-in for the hermes plugin context object passed to ``register``.""" + + def __init__(self) -> None: + self.hooks: dict[str, Any] = {} + + def register_hook(self, name: str, handler: Any) -> None: + self.hooks[name] = handler + + +@pytest.fixture +def ctx() -> FakeContext: + return FakeContext() diff --git a/plugins/hermes/tests/test_coerce.py b/plugins/hermes/tests/test_coerce.py new file mode 100644 index 000000000..341f094b3 --- /dev/null +++ b/plugins/hermes/tests/test_coerce.py @@ -0,0 +1,109 @@ +"""Type coercion for whatever hermes put on a hook payload. + +Hermes mirrors provider shapes rather than normalizing them, so these helpers +are what stands between a provider's idea of a message and the SDK's. Each one +has to absorb the wrong type rather than raise, because they are called inside +``set_result``'s argument list, where raising costs the whole generation. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from grafana_agento11y_hermes._coerce import as_int, as_optional_float, as_optional_int, coerce_text + + +class _Block: + def __repr__(self) -> str: + return "" + + +@pytest.mark.parametrize( + ("content", "expected"), + ( + (None, ""), + ("plain text", "plain text"), + ("", ""), + ([], ""), + (["one", "two"], "one\ntwo"), + # Anthropic-shaped typed blocks. + ([{"type": "text", "text": "hello"}], "hello"), + ([{"type": "text", "text": "a"}, {"type": "text", "text": "b"}], "a\nb"), + # Some providers name the field ``content`` instead. + ([{"content": "hello"}], "hello"), + ([{"text": "from-text", "content": "from-content"}], "from-text"), + # A block with neither is serialized rather than dropped, so a + # thinking or image block still shows up in the recorded input. + ([{"type": "image", "source": {"kind": "b64"}}], '{"type": "image", "source": {"kind": "b64"}}'), + ([_Block()], ""), + ([7, True], "7\nTrue"), + (["a", "", "b"], "a\nb"), + ([{"text": ""}, {"text": "kept"}], "kept"), + (42, "42"), + ({"role": "user"}, "{'role': 'user'}"), + ), +) +def test_content_coerces_to_text(content: Any, expected: str) -> None: + assert coerce_text(content) == expected + + +def test_an_unserializable_block_uses_the_default_stringifier() -> None: + """``json.dumps(..., default=str)`` — a block must never raise out of here.""" + out = coerce_text([{"type": "custom", "value": _Block()}]) + assert "" in out + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + (5, 5), + ("5", 5), + (5.9, 5), + (None, 0), + ("", 0), + (0, 0), + ("not a number", 0), + ([], 0), + ({}, 0), + (object(), 0), + ), +) +def test_as_int_never_raises(value: Any, expected: int) -> None: + assert as_int(value) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + (5, 5), + ("5", 5), + (5.9, 5), + # Zero is a real value here, unlike in as_int's ``value or 0``. + (0, 0), + (None, None), + ("", None), + ("not a number", None), + (object(), None), + ), +) +def test_as_optional_int_returns_none_rather_than_raising(value: Any, expected: int | None) -> None: + assert as_optional_int(value) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + (1.5, 1.5), + ("1.5", 1.5), + (2, 2.0), + (0, 0.0), + (None, None), + ("", None), + ("not a number", None), + (object(), None), + ), +) +def test_as_optional_float_returns_none_rather_than_raising(value: Any, expected: float | None) -> None: + assert as_optional_float(value) == expected diff --git a/plugins/hermes/tests/test_compat.py b/plugins/hermes/tests/test_compat.py new file mode 100644 index 000000000..5d3c07b4f --- /dev/null +++ b/plugins/hermes/tests/test_compat.py @@ -0,0 +1,110 @@ +"""Legacy env var promotion. + +``_compat`` promotes supported aliases before plugin or SDK config is read. These tests drive ``apply_legacy_env`` with an explicit dict, +which also bypasses its once-per-process guard. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from grafana_agento11y_hermes import _compat + + +def test_old_name_is_copied_not_moved() -> None: + """Hermes tool subprocesses inherit the environment, so the old name stays.""" + env = {"SIGIL_AUTH_TOKEN": "glc_secret"} + + promoted = _compat.apply_legacy_env(env) + + assert promoted == ["SIGIL_AUTH_TOKEN"] + assert env == {"SIGIL_AUTH_TOKEN": "glc_secret", "AGENTO11Y_AUTH_TOKEN": "glc_secret"} + + +def test_new_name_wins_when_both_are_set() -> None: + env = {"SIGIL_AUTH_TOKEN": "old", "AGENTO11Y_AUTH_TOKEN": "new"} + + promoted = _compat.apply_legacy_env(env) + + assert promoted == [] + assert env == {"SIGIL_AUTH_TOKEN": "old", "AGENTO11Y_AUTH_TOKEN": "new"} + + +@pytest.mark.parametrize( + ("old", "new"), + [ + # These two aliases rename more than the prefix. + ("SIGIL_API_ENDPOINT", "AGENTO11Y_ENDPOINT"), + ("SIGIL_TENANT_ID", "AGENTO11Y_AUTH_TENANT_ID"), + ("SIGIL_REDACT_INPUT_MESSAGES", "AGENTO11Y_REDACT_INPUT_MESSAGES"), + ("SIGIL_SERVICE_ACCOUNT_TOKEN", "AGENTO11Y_SERVICE_ACCOUNT_TOKEN"), + ("SIGIL_DEBUG", "AGENTO11Y_DEBUG"), + ("SIGIL_HEADERS", "AGENTO11Y_HEADERS"), + ("SIGIL_HERMES_MAX_CHARS", "AGENTO11Y_HERMES_MAX_CHARS"), + ], +) +def test_renames_cover_transport_privacy_and_plugin_settings(old: str, new: str) -> None: + env = {old: "v"} + + _compat.apply_legacy_env(env) + + assert env == {old: "v", new: "v"} + + +def test_local_rename_table_covers_credentials() -> None: + """Credentials and privacy aliases do not depend on SDK internals.""" + table = _compat.renames() + + assert table["SIGIL_AUTH_TOKEN"] == "AGENTO11Y_AUTH_TOKEN" + assert table["SIGIL_CONTENT_CAPTURE_MODE"] == "AGENTO11Y_CONTENT_CAPTURE_MODE" + + +def test_aliases_survive_an_sdk_without_the_table(monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + real_import = builtins.__import__ + + # The parameters are spelled out rather than taken as *args, because the + # shim is checked against the real ``__import__`` signature. + def guarded(name: str, globals: Any = None, locals: Any = None, fromlist: Any = (), level: int = 0) -> Any: + if name == "agento11y.config": + raise ImportError("no config module") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", guarded) + + table = _compat.renames() + + assert table["SIGIL_AUTH_TOKEN"] == "AGENTO11Y_AUTH_TOKEN" + assert table["SIGIL_HERMES_MAX_CHARS"] == "AGENTO11Y_HERMES_MAX_CHARS" + + +def test_unrelated_names_are_untouched() -> None: + env = {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://otlp", "HERMES_SIGIL_API_KEY": "stale"} + + promoted = _compat.apply_legacy_env(env) + + assert promoted == [] + assert env == {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://otlp", "HERMES_SIGIL_API_KEY": "stale"} + + +def test_os_environ_path_runs_once(monkeypatch: pytest.MonkeyPatch) -> None: + _compat._reset_for_tests() + monkeypatch.setenv("SIGIL_AUTH_TOKEN", "glc_secret") + monkeypatch.delenv("AGENTO11Y_AUTH_TOKEN", raising=False) + + first = _compat.apply_legacy_env() + + import os + + assert first == ["SIGIL_AUTH_TOKEN"] + assert os.environ["AGENTO11Y_AUTH_TOKEN"] == "glc_secret" + assert os.environ["SIGIL_AUTH_TOKEN"] == "glc_secret" + + monkeypatch.setenv("SIGIL_AUTH_TOKEN", "later") + assert _compat.apply_legacy_env() == [] + assert os.environ["AGENTO11Y_AUTH_TOKEN"] == "glc_secret" + + _compat._reset_for_tests() diff --git a/plugins/hermes/tests/test_config.py b/plugins/hermes/tests/test_config.py new file mode 100644 index 000000000..13c1f23a0 --- /dev/null +++ b/plugins/hermes/tests/test_config.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import base64 +import logging + +import pytest + +from grafana_agento11y_hermes import _config + + +def _expected_basic(tenant: str, token: str) -> str: + creds = base64.b64encode(f"{tenant}:{token}".encode()).decode() + return f"Basic {creds}" + + +@pytest.fixture(autouse=True) +def _clear_auth_env(monkeypatch: pytest.MonkeyPatch) -> None: + for var in ( + "AGENTO11Y_AUTH_MODE", + "AGENTO11Y_AUTH_TENANT_ID", + "AGENTO11Y_AUTH_TOKEN", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT", + "AGENTO11Y_HERMES_ERROR_FLUSH_TIMEOUT", + "AGENTO11Y_HERMES_SAMPLE_RATE", + "AGENTO11Y_HERMES_MAX_CHARS", + "AGENTO11Y_HERMES_OTEL_AUTO", + "AGENTO11Y_HEADERS", + ): + monkeypatch.delenv(var, raising=False) + + +def test_otel_auth_headers_derived_from_generations_creds(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTO11Y_AUTH_TENANT_ID", "stack-1") + monkeypatch.setenv("AGENTO11Y_AUTH_TOKEN", "glc_secret") + assert _config._otel_auth_headers() == { + "Authorization": _expected_basic("stack-1", "glc_secret"), + "X-Scope-OrgID": "stack-1", + } + + +def test_otel_auth_headers_empty_without_tenant(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTO11Y_AUTH_TOKEN", "glc_secret") + assert _config._otel_auth_headers() == {} + + +def test_otel_auth_headers_empty_without_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTO11Y_AUTH_TENANT_ID", "stack-1") + assert _config._otel_auth_headers() == {} + + +def test_otel_auth_headers_skipped_for_bearer_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """A bearer token is not a basic password — don't derive basic auth from it.""" + monkeypatch.setenv("AGENTO11Y_AUTH_MODE", "bearer") + monkeypatch.setenv("AGENTO11Y_AUTH_TENANT_ID", "stack-1") + monkeypatch.setenv("AGENTO11Y_AUTH_TOKEN", "glc_secret") + assert _config._otel_auth_headers() == {} + + +def test_load_populates_otel_auth_headers(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTO11Y_AUTH_TENANT_ID", "stack-1") + monkeypatch.setenv("AGENTO11Y_AUTH_TOKEN", "glc_secret") + cfg = _config.load() + assert cfg.otel_auth_headers == { + "Authorization": _expected_basic("stack-1", "glc_secret"), + "X-Scope-OrgID": "stack-1", + } + + +def test_load_otel_auth_headers_empty_when_no_creds() -> None: + assert _config.load().otel_auth_headers == {} + + +# --- OTLP endpoint resolution --- + + +def test_otel_unconfigured_without_any_endpoint() -> None: + cfg = _config.load() + assert cfg.otel_configured is False + assert cfg.otel_endpoint_override == "" + + +def test_standard_endpoint_needs_no_override(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://otlp.example/otlp") + cfg = _config.load() + assert cfg.otel_configured is True + assert cfg.otel_endpoint_override == "" + + +def test_branded_endpoint_configures_otel(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT", "https://otlp.example/otlp") + cfg = _config.load() + assert cfg.otel_configured is True + assert cfg.otel_endpoint_override == "https://otlp.example/otlp" + + +def test_standard_endpoint_wins_over_branded(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT", "https://branded.example/otlp") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://standard.example/otlp") + cfg = _config.load() + assert cfg.otel_endpoint_override == "", "the exporters read the standard env themselves" + + +def test_blank_branded_endpoint_is_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT", " ") + cfg = _config.load() + assert cfg.otel_configured is False + assert cfg.otel_endpoint_override == "" + + +# --- error flush timeout --- + + +def test_error_flush_timeout_defaults_to_two_seconds() -> None: + assert _config.load().error_flush_timeout == 2.0 + + +def test_error_flush_timeout_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTO11Y_HERMES_ERROR_FLUSH_TIMEOUT", "0.5") + assert _config.load().error_flush_timeout == 0.5 + + +def test_error_flush_timeout_zero_disables(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTO11Y_HERMES_ERROR_FLUSH_TIMEOUT", "0") + assert _config.load().error_flush_timeout == 0.0 + + +# --- env parsing --- +# +# An unreadable value has to fall back to the default and say so, rather than +# raising out of load() and taking the whole plugin down at import time. + + +@pytest.mark.parametrize( + ("raw", "expected"), + (("0.5", 0.5), ("1", 1.0), (" 0.25 ", 0.25), ("", 1.0), (" ", 1.0), ("half", 1.0), ("1,5", 1.0)), +) +def test_a_float_env_falls_back_to_its_default(monkeypatch: pytest.MonkeyPatch, raw: str, expected: float) -> None: + monkeypatch.setenv("AGENTO11Y_HERMES_SAMPLE_RATE", raw) + assert _config.load().sample_rate == expected + + +@pytest.mark.parametrize( + ("raw", "expected"), + (("500", 500), (" 500 ", 500), ("", 12000), ("lots", 12000), ("1.5", 12000)), +) +def test_an_int_env_falls_back_to_its_default(monkeypatch: pytest.MonkeyPatch, raw: str, expected: int) -> None: + monkeypatch.setenv("AGENTO11Y_HERMES_MAX_CHARS", raw) + assert _config.load().max_chars == expected + + +def test_an_unreadable_env_value_is_logged(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None: + monkeypatch.setenv("AGENTO11Y_HERMES_MAX_CHARS", "lots") + with caplog.at_level(logging.WARNING): + _config.load() + assert [r for r in caplog.records if "AGENTO11Y_HERMES_MAX_CHARS" in r.getMessage()] + + +@pytest.mark.parametrize( + ("raw", "expected"), + ( + ("1", True), + ("true", True), + ("TRUE", True), + ("yes", True), + ("on", True), + ("0", False), + ("false", False), + ("no", False), + ("anything else", False), + ("", True), + (" ", True), + ), +) +def test_a_bool_env_reads_the_usual_spellings(monkeypatch: pytest.MonkeyPatch, raw: str, expected: bool) -> None: + monkeypatch.setenv("AGENTO11Y_HERMES_OTEL_AUTO", raw) + assert _config.load().otel_auto is expected + + +# --- generations channel presence --- + + +@pytest.mark.parametrize( + ("env", "expected"), + ( + ({}, False), + ({"AGENTO11Y_AUTH_TOKEN": "glc_secret"}, True), + ({"AGENTO11Y_AUTH_TOKEN": " "}, False), + ({"AGENTO11Y_AUTH_MODE": "basic"}, True), + ({"AGENTO11Y_AUTH_MODE": "bearer"}, True), + ({"AGENTO11Y_AUTH_MODE": "none"}, False), + ({"AGENTO11Y_AUTH_MODE": "NONE"}, False), + ), +) +def test_the_generations_channel_is_configured_by_token_or_mode( + monkeypatch: pytest.MonkeyPatch, env: dict[str, str], expected: bool +) -> None: + for key, value in env.items(): + monkeypatch.setenv(key, value) + assert _config.load().generations_configured is expected + + +# --- header parsing --- + + +@pytest.mark.parametrize( + ("raw", "expected"), + ( + ("", {}), + ("X-Foo=bar", {"X-Foo": "bar"}), + ("X-Foo=bar,X-Baz=qux", {"X-Foo": "bar", "X-Baz": "qux"}), + (" X-Foo = bar , X-Baz=qux ", {"X-Foo": "bar", "X-Baz": "qux"}), + ("Authorization=Basic dGVzdA==", {"Authorization": "Basic dGVzdA=="}), + ("novalue,X-Foo=bar", {"X-Foo": "bar"}), + ("=orphan,X-Foo=bar", {"X-Foo": "bar"}), + (",,", {}), + ("X-Empty=", {"X-Empty": ""}), + ), +) +def test_export_headers_parse_like_the_sdk(monkeypatch: pytest.MonkeyPatch, raw: str, expected: dict[str, str]) -> None: + monkeypatch.setenv("AGENTO11Y_HEADERS", raw) + assert _config.load().export_headers == expected diff --git a/plugins/hermes/tests/test_fail_open.py b/plugins/hermes/tests/test_fail_open.py new file mode 100644 index 000000000..a335ae427 --- /dev/null +++ b/plugins/hermes/tests/test_fail_open.py @@ -0,0 +1,236 @@ +"""Hook failures must not interrupt the hermes loop.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import Any + +import pytest + +from grafana_agento11y_hermes import _hooks, _state +from tests.conftest import FakeClient + +# Every SDK call the plugin makes across a full turn. Each one is a place the +# real client can raise: a closed client, a full queue, a recorder the SDK +# changed the shape of. +SDK_CALLS = ( + "start_generation", + "start_tool_execution", + "flush", + "__enter__", + "__exit__", + "set_result", + "set_call_error", + "set_exec_error", +) + + +def _drive_a_turn(*, session: str = "s1", request: str = "req-1") -> None: + """Fire the hooks of one tool-calling turn, in the order hermes fires them.""" + _hooks.on_pre_llm_call(session_id=session, conversation_history=[], user_message="hi") + _hooks.on_pre_api_request( + session_id=session, + api_request_id=request, + turn_id="turn-1", + model="claude-opus-5", + provider="anthropic", + conversation_history=[{"role": "user", "content": "hi"}], + tool_count=1, + ) + _hooks.on_post_tool_call( + session_id=session, + api_request_id=request, + tool_name="bash", + tool_call_id="call-1", + args={"command": "ls"}, + result="a.txt", + duration_ms=12, + ) + _hooks.on_post_api_request( + session_id=session, + api_request_id=request, + model="claude-opus-5", + assistant_message={"role": "assistant", "content": "done"}, + usage={"input_tokens": 10, "output_tokens": 2}, + api_duration=0.5, + ) + _hooks.on_post_llm_call(session_id=session, turn_id="turn-1", conversation_history=[]) + _hooks.on_session_end(session_id=session) + _hooks.on_session_finalize(session_id=session) + + +@pytest.mark.parametrize("failing_call", SDK_CALLS) +def test_a_raising_sdk_call_never_reaches_the_hermes_loop( + failing_call: str, + failing_client: Callable[..., FakeClient], +) -> None: + failing_client(failing_call) + _drive_a_turn() + + +@pytest.mark.parametrize("failing_call", SDK_CALLS) +def test_a_raising_sdk_call_on_the_error_path_is_contained( + failing_call: str, + failing_client: Callable[..., FakeClient], +) -> None: + failing_client(failing_call) + _hooks.on_pre_api_request(session_id="s1", api_request_id="req-1", model="m", provider="p") + _hooks.on_api_request_error(api_request_id="req-1", error={"type": "RateLimit"}, status_code=429) + _hooks.on_session_end(session_id="s1") + + +def test_every_sdk_call_failing_at_once_still_completes_a_turn( + failing_client: Callable[..., FakeClient], +) -> None: + failing_client(*SDK_CALLS) + _drive_a_turn() + + +def test_a_failure_is_logged_rather_than_swallowed_silently( + failing_client: Callable[..., FakeClient], + caplog: pytest.LogCaptureFixture, +) -> None: + failing_client("start_generation") + with caplog.at_level(logging.WARNING): + _hooks.on_pre_api_request(session_id="s1", api_request_id="req-1", model="m", provider="p") + assert [r for r in caplog.records if "on_pre_api_request failed" in r.getMessage()] + + +# Each hook paired with a ``_state`` function it reaches on the way through. +# Poisoning that function is what proves the handler's own guard is there, as +# opposed to the narrower ones around the SDK calls. +HOOK_POISON: tuple[tuple[str, dict[str, Any], str], ...] = ( + ("on_pre_llm_call", {"session_id": "s1", "conversation_history": []}, "convo_set"), + ("on_post_llm_call", {"session_id": "s1", "turn_id": "t1"}, "gen_pop_session"), + ("on_pre_api_request", {"session_id": "s1", "api_request_id": "r1"}, "session_facts_get"), + ("on_post_api_request", {"session_id": "s1", "api_request_id": "r1"}, "req_pop"), + ("on_api_request_error", {"api_request_id": "r1"}, "req_pop"), + ("on_post_tool_call", {"session_id": "s1", "tool_name": "bash"}, "session_model_get"), + ("on_session_end", {"session_id": "s1"}, "req_pop_session"), + ("on_session_finalize", {"session_id": "s1"}, "req_pop_session"), +) + + +@pytest.mark.parametrize(("hook_name", "kwargs", "poisoned"), HOOK_POISON, ids=[c[0] for c in HOOK_POISON]) +def test_a_raising_state_layer_never_reaches_the_hermes_loop( + hook_name: str, + kwargs: dict[str, Any], + poisoned: str, + patch_client: FakeClient, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + def explode(*_: Any, **__: Any) -> Any: + raise RuntimeError(f"{poisoned} exploded") + + monkeypatch.setattr(_state, poisoned, explode) + with caplog.at_level(logging.WARNING): + getattr(_hooks, hook_name)(**kwargs) + assert [r for r in caplog.records if " failed: " in r.getMessage()], "the failure has to be logged" + + +GARBAGE_PAYLOADS: tuple[tuple[str, dict[str, Any]], ...] = ( + ("api_call_count as text", {"hook": "on_post_api_request", "api_call_count": "seven"}), + ("api_call_count as text at open", {"hook": "on_pre_api_request", "api_call_count": "seven"}), + ("usage as a string", {"hook": "on_post_api_request", "api_request_id": "r1", "usage": "none"}), + ("assistant_message as a list", {"hook": "on_post_api_request", "api_request_id": "r1", "assistant_message": []}), + ("api_duration as text", {"hook": "on_post_api_request", "api_request_id": "r1", "api_duration": "slow"}), + ("conversation_history as an object", {"hook": "on_pre_api_request", "conversation_history": object()}), + ("request as a string", {"hook": "on_pre_api_request", "request": "clipped"}), + ("tool_count as text", {"hook": "on_pre_api_request", "tool_count": "many"}), + ("duration_ms as text", {"hook": "on_post_tool_call", "tool_name": "bash", "duration_ms": "fast"}), + ("args that will not serialize", {"hook": "on_post_tool_call", "tool_name": "bash", "args": {1, 2}}), + ("result that will not serialize", {"hook": "on_post_tool_call", "tool_name": "bash", "result": object()}), + ("status as a number", {"hook": "on_post_tool_call", "tool_name": "bash", "status": 500}), + ("error as an object", {"hook": "on_api_request_error", "api_request_id": "r1", "error": object()}), + ("status_code as text", {"hook": "on_api_request_error", "api_request_id": "r1", "status_code": "429"}), + ("session_id as None", {"hook": "on_session_end", "session_id": None}), + ("conversation_history as text", {"hook": "on_post_llm_call", "conversation_history": "nope"}), + ("max_tokens as text", {"hook": "on_pre_api_request", "max_tokens": "lots"}), + ("model as None", {"hook": "on_pre_api_request", "model": None, "provider": None}), + ("request_messages as a dict", {"hook": "on_pre_api_request", "request_messages": {"role": "user"}}), + ("system_prompt as a list", {"hook": "on_pre_api_request", "system_prompt": ["be nice"]}), + ("turn_id as an object", {"hook": "on_pre_api_request", "turn_id": object()}), + ( + "messages holding non-dicts", + {"hook": "on_pre_api_request", "conversation_history": ["raw", 7, None, {"role": "user"}]}, + ), + ( + "tool_calls of the wrong shape", + { + "hook": "on_post_api_request", + "api_request_id": "r1", + "assistant_message": {"role": "assistant", "tool_calls": ["not-a-call", 3]}, + }, + ), + ("usage counts as text", {"hook": "on_post_api_request", "api_request_id": "r1", "usage": {"input_tokens": "ten"}}), + ("finish_reason as a number", {"hook": "on_post_api_request", "api_request_id": "r1", "finish_reason": 5}), + ("tool_call_id as a number", {"hook": "on_post_tool_call", "tool_name": "bash", "tool_call_id": 7}), + ("tool_name as None", {"hook": "on_post_tool_call", "tool_name": None}), + ( + "error_message as a dict", + {"hook": "on_post_tool_call", "tool_name": "bash", "status": "error", "error_message": {"why": "boom"}}, + ), + ("negative duration_ms", {"hook": "on_post_tool_call", "tool_name": "bash", "duration_ms": -5}), + ("error as a bare exception", {"hook": "on_api_request_error", "api_request_id": "r1", "error": ValueError("x")}), + ( + "error dict with a text status", + {"hook": "on_api_request_error", "api_request_id": "r1", "error": {"status": "?"}}, + ), +) + + +@pytest.mark.parametrize(("label", "payload"), GARBAGE_PAYLOADS, ids=[c[0] for c in GARBAGE_PAYLOADS]) +def test_a_malformed_payload_is_coerced_rather_than_caught( + label: str, + payload: dict[str, Any], + patch_client: FakeClient, + caplog: pytest.LogCaptureFixture, +) -> None: + """A bad field should be absorbed by the read that touches it. + + Asserting on the absence of the backstop log is what makes this stronger + than "no exception escaped": ``_fail_open`` would hide an unguarded + conversion either way, and the handler that took the backstop abandoned + everything after the bad field, which usually means a leaked recorder. + """ + kwargs = dict(payload) + hook = getattr(_hooks, kwargs.pop("hook")) + kwargs.setdefault("session_id", "s1") + with caplog.at_level(logging.WARNING): + hook(**kwargs) + rescued = [r.getMessage() for r in caplog.records if " failed: " in r.getMessage()] + assert not rescued, f"{label} fell through to the fail-open backstop: {rescued}" + _hooks.on_session_end(session_id="s1") + + +def test_an_unknown_kwarg_is_ignored(patch_client: FakeClient) -> None: + """Hermes adds hook kwargs between releases; the handlers take ``**_``.""" + for name in ( + "on_pre_llm_call", + "on_post_llm_call", + "on_pre_api_request", + "on_post_api_request", + "on_api_request_error", + "on_post_tool_call", + "on_session_end", + "on_session_finalize", + ): + getattr(_hooks, name)(session_id="s1", api_request_id="r1", a_field_from_a_future_hermes=object()) + + +def test_a_client_that_cannot_be_built_leaves_every_hook_a_no_op( + monkeypatch: pytest.MonkeyPatch, + env_creds: None, +) -> None: + import agento11y + + from grafana_agento11y_hermes import _otel + + def explode(*_: Any, **__: Any) -> Any: + raise RuntimeError("no client for you") + + monkeypatch.setattr(agento11y, "Client", explode) + monkeypatch.setattr(_otel, "setup_if_needed", lambda cfg: True) + _drive_a_turn() diff --git a/plugins/hermes/tests/test_flush.py b/plugins/hermes/tests/test_flush.py new file mode 100644 index 000000000..2a1c30de8 --- /dev/null +++ b/plugins/hermes/tests/test_flush.py @@ -0,0 +1,146 @@ +"""A flush that raises or hangs in ``on_session_end`` can disrupt the hermes loop.""" + +from __future__ import annotations + +import threading +from typing import Any + +import pytest + +from grafana_agento11y_hermes import _client, _hooks, _otel +from tests.conftest import FakeClient + + +class RaisingProvider: + """A provider whose flush and shutdown both fail, as an unreachable one does.""" + + def __init__(self) -> None: + self.force_flush_calls: list[dict[str, Any]] = [] + self.shutdown_calls = 0 + + def force_flush(self, **kwargs: Any) -> None: + self.force_flush_calls.append(kwargs) + raise RuntimeError("exporter is unreachable") + + def shutdown(self) -> None: + self.shutdown_calls += 1 + raise RuntimeError("already shut down") + + +class RecordingProvider: + def __init__(self) -> None: + self.force_flush_calls: list[dict[str, Any]] = [] + + def force_flush(self, **kwargs: Any) -> None: + self.force_flush_calls.append(kwargs) + + +@pytest.mark.parametrize("failing", ("tracer", "meter", "both")) +def test_a_provider_that_cannot_flush_does_not_stop_the_other(monkeypatch: pytest.MonkeyPatch, failing: str) -> None: + tracer = RaisingProvider() if failing in ("tracer", "both") else RecordingProvider() + meter = RaisingProvider() if failing in ("meter", "both") else RecordingProvider() + monkeypatch.setattr(_otel, "_INSTALLED_TRACER_PROVIDER", tracer) + monkeypatch.setattr(_otel, "_INSTALLED_METER_PROVIDER", meter) + + _otel.force_flush() + + assert tracer.force_flush_calls, "the tracer provider was asked" + assert meter.force_flush_calls, "and so was the meter provider, whichever failed" + + +def test_a_flush_timeout_is_passed_to_both_providers(monkeypatch: pytest.MonkeyPatch) -> None: + tracer, meter = RecordingProvider(), RecordingProvider() + monkeypatch.setattr(_otel, "_INSTALLED_TRACER_PROVIDER", tracer) + monkeypatch.setattr(_otel, "_INSTALLED_METER_PROVIDER", meter) + + _otel.force_flush(1500) + + assert tracer.force_flush_calls == [{"timeout_millis": 1500}] + assert meter.force_flush_calls == [{"timeout_millis": 1500}] + + +def test_no_timeout_leaves_the_otel_default_in_place(monkeypatch: pytest.MonkeyPatch) -> None: + tracer = RecordingProvider() + monkeypatch.setattr(_otel, "_INSTALLED_TRACER_PROVIDER", tracer) + monkeypatch.setattr(_otel, "_INSTALLED_METER_PROVIDER", None) + + _otel.force_flush() + + assert tracer.force_flush_calls == [{}] + + +def test_host_owned_providers_are_never_flushed(monkeypatch: pytest.MonkeyPatch) -> None: + """Only providers this plugin installed are tracked, so this is a no-op.""" + monkeypatch.setattr(_otel, "_INSTALLED_TRACER_PROVIDER", None) + monkeypatch.setattr(_otel, "_INSTALLED_METER_PROVIDER", None) + _otel.force_flush(1000) + + +def test_a_provider_that_cannot_shut_down_does_not_break_the_reset(monkeypatch: pytest.MonkeyPatch) -> None: + tracer, meter = RaisingProvider(), RaisingProvider() + monkeypatch.setattr(_otel, "_INSTALLED_TRACER_PROVIDER", tracer) + monkeypatch.setattr(_otel, "_INSTALLED_METER_PROVIDER", meter) + + _otel._reset_for_tests() + + assert tracer.shutdown_calls == 1 + assert meter.shutdown_calls == 1 + assert _otel._INSTALLED_TRACER_PROVIDER is None + assert _otel._INSTALLED_METER_PROVIDER is None + + +def test_a_client_flush_that_raises_still_drains_otel(monkeypatch: pytest.MonkeyPatch, failing_client: Any) -> None: + client = failing_client("flush") + tracer = RecordingProvider() + monkeypatch.setattr(_otel, "_INSTALLED_TRACER_PROVIDER", tracer) + + _client._flush_channels() + + assert client.flush_calls == 1 + assert tracer.force_flush_calls, "the OTel pipeline is a separate channel and still needs draining" + + +def test_flushing_before_the_client_exists_does_not_build_one(monkeypatch: pytest.MonkeyPatch) -> None: + import agento11y + + def explode(*_: Any, **__: Any) -> Any: + raise AssertionError("Client must not be constructed by a flush") + + monkeypatch.setattr(agento11y, "Client", explode) + _client._flush_channels() + + +def test_a_zero_timeout_skips_the_bounded_flush(patch_client: FakeClient) -> None: + assert _client.flush_bounded(0) is False + assert patch_client.flush_calls == 0 + + +def test_a_bounded_flush_reports_success(patch_client: FakeClient) -> None: + assert _client.flush_bounded(5.0) is True + assert patch_client.flush_calls == 1 + + +def test_a_bounded_flush_gives_up_on_a_hanging_exporter( + monkeypatch: pytest.MonkeyPatch, patch_client: FakeClient +) -> None: + """A blocking flush must not stall the hermes loop past the timeout.""" + release = threading.Event() + + def hang(*_: Any, **__: Any) -> None: + release.wait(timeout=5) + + monkeypatch.setattr(_client, "_flush_channels", hang) + try: + assert _client.flush_bounded(0.05) is False + finally: + release.set() + + +def test_session_end_flushes_without_shutting_the_client_down(patch_client: FakeClient) -> None: + """The client is a process-wide singleton; the next session reuses it.""" + _hooks.on_session_end(session_id="s1") + _hooks.on_session_end(session_id="s2") + + assert patch_client.flush_calls == 2 + assert patch_client.shutdown_calls == 0 + assert _client._get_client() is patch_client diff --git a/plugins/hermes/tests/test_hook_edges.py b/plugins/hermes/tests/test_hook_edges.py new file mode 100644 index 000000000..33d689bf5 --- /dev/null +++ b/plugins/hermes/tests/test_hook_edges.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import logging +from typing import Any + +import pytest + +from grafana_agento11y_hermes import _client, _config, _hooks, _state +from tests.conftest import FakeClient, FakeRecorder + +# --- sampling --- + + +@pytest.mark.parametrize( + ("sample_rate", "roll", "expected"), + ( + # At or above 1.0 nothing is rolled at all. + (1.0, 0.99, True), + (2.0, 0.99, True), + # At or below zero everything is dropped, also without a roll. + (0.0, 0.0, False), + (-1.0, 0.0, False), + (0.5, 0.49, True), + (0.5, 0.5, False), + (0.5, 0.51, False), + ), +) +def test_the_sample_rate_decides_what_is_recorded( + monkeypatch: pytest.MonkeyPatch, sample_rate: float, roll: float, expected: bool +) -> None: + monkeypatch.setattr(_client, "_CONFIG", _config.PluginConfig(sample_rate=sample_rate)) + monkeypatch.setattr(_hooks.random, "random", lambda: roll) + assert _hooks._should_sample() is expected + + +def test_sampling_defaults_to_on_before_the_client_exists() -> None: + assert _hooks._should_sample() is True + + +def test_a_sampled_out_request_records_nothing_and_closes_cleanly( + monkeypatch: pytest.MonkeyPatch, patch_client: FakeClient +) -> None: + monkeypatch.setattr(_client, "_CONFIG", _config.PluginConfig(sample_rate=0.0)) + + _hooks.on_pre_api_request(session_id="s1", api_request_id="r1", model="m", provider="p") + _hooks.on_post_tool_call(session_id="s1", api_request_id="r1", tool_name="bash") + _hooks.on_post_api_request(session_id="s1", api_request_id="r1", assistant_message={"content": "hi"}) + _hooks.on_session_end(session_id="s1") + + assert patch_client.start_generation_calls == [] + assert patch_client.start_tool_execution_calls == [] + + +def test_the_request_capture_is_read_even_when_the_request_is_sampled_out( + monkeypatch: pytest.MonkeyPatch, patch_client: FakeClient +) -> None: + """The readable payloads are the earliest of a session, so the gate is after the read.""" + monkeypatch.setattr(_client, "_CONFIG", _config.PluginConfig(sample_rate=0.0)) + + _hooks.on_pre_api_request( + session_id="s1", + api_request_id="r1", + model="m", + provider="p", + request={"body": {"system": "be brief", "max_tokens": 900}}, + ) + + entry = _state.session_facts_get("s1") + assert entry is not None + assert entry[1].system_prompt == "be brief" + assert entry[1].max_tokens == 900 + + +# --- the error hook --- + + +def test_an_error_without_a_request_id_is_ignored(patch_client: FakeClient) -> None: + """There is nothing to close, and the legacy path has no id to match on.""" + _hooks.on_api_request_error(error={"type": "RateLimit"}, status_code=429) + assert patch_client.flush_calls == 0 + + +def test_an_error_for_an_unknown_request_closes_nothing(patch_client: FakeClient) -> None: + _hooks.on_api_request_error(api_request_id="never-opened", error="boom") + assert patch_client.start_generation_calls == [] + + +# --- linking a tool span to its generation --- + + +def test_a_span_that_refuses_the_attribute_still_leaves_a_tool_execution( + caplog: pytest.LogCaptureFixture, +) -> None: + class HostileSpan: + def set_attribute(self, key: str, value: Any) -> None: + raise RuntimeError("attribute rejected") + + recorder = FakeRecorder() + recorder.span = HostileSpan() # ty: ignore[invalid-assignment] + link = _state.GenLink(generation_id="gen_1", span_context=None, session_id="s1", turn_id="t1") + + with caplog.at_level(logging.DEBUG): + _hooks._stamp_parent_generation(recorder, link) + + assert [r for r in caplog.records if "parent generation attribute" in r.getMessage()] + + +def test_a_recorder_without_a_span_is_left_alone() -> None: + """``NoopToolExecutionRecorder``, which an empty tool name produces, has none.""" + link = _state.GenLink(generation_id="gen_1", span_context=None, session_id="s1", turn_id="t1") + _hooks._stamp_parent_generation(object(), link) + + +def test_no_link_means_no_attribute() -> None: + recorder = FakeRecorder() + _hooks._stamp_parent_generation(recorder, None) + assert recorder.span.attributes == {} + + +def test_a_tool_without_a_usable_parent_is_still_recorded(patch_client: FakeClient) -> None: + """No generation to hang it off, so it becomes its own root span.""" + _hooks.on_post_tool_call(session_id="s1", api_request_id="never-opened", tool_name="bash", tool_call_id="c1") + assert len(patch_client.start_tool_execution_calls) == 1 + + +# --- LEGACY: the running conversation, for hermes with no api_request_id --- + + +def test_arguments_that_will_not_serialize_are_recorded_as_empty(patch_client: FakeClient) -> None: + # convo_append only extends a bucket pre_llm_call opened, which is the hook + # order on the hermes releases that need this path. + _hooks.on_pre_llm_call(session_id="s1", conversation_history=[]) + + _hooks.on_post_tool_call(session_id="s1", tool_name="bash", tool_call_id="c1", args={1, 2}, result="ok") + + convo = _state.convo_get(("", "s1")) + assert convo[0]["tool_calls"][0]["function"]["arguments"] == "{}" + + +def test_a_result_that_will_not_serialize_falls_back_to_its_repr(patch_client: FakeClient) -> None: + circular: list[Any] = [] + circular.append(circular) + _hooks.on_pre_llm_call(session_id="s1", conversation_history=[]) + + _hooks.on_post_tool_call(session_id="s1", tool_name="bash", tool_call_id="c1", args={}, result=circular) + + convo = _state.convo_get(("", "s1")) + assert convo[1]["content"] == repr(circular) + + +def test_the_convo_bookkeeping_stops_once_a_request_id_has_been_seen(patch_client: FakeClient) -> None: + _hooks.on_pre_api_request(session_id="s1", api_request_id="r1", model="m", provider="p") + _hooks.on_post_tool_call(session_id="s1", api_request_id="r1", tool_name="bash", tool_call_id="c1") + + assert _state.convo_get(("", "s1")) == [], "current hermes carries its own messages" + + +# --- state keys the layer refuses --- + + +def test_an_empty_session_or_request_id_is_never_stored() -> None: + """Empty keys would collide across sessions, so they are dropped at the door.""" + _state.gen_link_put("", _state.GenLink(generation_id="g", span_context=None, session_id="s", turn_id="t")) + _state.session_model_put("", "model", "provider") + _state.session_facts_put("", "model", _hooks._request.RequestFacts()) + + assert _state.gen_link_get("") is None + assert _state.session_model_get("") == ("", "") + assert _state.session_facts_get("") is None diff --git a/plugins/hermes/tests/test_hooks.py b/plugins/hermes/tests/test_hooks.py new file mode 100644 index 000000000..fc85db86c --- /dev/null +++ b/plugins/hermes/tests/test_hooks.py @@ -0,0 +1,849 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from agento11y import GenerationStart, MessageRole, PartKind, ToolExecutionStart + +from grafana_agento11y_hermes import _hooks, _state + + +def _sample_messages() -> list[dict]: + return [ + {"role": "system", "content": "You are concise."}, + {"role": "user", "content": "What's 2+2?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "tc_1", "function": {"name": "calc", "arguments": '{"expr": "2+2"}'}}, + ], + }, + {"role": "tool", "tool_call_id": "tc_1", "content": "4"}, + ] + + +def test_pre_api_request_calls_start_generation_with_expected_fields(patch_client) -> None: + _hooks.on_pre_api_request( + task_id="t1", + session_id="s1", + model="claude-sonnet-4-6", + provider="anthropic", + messages=_sample_messages(), + api_call_count=1, + ) + + assert len(patch_client.start_generation_calls) == 1 + start: GenerationStart = patch_client.start_generation_calls[0] + assert isinstance(start, GenerationStart) + assert start.conversation_id == "s1" + assert start.agent_name == "hermes" + assert start.model.provider == "anthropic" + assert start.model.name == "claude-sonnet-4-6" + assert start.system_prompt == "You are concise." + assert start.metadata.get("hermes.api_call_count") == 1 + assert start.metadata.get("hermes.task_id") == "t1" + + rec = patch_client._next_gen_recorder + assert rec.entered + assert not rec.exited + # Input is stored on GenState; threaded into set_result at close-time. + state = _state.gen_get(("t1", "s1", 1)) + assert state is not None + assert any(m.role == MessageRole.USER for m in state.input_messages) + + +def test_the_legacy_path_reads_the_request_payload_too(patch_client) -> None: + """``on_pre_api_request`` is shared, so a pre-0.16.0 hermes captures both.""" + _hooks.on_pre_api_request( + task_id="t1", + session_id="s1", + model="claude-sonnet-4-6", + provider="anthropic", + messages=_sample_messages(), + api_call_count=1, + tool_count=1, + request={ + "method": "POST", + "body": {"system": "be helpful", "tools": [{"name": "calc", "input_schema": {"type": "object"}}]}, + }, + ) + + start: GenerationStart = patch_client.start_generation_calls[0] + assert [tool.name for tool in start.tools] == ["calc"] + assert start.system_prompt == "be helpful", "the request body beats a system message in the history" + assert start.metadata["hermes.tool_count"] == 1 + + +def test_pre_post_api_request_round_trip(patch_client) -> None: + _hooks.on_pre_api_request( + task_id="t1", + session_id="s1", + model="gpt-4.1", + provider="openai", + messages=_sample_messages(), + api_call_count=2, + ) + rec = patch_client._next_gen_recorder + assert rec.entered + + assistant_resp = {"role": "assistant", "content": "The answer is 4.", "tool_calls": []} + _hooks.on_post_api_request( + task_id="t1", + session_id="s1", + api_call_count=2, + model="gpt-4.1", + assistant_message=assistant_resp, + usage={ + "input_tokens": 100, + "output_tokens": 20, + "cache_read_input_tokens": 30, + "cache_creation_input_tokens": 5, + "reasoning_tokens": 7, + }, + finish_reason="stop", + messages=_sample_messages(), + ) + + # Recorder is NOT yet closed — close is deferred to post_llm_call so we + # can assign the assistant output from conversation_history. + assert not rec.exited + + _hooks.on_post_llm_call( + task_id="t1", + session_id="s1", + conversation_history=[ + {"role": "user", "content": "What's 2+2?"}, + assistant_resp, + ], + assistant_response="The answer is 4.", + ) + + assert rec.exited + assert _state.gen_pop(("t1", "s1", 2)) is None + + final = rec.set_result_calls[-1] + assert final["stop_reason"] == "stop" + assert final["response_model"] == "gpt-4.1" + usage = final["usage"] + assert usage.input_tokens == 100 + assert usage.output_tokens == 20 + assert usage.cache_read_input_tokens == 30 + assert usage.cache_write_input_tokens == 5 + assert usage.reasoning_tokens == 7 + output_messages = final["output"] + assert len(output_messages) == 1 + assert output_messages[0].role == MessageRole.ASSISTANT + assert any(p.kind == PartKind.TEXT and "4" in p.text for p in output_messages[0].parts) + + +def test_post_tool_call_records_full_round_trip(patch_client) -> None: + args = {"path": "/tmp/foo.txt", "limit": 100} + result = {"content": "hello world", "total_lines": 1} + + _hooks.on_post_tool_call( + tool_name="read_file", + args=args, + result=result, + task_id="t1", + session_id="s1", + tool_call_id="tc_42", + duration_ms=42, + ) + + assert len(patch_client.start_tool_execution_calls) == 1 + start: ToolExecutionStart = patch_client.start_tool_execution_calls[0] + assert isinstance(start, ToolExecutionStart) + assert start.tool_name == "read_file" + assert start.tool_call_id == "tc_42" + assert start.conversation_id == "s1" + assert start.agent_name == "hermes" + # Plugin must not pin include_content — the SDK derives it from the capture + # mode, so no_tool_content can still strip tool args/results from the span. + assert start.include_content is False + assert start.started_at is not None + + rec = patch_client._next_tool_recorder + assert rec.entered + assert rec.exited + final = rec.set_result_calls[-1] + # Args/result pass through redactor — values are echoed since they're tiny + assert final["arguments"] == {"path": "/tmp/foo.txt", "limit": 100} + assert final["result"] == {"content": "hello world", "total_lines": 1} + delta_ms = (final["completed_at"] - start.started_at).total_seconds() * 1000 + assert delta_ms == pytest.approx(42, abs=1) + + +def test_missing_credentials_makes_handlers_noop(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ( + "AGENTO11Y_ENDPOINT", + "AGENTO11Y_PROTOCOL", + "AGENTO11Y_AUTH_MODE", + "AGENTO11Y_AUTH_TENANT_ID", + "AGENTO11Y_AUTH_TOKEN", + "OTEL_EXPORTER_OTLP_ENDPOINT", + ): + monkeypatch.delenv(name, raising=False) + # Also delete any leftover legacy names from a host shell. + for name in ( + "HERMES_SIGIL_ENDPOINT", + "HERMES_SIGIL_INSTANCE_ID", + "HERMES_SIGIL_API_KEY", + "HERMES_SIGIL_OTLP_ENDPOINT", + "HERMES_SIGIL_OTLP_INSTANCE_ID", + "HERMES_SIGIL_OTLP_TOKEN", + ): + monkeypatch.delenv(name, raising=False) + + constructed: list[Any] = [] + + import agento11y + + def boom(*_: Any, **__: Any) -> Any: + constructed.append(True) + raise AssertionError("Client should not be constructed when creds missing") + + monkeypatch.setattr(agento11y, "Client", boom) + + _hooks.on_pre_api_request(task_id="t", session_id="s", model="m", provider="p", messages=[], api_call_count=1) + _hooks.on_post_api_request(task_id="t", session_id="s", api_call_count=1) + _hooks.on_post_tool_call(tool_name="x", task_id="t", session_id="s", tool_call_id="tc") + _hooks.on_session_end() + assert constructed == [] + + +def test_client_init_failure_is_cached(monkeypatch: pytest.MonkeyPatch, env_creds: None) -> None: + """Construction error → handlers swallow, subsequent calls don't retry.""" + import agento11y + + from grafana_agento11y_hermes import _otel + + monkeypatch.setattr(_otel, "setup_if_needed", lambda cfg: True) + + call_count = {"n": 0} + + def boom(*_: Any, **__: Any) -> Any: + call_count["n"] += 1 + raise RuntimeError("unreachable endpoint") + + monkeypatch.setattr(agento11y, "Client", boom) + + _hooks.on_pre_api_request(task_id="t", session_id="s", model="m", provider="p", messages=[], api_call_count=1) + _hooks.on_post_tool_call(tool_name="x", task_id="t", session_id="s", tool_call_id="tc") + _hooks.on_session_end() + + assert call_count["n"] == 1, "client construction must only be retried once after failure" + + +def test_on_session_end_flushes_without_closing_client(patch_client) -> None: + """on_session_end must flush, not shutdown — the client is a process-wide singleton.""" + _hooks.on_session_end() + assert patch_client.flush_calls == 1 + assert patch_client.shutdown_calls == 0 + + +def test_session_end_lets_subsequent_session_record(patch_client) -> None: + _hooks.on_pre_api_request( + task_id="t1", + session_id="s1", + model="m", + provider="p", + messages=[{"role": "user", "content": "hi"}], + api_call_count=1, + ) + _hooks.on_session_end() + _hooks.on_pre_api_request( + task_id="t2", + session_id="s2", + model="m", + provider="p", + messages=[{"role": "user", "content": "again"}], + api_call_count=1, + ) + assert len(patch_client.start_generation_calls) == 2 + + +def test_session_end_force_flushes_installed_providers( + monkeypatch: pytest.MonkeyPatch, + env_creds: None, +) -> None: + import agento11y + + from grafana_agento11y_hermes import _client, _otel + from tests.conftest import FakeClient + + class FakeProvider: + def __init__(self) -> None: + self.flush_calls = 0 + + def force_flush(self, *_: Any, **__: Any) -> None: + self.flush_calls += 1 + + fake_tracer = FakeProvider() + fake_meter = FakeProvider() + monkeypatch.setattr(_otel, "_INSTALLED_TRACER_PROVIDER", fake_tracer, raising=False) + monkeypatch.setattr(_otel, "_INSTALLED_METER_PROVIDER", fake_meter, raising=False) + monkeypatch.setattr(_otel, "setup_if_needed", lambda cfg: True) + monkeypatch.setattr(agento11y, "Client", lambda *a, **k: FakeClient()) + + assert _client._get_client() is not None + _hooks.on_session_end() + assert fake_tracer.flush_calls == 1 + assert fake_meter.flush_calls == 1 + + +def test_session_end_does_not_flush_user_owned_providers( + monkeypatch: pytest.MonkeyPatch, + env_creds: None, +) -> None: + import agento11y + from opentelemetry import trace + + from grafana_agento11y_hermes import _client, _otel + from tests.conftest import FakeClient + + class FakeProvider: + def __init__(self) -> None: + self.flush_calls = 0 + + def force_flush(self, *_: Any, **__: Any) -> None: + self.flush_calls += 1 + + # Globally installed, but by the host: _INSTALLED_*_PROVIDER stay None, so + # the plugin has no claim on it. Reaching for the module globals rather + # than set_tracer_provider, which is once-per-process. + fake_provider = FakeProvider() + monkeypatch.setattr(trace, "_TRACER_PROVIDER", fake_provider, raising=False) + monkeypatch.setattr(_otel, "setup_if_needed", lambda cfg: True) + monkeypatch.setattr(agento11y, "Client", lambda *a, **k: FakeClient()) + assert _client._get_client() is not None + assert trace.get_tracer_provider() is fake_provider + + _hooks.on_session_end() + assert fake_provider.flush_calls == 0 + + +def test_on_session_end_does_not_initialize_client(monkeypatch: pytest.MonkeyPatch, env_creds: None) -> None: + """on_session_end must use create_if_missing=False and not trigger init.""" + import agento11y + + from grafana_agento11y_hermes import _otel + + monkeypatch.setattr(_otel, "setup_if_needed", lambda cfg: True) + + constructed = {"n": 0} + + def factory(*_: Any, **__: Any) -> Any: + constructed["n"] += 1 + return object() + + monkeypatch.setattr(agento11y, "Client", factory) + + _hooks.on_session_end() + assert constructed["n"] == 0 + + +def test_post_api_request_without_pre_is_safe(patch_client) -> None: + _hooks.on_post_api_request(task_id="t", session_id="s", api_call_count=999, assistant_message={"content": "x"}) + + +def test_post_tool_call_with_unknown_id_is_safe(patch_client) -> None: + _hooks.on_post_tool_call(tool_name="x", task_id="t", session_id="s", tool_call_id="ghost") + + +def test_sample_rate_zero_skips_recording(monkeypatch: pytest.MonkeyPatch, patch_client) -> None: + """AGENTO11Y_HERMES_SAMPLE_RATE=0 → pre-hooks short-circuit, no recorder created.""" + from grafana_agento11y_hermes import _client, _config + + monkeypatch.setattr( + _client, + "_CONFIG", + _config.PluginConfig(sample_rate=0.0), + raising=False, + ) + + _hooks.on_pre_api_request( + task_id="t", + session_id="s", + model="m", + provider="p", + messages=[{"role": "user", "content": "hi"}], + api_call_count=1, + ) + _hooks.on_post_tool_call(tool_name="x", args={}, result="ok", task_id="t", session_id="s", tool_call_id="tc1") + + assert patch_client.start_generation_calls == [] + assert patch_client.start_tool_execution_calls == [] + + +def test_pre_llm_call_seeds_input_for_pre_api_request(patch_client) -> None: + """Hermes does not pass messages to pre_api_request — input must come from pre_llm_call.""" + _hooks.on_pre_llm_call( + task_id="t1", + session_id="s1", + conversation_history=[ + {"role": "user", "content": "hey"}, + ], + ) + _hooks.on_pre_api_request( + task_id="t1", + session_id="s1", + model="m", + provider="p", + api_call_count=1, + # messages NOT passed — matches real hermes + ) + + rec = patch_client._next_gen_recorder + assert rec is not None + state = _state.gen_get(("t1", "s1", 1)) + assert state is not None + assert len(state.input_messages) == 1 + assert state.input_messages[0].role == MessageRole.USER + + +def test_post_llm_call_assigns_outputs_to_pending_recorders(patch_client) -> None: + """Tool loop: 2 LLM calls, post_llm_call assigns each call's assistant output.""" + _hooks.on_pre_llm_call( + task_id="t1", + session_id="s1", + conversation_history=[{"role": "user", "content": "search for X"}], + ) + # LLM call 1 — emits a tool call + _hooks.on_pre_api_request(task_id="t1", session_id="s1", model="m", provider="p", api_call_count=1) + rec1 = patch_client._next_gen_recorder + _hooks.on_post_api_request( + task_id="t1", + session_id="s1", + api_call_count=1, + model="m", + usage={"input_tokens": 10, "output_tokens": 5}, + finish_reason="tool_calls", + ) + assert not rec1.exited # deferred close + # LLM call 2 — final answer + _hooks.on_pre_api_request(task_id="t1", session_id="s1", model="m", provider="p", api_call_count=2) + rec2 = patch_client._next_gen_recorder + _hooks.on_post_api_request( + task_id="t1", + session_id="s1", + api_call_count=2, + model="m", + usage={"input_tokens": 20, "output_tokens": 8}, + finish_reason="stop", + ) + assert not rec2.exited + + asst1 = { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "tc_1", "function": {"name": "search", "arguments": '{"q":"X"}'}}], + } + asst2 = {"role": "assistant", "content": "found 3 results"} + _hooks.on_post_llm_call( + task_id="t1", + session_id="s1", + conversation_history=[ + {"role": "user", "content": "search for X"}, + asst1, + {"role": "tool", "tool_call_id": "tc_1", "content": "results"}, + asst2, + ], + assistant_response="found 3 results", + ) + + assert rec1.exited and rec2.exited + final1 = rec1.set_result_calls[-1] + final2 = rec2.set_result_calls[-1] + assert final1["stop_reason"] == "tool_calls" + assert any(p.kind == PartKind.TOOL_CALL for p in final1["output"][0].parts) + assert final2["stop_reason"] == "stop" + assert any(p.kind == PartKind.TEXT and "found 3 results" in p.text for p in final2["output"][0].parts) + + +def test_completed_at_uses_api_duration_not_recorder_close_time(patch_client) -> None: + """Span end + duration metric must reflect the LLM call, not the close time. + + If we used wallclock at close, the first call in a tool loop would report + a span/histogram covering the full turn (LLM + tool + later calls). + """ + _hooks.on_pre_api_request( + task_id="t1", + session_id="s1", + model="m", + provider="p", + messages=[{"role": "user", "content": "hi"}], + api_call_count=1, + ) + rec = patch_client._next_gen_recorder + state = _state.gen_get(("t1", "s1", 1)) + assert state is not None and state.started_at is not None + started_at = state.started_at + + _hooks.on_post_api_request( + task_id="t1", + session_id="s1", + api_call_count=1, + model="m", + usage={}, + finish_reason="stop", + api_duration=2.5, + ) + _hooks.on_post_llm_call(task_id="t1", session_id="s1", conversation_history=[]) + + final = rec.set_result_calls[-1] + assert final["started_at"] == started_at + completed_at = final["completed_at"] + assert completed_at is not None + delta = (completed_at - started_at).total_seconds() + assert delta == pytest.approx(2.5) + + +def test_completed_at_is_none_when_api_duration_missing(patch_client) -> None: + """No api_duration → leave completed_at unset; SDK falls back to its clock.""" + _hooks.on_pre_api_request( + task_id="t1", + session_id="s1", + model="m", + provider="p", + messages=[{"role": "user", "content": "hi"}], + api_call_count=1, + ) + rec = patch_client._next_gen_recorder + _hooks.on_post_api_request( + task_id="t1", + session_id="s1", + api_call_count=1, + model="m", + usage={}, + finish_reason="stop", + ) + _hooks.on_post_llm_call(task_id="t1", session_id="s1", conversation_history=[]) + + final = rec.set_result_calls[-1] + assert final["completed_at"] is None + + +def test_close_pending_handles_discarded_retry(patch_client) -> None: + """Discarded retry iterations must not steal a prior turn's assistant. + + Hermes increments ``api_call_count`` on every iteration, including ones + whose response is discarded (incomplete , invalid- + response retries). The discarded + iteration's recorder is still in ``_GEN_STATE`` when ``post_llm_call`` + fires, but no assistant message was appended for it. End-anchored + pairing closes the discarded recorder with empty output rather than + pulling an assistant from a successful call or an earlier turn. + """ + prior_history = [ + {"role": "user", "content": "first turn"}, + {"role": "assistant", "content": "first turn answer"}, + {"role": "user", "content": "second turn"}, + ] + _hooks.on_pre_llm_call( + task_id="t1", + session_id="s1", + conversation_history=prior_history, + ) + # Iteration 1 — response discarded by hermes (`continue` without append). + _hooks.on_pre_api_request(task_id="t1", session_id="s1", model="m", provider="p", api_call_count=1) + rec1 = patch_client._next_gen_recorder + _hooks.on_post_api_request( + task_id="t1", + session_id="s1", + api_call_count=1, + model="m", + usage={"input_tokens": 10, "output_tokens": 5}, + finish_reason="stop", + ) + # Iteration 2 — kept; produces final_response. + _hooks.on_pre_api_request(task_id="t1", session_id="s1", model="m", provider="p", api_call_count=2) + rec2 = patch_client._next_gen_recorder + _hooks.on_post_api_request( + task_id="t1", + session_id="s1", + api_call_count=2, + model="m", + usage={"input_tokens": 12, "output_tokens": 6}, + finish_reason="stop", + ) + + asst_for_iter_2 = {"role": "assistant", "content": "real answer"} + _hooks.on_post_llm_call( + task_id="t1", + session_id="s1", + conversation_history=[*prior_history, asst_for_iter_2], + assistant_response="real answer", + ) + + # Discarded iter 1 closes empty — must NOT have stolen "first turn answer". + final1 = rec1.set_result_calls[-1] + assert final1["output"] == [], f"discarded iteration must close with empty output, got {final1['output']}" + final2 = rec2.set_result_calls[-1] + assert any(p.kind == PartKind.TEXT and "real answer" in p.text for p in final2["output"][0].parts) + + +def test_session_end_closes_pending_recorders_on_interrupt(patch_client) -> None: + """If post_llm_call never fires (interrupt), on_session_end must still close recorders.""" + _hooks.on_pre_api_request( + task_id="t1", + session_id="s1", + model="m", + provider="p", + messages=[{"role": "user", "content": "hi"}], + api_call_count=1, + ) + rec = patch_client._next_gen_recorder + _hooks.on_post_api_request( + task_id="t1", + session_id="s1", + api_call_count=1, + model="m", + usage={}, + finish_reason="length", + ) + assert not rec.exited + # session_end fires before post_llm_call (interrupt path) + _hooks.on_session_end(session_id="s1") + assert rec.exited + # Output is empty (no conversation_history to derive it from), but the + # recorder was closed and partial state (input, usage) was set. + final = rec.set_result_calls[-1] + assert final["output"] == [] + + +def test_running_convo_includes_assistant_and_tool_results(patch_client) -> None: + """Tool loop: conversation grows across calls so api_call_count=2 has full input.""" + _hooks.on_pre_llm_call( + task_id="t1", + session_id="s1", + conversation_history=[{"role": "user", "content": "search for X"}], + ) + # Call #1 — model decides to call a tool + _hooks.on_pre_api_request( + task_id="t1", + session_id="s1", + model="m", + provider="p", + api_call_count=1, + ) + _hooks.on_post_api_request( + task_id="t1", + session_id="s1", + api_call_count=1, + model="m", + usage={}, + finish_reason="tool_calls", + ) + # Tool runs — post_tool_call synthesizes the assistant tool_call message + # and appends the tool result, both into the running convo. + _hooks.on_post_tool_call( + tool_name="search", + args={"q": "X"}, + task_id="t1", + session_id="s1", + tool_call_id="tc_1", + result="found 3 results", + ) + # Call #2 — model gets to see user msg + asst tool call + tool result + _hooks.on_pre_api_request( + task_id="t1", + session_id="s1", + model="m", + provider="p", + api_call_count=2, + ) + rec2 = patch_client._next_gen_recorder + assert rec2 is not None + state2 = _state.gen_get(("t1", "s1", 2)) + assert state2 is not None + roles = [m.role for m in state2.input_messages] + assert MessageRole.USER in roles + assert MessageRole.ASSISTANT in roles + assert MessageRole.TOOL in roles + + +def test_post_llm_call_clears_running_convo(patch_client) -> None: + _hooks.on_pre_llm_call( + task_id="t1", + session_id="s1", + conversation_history=[{"role": "user", "content": "hi"}], + ) + from grafana_agento11y_hermes import _state + + # Convo is keyed by session_id only — task_id is not passed to pre_llm_call. + assert _state.convo_get(("", "s1")) != [] + _hooks.on_post_llm_call(task_id="t1", session_id="s1") + assert _state.convo_get(("", "s1")) == [] + + +def test_sample_rate_one_records_everything(patch_client) -> None: + """AGENTO11Y_HERMES_SAMPLE_RATE=1.0 (default) → every call recorded.""" + _hooks.on_pre_api_request( + task_id="t", + session_id="s", + model="m", + provider="p", + messages=[{"role": "user", "content": "hi"}], + api_call_count=1, + ) + assert len(patch_client.start_generation_calls) == 1 + + +def test_client_called_with_content_capture_override_when_generations_configured( + monkeypatch: pytest.MonkeyPatch, + env_creds: None, +) -> None: + """Capture defaults to metadata-only without overriding SDK transport resolution.""" + import agento11y + from agento11y import ContentCaptureMode + + from grafana_agento11y_hermes import _client, _otel + + monkeypatch.delenv("AGENTO11Y_CONTENT_CAPTURE_MODE", raising=False) + captured: list[Any] = [] + + def factory(*args: Any, **kwargs: Any) -> Any: + captured.append(args[0] if args else None) + from tests.conftest import FakeClient + + return FakeClient() + + monkeypatch.setattr(agento11y, "Client", factory) + monkeypatch.setattr(_otel, "setup_if_needed", lambda cfg: True) + + assert _client._get_client() is not None + assert len(captured) == 1 + cfg = captured[0] + # The override is content_capture only; transport is left to env resolution. + assert cfg.content_capture == ContentCaptureMode.METADATA_ONLY + # The plugin must not pin protocol="none" when generations are configured — + # that switch is reserved for OTel-only mode. + assert cfg.generation_export.protocol != "none" + # Generations get a plugin User-Agent so the backend can attribute the traffic. + ua = cfg.generation_export.headers["User-Agent"] + assert ua.startswith("agento11y-plugin-hermes/") + assert "agento11y-sdk-python/" in ua + + +def test_client_sends_plugin_user_agent_when_content_capture_mode_set( + monkeypatch: pytest.MonkeyPatch, + env_creds: None, +) -> None: + """Transport and auth stay env-resolved with an explicit capture mode.""" + import agento11y + + from grafana_agento11y_hermes import _client, _otel + + monkeypatch.setenv("AGENTO11Y_CONTENT_CAPTURE_MODE", "no_tool_content") + captured: list[Any] = [] + + def factory(*args: Any, **kwargs: Any) -> Any: + captured.append(args[0] if args else kwargs.get("config")) + from tests.conftest import FakeClient + + return FakeClient() + + monkeypatch.setattr(agento11y, "Client", factory) + monkeypatch.setattr(_otel, "setup_if_needed", lambda cfg: True) + + assert _client._get_client() is not None + assert len(captured) == 1 + cfg = captured[0] + assert cfg.content_capture.value == "no_tool_content" + assert cfg.generation_export.protocol != "none" + assert cfg.generation_export.headers["User-Agent"].startswith("agento11y-plugin-hermes/") + + +def test_export_headers_preserved_and_user_agent_override_wins( + monkeypatch: pytest.MonkeyPatch, + env_creds: None, +) -> None: + import agento11y + + from grafana_agento11y_hermes import _client, _otel + + monkeypatch.setenv("AGENTO11Y_HEADERS", "X-Custom=1,User-Agent=my-agent/9") + captured: list[Any] = [] + + def factory(*args: Any, **kwargs: Any) -> Any: + captured.append(args[0] if args else kwargs.get("config")) + from tests.conftest import FakeClient + + return FakeClient() + + monkeypatch.setattr(agento11y, "Client", factory) + monkeypatch.setattr(_otel, "setup_if_needed", lambda cfg: True) + + assert _client._get_client() is not None + headers = captured[0].generation_export.headers + assert headers["X-Custom"] == "1" + assert headers["User-Agent"] == "my-agent/9" + + +def test_legacy_hermes_sigil_names_are_ignored(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ( + "AGENTO11Y_ENDPOINT", + "AGENTO11Y_PROTOCOL", + "AGENTO11Y_AUTH_MODE", + "AGENTO11Y_AUTH_TENANT_ID", + "AGENTO11Y_AUTH_TOKEN", + "OTEL_EXPORTER_OTLP_ENDPOINT", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("HERMES_SIGIL_ENDPOINT", "http://legacy/api") + monkeypatch.setenv("HERMES_SIGIL_INSTANCE_ID", "stack-1") + monkeypatch.setenv("HERMES_SIGIL_API_KEY", "glc_secret") + monkeypatch.setenv("HERMES_SIGIL_OTLP_ENDPOINT", "http://legacy/otlp") + + import agento11y + + # Counted out here, not asserted inside the factory: _client swallows every + # exception the constructor raises, an AssertionError included. + constructed: list[Any] = [] + + def factory(*args: Any, **kwargs: Any) -> Any: + constructed.append(args or kwargs) + from tests.conftest import FakeClient + + return FakeClient() + + monkeypatch.setattr(agento11y, "Client", factory) + + _hooks.on_pre_api_request(task_id="t", session_id="s", model="m", provider="p", messages=[], api_call_count=1) + + assert constructed == [] + + +def test_client_config_uses_protocol_none_when_only_otel_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """OTel-only mode: no AGENTO11Y_AUTH_TOKEN/MODE → SDK's HTTP exporter is disabled.""" + import agento11y + + from grafana_agento11y_hermes import _client, _otel + + for name in ( + "AGENTO11Y_AUTH_TOKEN", + "AGENTO11Y_AUTH_MODE", + "AGENTO11Y_AUTH_TENANT_ID", + "AGENTO11Y_ENDPOINT", + "AGENTO11Y_PROTOCOL", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://otlp") + + captured: list[Any] = [] + + def factory(*args: Any, **kwargs: Any) -> Any: + captured.append(args[0] if args else kwargs.get("config")) + from tests.conftest import FakeClient + + return FakeClient() + + monkeypatch.setattr(agento11y, "Client", factory) + monkeypatch.setattr(_otel, "setup_if_needed", lambda cfg: True) + + assert _client._get_client() is not None + assert len(captured) == 1 + assert captured[0].generation_export.protocol == "none" diff --git a/plugins/hermes/tests/test_hooks_request_scoped.py b/plugins/hermes/tests/test_hooks_request_scoped.py new file mode 100644 index 000000000..764f18418 --- /dev/null +++ b/plugins/hermes/tests/test_hooks_request_scoped.py @@ -0,0 +1,1166 @@ +"""Request-scoped generation pairing, the path hermes v2026.6.5+ (PyPI 0.16.0) takes. + +These tests use the kwarg names hermes actually sends, captured from the +``pre_api_request`` and ``post_api_request`` call sites in +``agent/conversation_loop.py`` (``:1357`` and ``:4486`` in hermes 0.19.0). The +older tests in ``test_hooks.py`` omit ``api_request_id`` and so exercise the +legacy fallback instead. +""" + +from __future__ import annotations + +import logging +import threading +import time +from typing import Any + +import pytest +from opentelemetry import trace as otel_trace + +from grafana_agento11y_hermes import _client, _errors, _hooks, _state + +# No system message: hermes prepends the system prompt to the list that goes on +# the wire, not to the running conversation it hands the hooks. +CONVO = [ + {"role": "user", "content": "what is 2+2?"}, +] + +# The anthropic_messages body, which is where the system prompt and the tool +# schemas actually reach the plugin. +REQUEST = { + "method": "POST", + "body": { + "model": "claude-sonnet-4-6", + "max_tokens": 8192, + "temperature": 0.7, + "system": [{"type": "text", "text": "be helpful"}], + "messages": [{"role": "user", "content": "what is 2+2?"}], + "tools": [ + {"name": "read_file", "description": "read a file", "input_schema": {"type": "object"}}, + {"name": "shell", "description": "run a command", "input_schema": {"type": "object"}}, + ], + "tool_choice": {"type": "auto"}, + }, +} + +# What the host's payload sanitizer leaves once the envelope is over its cap. +CLIPPED_REQUEST = {"_truncated": True, "original_type": "dict", "preview": "{'model': 'claude"} + +# The pass before that one: the body still reads, but the prompt is cut short +# and the tool list is one entry plus the count of what it dropped. +CLIPPED_FIELDS_REQUEST = { + "method": "POST", + "body": { + "model": "claude-sonnet-4-6", + "max_tokens": 8192, + "system": [{"type": "text", "text": "be help...[truncated 900 chars]"}], + "tools": [ + {"name": "read_file", "description": "read a file", "input_schema": {"type": "object"}}, + {"_truncated_items": 1}, + ], + }, +} + + +def texts(messages: Any) -> list[str]: + """Text of each SDK ``Message``, which stores content as typed parts.""" + return ["".join(p.text for p in m.parts if p.text) for m in messages] + + +def attributes(span: Any) -> dict[str, Any]: + """Span attributes as a plain dict. The OTel SDK types them as optional.""" + return dict(span.attributes or {}) + + +def _pre(client_unused: Any = None, **over: Any) -> None: + kwargs: dict[str, Any] = { + "task_id": "task-1", + "turn_id": "turn-1", + "api_request_id": "req-1", + "session_id": "sess-1", + "user_message": "what is 2+2?", + "conversation_history": list(CONVO), + "platform": "cli", + "model": "claude-sonnet-4-6", + "provider": "anthropic", + "api_call_count": 1, + "message_count": 1, + "tool_count": 2, + "request": REQUEST, + } + kwargs.update(over) + _hooks.on_pre_api_request(**kwargs) + + +def _post(**over: Any) -> None: + kwargs: dict[str, Any] = { + "task_id": "task-1", + "turn_id": "turn-1", + "api_request_id": "req-1", + "session_id": "sess-1", + "model": "claude-sonnet-4-6", + "provider": "anthropic", + "api_call_count": 1, + "api_duration": 1.5, + "finish_reason": "stop", + "response_model": "claude-sonnet-4-6", + "usage": {"input_tokens": 10, "output_tokens": 4}, + "assistant_message": {"role": "assistant", "content": "4"}, + "assistant_content_chars": 1, + "assistant_tool_call_count": 0, + } + kwargs.update(over) + _hooks.on_post_api_request(**kwargs) + + +def test_generation_closes_in_post_api_request(patch_client: Any, env_creds: None) -> None: + """No post_llm_call needed: the pair is exact and the output is in hand.""" + _pre() + rec = patch_client._next_gen_recorder + assert rec is not None and rec.entered + assert not rec.exited + + _post() + + assert rec.exited + assert len(rec.set_result_calls) == 1 + call = rec.set_result_calls[0] + assert call["stop_reason"] == "stop" + assert call["response_model"] == "claude-sonnet-4-6" + + +def test_output_comes_from_assistant_message(patch_client: Any, env_creds: None) -> None: + _pre() + _post(assistant_message={"role": "assistant", "content": "the answer is 4"}) + + call = patch_client._next_gen_recorder.set_result_calls[0] + assert texts(call["output"]) == ["the answer is 4"] + assert call["input"], "input seeded at pre-time must survive the close" + + +def test_completed_at_reflects_api_duration(patch_client: Any, env_creds: None) -> None: + """Span covers the LLM call, not the recorder lifetime.""" + _pre() + _post(api_duration=2.0) + + call = patch_client._next_gen_recorder.set_result_calls[0] + delta = call["completed_at"] - call["started_at"] + assert delta.total_seconds() == pytest.approx(2.0) + + +def test_concurrent_requests_in_one_session_do_not_collide(patch_client: Any, env_creds: None) -> None: + """MoA fan-out and subagents interleave requests with the same task/session and api_call_count.""" + _pre(api_request_id="req-a", conversation_history=[{"role": "user", "content": "A"}]) + rec_a = patch_client._next_gen_recorder + _pre(api_request_id="req-b", conversation_history=[{"role": "user", "content": "B"}]) + rec_b = patch_client._next_gen_recorder + + assert rec_a is not rec_b + + _post(api_request_id="req-b", assistant_message={"role": "assistant", "content": "B-out"}) + _post(api_request_id="req-a", assistant_message={"role": "assistant", "content": "A-out"}) + + assert texts(rec_a.set_result_calls[0]["output"]) == ["A-out"] + assert texts(rec_b.set_result_calls[0]["output"]) == ["B-out"] + + +def test_unmatched_post_is_ignored(patch_client: Any, env_creds: None) -> None: + _pre(api_request_id="req-1") + _post(api_request_id="req-does-not-exist") + + assert not patch_client._next_gen_recorder.exited + + +def test_session_end_closes_a_request_that_never_completed(patch_client: Any, env_creds: None) -> None: + """Interrupt safety: input and timing still export, output is empty.""" + _pre() + rec = patch_client._next_gen_recorder + + _hooks.on_session_end(session_id="sess-1") + + assert rec.exited + assert rec.set_result_calls[0]["output"] == [] + assert rec.set_result_calls[0]["input"] + assert _state.req_pop("req-1") is None + + +def test_session_end_drops_the_link_state_it_owns(patch_client: Any, env_creds: None) -> None: + """Nothing of this session can fire after it ends, so nothing should be kept.""" + _pre() + _post() + assert _state.gen_link_get("req-1") is not None + assert _state.turn_last_gen_get("turn-1") + + _hooks.on_session_end(session_id="sess-1") + + assert _state.gen_link_get("req-1") is None + assert _state.turn_last_gen_get("turn-1") == "" + + +def test_session_end_leaves_another_sessions_links_alone(patch_client: Any, env_creds: None) -> None: + _pre(api_request_id="req-mine", session_id="sess-1", turn_id="turn-mine") + _post(api_request_id="req-mine", session_id="sess-1", turn_id="turn-mine") + _pre(api_request_id="req-other", session_id="sess-2", turn_id="turn-other") + _post(api_request_id="req-other", session_id="sess-2", turn_id="turn-other") + + _hooks.on_session_end(session_id="sess-1") + + assert _state.gen_link_get("req-other") is not None + assert _state.turn_last_gen_get("turn-other") + + +def test_session_end_only_drains_its_own_session(patch_client: Any, env_creds: None) -> None: + _pre(api_request_id="req-mine", session_id="sess-1") + mine = patch_client._next_gen_recorder + _pre(api_request_id="req-other", session_id="sess-2") + other = patch_client._next_gen_recorder + + _hooks.on_session_end(session_id="sess-1") + + assert mine.exited + assert not other.exited + + +def test_legacy_hermes_warns_once(patch_client: Any, env_creds: None, caplog: pytest.LogCaptureFixture) -> None: + """No api_request_id means an old hermes, and the user should hear so.""" + with caplog.at_level(logging.WARNING): + _pre(api_request_id="", conversation_history=None, api_call_count=1) + _pre(api_request_id="", conversation_history=None, api_call_count=2) + + warnings = [r for r in caplog.records if "api_request_id" in r.getMessage()] + assert len(warnings) == 1 + assert "v2026.6.5" in warnings[0].getMessage() + + +def test_a_retry_reusing_the_request_id_closes_the_displaced_recorder( + patch_client: Any, + env_creds: None, +) -> None: + """Hermes assigns api_request_id above its retry loop, so ids repeat.""" + _pre() + first = patch_client._next_gen_recorder + _pre() + second = patch_client._next_gen_recorder + + assert first is not second + assert first.exited, "the abandoned attempt must not outlive the request" + assert not second.exited + + _post(assistant_message={"role": "assistant", "content": "kept"}) + + assert texts(second.set_result_calls[0]["output"]) == ["kept"] + + +def test_a_displaced_attempt_is_marked_rather_than_exported_as_a_success( + patch_client: Any, + env_creds: None, +) -> None: + _pre() + first = patch_client._next_gen_recorder + _pre() + + assert isinstance(first.set_call_error_calls[0], _errors.SupersededAttempt) + assert first.set_result_calls[0]["output"] == [] + assert first.set_result_calls[0]["input"], "the attempt's input still exports" + + +def test_api_request_error_closes_the_attempt_once(patch_client: Any, env_creds: None) -> None: + _pre() + rec = patch_client._next_gen_recorder + + _hooks.on_api_request_error( + api_request_id="req-1", + error={"type": "RateLimitError", "message": "slow down"}, + status_code=429, + ) + + assert rec.exited + assert len(rec.set_result_calls) == 1 + assert rec.set_result_calls[0]["call_error"] == "slow down" + error = rec.set_call_error_calls[0] + assert isinstance(error, _errors.ProviderCallError) + assert error.status_code == 429 + + +def test_a_retry_after_the_error_hook_displaces_nothing(patch_client: Any, env_creds: None) -> None: + _pre() + failed = patch_client._next_gen_recorder + _hooks.on_api_request_error(api_request_id="req-1", error="boom", status_code=500) + + _pre() + retry = patch_client._next_gen_recorder + + assert len(failed.set_call_error_calls) == 1, "closed once, not once per mechanism" + assert not retry.exited + assert retry.set_call_error_calls == [] + + +def test_the_status_code_is_read_off_the_error_payload_too(patch_client: Any, env_creds: None) -> None: + _pre() + + _hooks.on_api_request_error(api_request_id="req-1", error={"message": "slow down", "status_code": 429}) + + assert patch_client._next_gen_recorder.set_call_error_calls[0].status_code == 429 + + +def test_api_request_error_for_an_unknown_id_is_a_no_op(patch_client: Any, env_creds: None) -> None: + _pre() + + _hooks.on_api_request_error(api_request_id="req-other", error="boom") + + assert not patch_client._next_gen_recorder.exited + + +def test_an_unreadable_error_payload_neither_raises_nor_orphans_the_recorder( + patch_client: Any, + env_creds: None, +) -> None: + """``error`` is whatever hermes built, and reading it must not escape.""" + + class Unreadable: + def __str__(self) -> str: + raise RuntimeError("unreadable") + + _pre() + rec = patch_client._next_gen_recorder + + _hooks.on_api_request_error(api_request_id="req-1", error=Unreadable()) + + assert not rec.exited, "the state stays in the map for a later sweep" + _hooks.on_session_end(session_id="sess-1") + assert rec.exited + + +def test_an_oversized_error_message_is_truncated(patch_client: Any, env_creds: None) -> None: + _pre() + + _hooks.on_api_request_error(api_request_id="req-1", error={"message": "x" * 5000}) + + message = patch_client._next_gen_recorder.set_result_calls[0]["call_error"] + assert message.startswith("x" * 2000) + assert "truncated 3000 chars" in message + + +def test_the_sdk_derives_the_category_from_our_sentinel() -> None: + """Dashboards group failures by ``error.category``, which the SDK reads from the exception.""" + from agento11y.client import _error_category_from_exception + + cases = {429: "rate_limit", 401: "auth_error", 403: "auth_error", 503: "server_error"} + for status_code, expected in cases.items(): + error = _errors.ProviderCallError("api_request_error", status_code) + assert _error_category_from_exception(error, fallback_sdk=True) == expected + + +def test_a_non_numeric_token_count_does_not_discard_the_generation( + patch_client: Any, + env_creds: None, +) -> None: + """One unusable token value used to abort the close before set_result.""" + _pre() + _post(usage={"input_tokens": "lots"}, assistant_message={"role": "assistant", "content": "4"}) + + call = patch_client._next_gen_recorder.set_result_calls[0] + assert call["input"] + assert texts(call["output"]) == ["4"] + assert call["response_model"] == "claude-sonnet-4-6" + usage = call["usage"] + assert usage.input_tokens == 0 + assert usage.output_tokens == 0 + assert usage.total_tokens == 0 + + +@pytest.mark.parametrize( + ("sent", "body", "recorded"), + [ + # The body is what hermes put on the wire, and the kwarg beside it + # arrives as None on every supported release. + (None, {"max_tokens": 8192}, 8192), + ("8192", {"max_tokens": 4096}, 4096), + # Nothing readable on the body: the kwarg is all there is. + (4096, None, 4096), + (None, None, None), + ("8192", None, 8192), + ("none", None, None), + ], +) +def test_max_tokens_is_recorded( + patch_client: Any, env_creds: None, sent: Any, body: dict | None, recorded: int | None +) -> None: + _pre(max_tokens=sent, request={"method": "POST", "body": body} if body else None) + + assert patch_client.start_generation_calls[0].max_tokens == recorded + + +# --- what the request payload puts on the generation --- + + +def test_the_seed_carries_the_tools_and_sampling_params(patch_client: Any, env_creds: None) -> None: + _pre() + + start = patch_client.start_generation_calls[0] + assert [tool.name for tool in start.tools] == ["read_file", "shell"] + assert start.system_prompt == "be helpful" + assert start.max_tokens == 8192 + assert start.temperature == 0.7 + assert start.tool_choice == "auto" + assert start.metadata["hermes.tool_count"] == 2 + + +@pytest.mark.parametrize( + ("over", "expected"), + [ + # anthropic_messages puts it on the body. + ({}, "be helpful"), + # chat_completions puts no ``system`` on the body at all, so the + # message list is the only copy. + ( + { + "request": {"method": "POST", "body": {"model": "gpt-5"}}, + "request_messages": [{"role": "system", "content": "from the messages"}], + }, + "from the messages", + ), + # Hermes past 0.20.1 passes the unclipped text as its own kwarg, which + # beats the body the sanitizer may have clipped. + ({"system_prompt": "from the kwarg"}, "from the kwarg"), + ], +) +def test_the_seed_system_prompt_follows_the_api_mode( + patch_client: Any, env_creds: None, over: dict[str, Any], expected: str +) -> None: + _pre(**over) + + assert patch_client.start_generation_calls[0].system_prompt == expected + + +def test_a_clipped_request_reuses_the_session_capture(patch_client: Any, env_creds: None) -> None: + """The common case: hermes clips the payload as the conversation grows.""" + _pre() + _pre(api_request_id="req-2", request=CLIPPED_REQUEST) + + start = patch_client.start_generation_calls[1] + assert [tool.name for tool in start.tools] == ["read_file", "shell"] + assert start.system_prompt == "be helpful" + assert start.max_tokens == 8192 + + +def test_a_model_switch_drops_the_carried_sampling_params(patch_client: Any, env_creds: None) -> None: + """A cap and a temperature come from the model's own profile. + + The prompt and the toolset are the agent's, so a fallback to another + provider keeps those and resolves its own sampling params. + """ + _pre() + _pre(api_request_id="req-2", model="claude-opus-4-1", request=CLIPPED_REQUEST) + + start = patch_client.start_generation_calls[1] + assert start.max_tokens is None + assert start.temperature is None + assert start.system_prompt == "be helpful" + assert [tool.name for tool in start.tools] == ["read_file", "shell"] + + +@pytest.mark.parametrize("fallback_body", [None, {"max_tokens": 32000}, {"temperature": 0.2}]) +def test_a_one_turn_fallback_keeps_the_params_of_the_model_it_left( + patch_client: Any, env_creds: None, fallback_body: dict[str, Any] | None +) -> None: + """Hermes restores the primary runtime at the top of every turn. + + So the model a failure moved the session to holds it for one turn, and the + request that comes back to the first model is deep enough in the session to + arrive clipped. Retiring the params on the way out would empty every + generation after that. + """ + _pre() + _pre( + api_request_id="req-2", + model="claude-opus-4-1", + request={"body": fallback_body} if fallback_body is not None else CLIPPED_REQUEST, + ) + _pre(api_request_id="req-3", request=CLIPPED_REQUEST) + + fallback = patch_client.start_generation_calls[1] + assert fallback.max_tokens == (fallback_body or {}).get("max_tokens") + assert fallback.temperature == (fallback_body or {}).get("temperature") + start = patch_client.start_generation_calls[2] + assert start.max_tokens == 8192 + assert start.temperature == 0.7 + + _pre(api_request_id="req-4", model="claude-opus-4-1", request=CLIPPED_REQUEST) + restored_fallback = patch_client.start_generation_calls[3] + assert restored_fallback.max_tokens == fallback.max_tokens + assert restored_fallback.temperature == fallback.temperature + assert restored_fallback.system_prompt == "be helpful" + assert [tool.name for tool in restored_fallback.tools] == ["read_file", "shell"] + + +def test_a_real_model_switch_takes_the_capture_over(patch_client: Any, env_creds: None) -> None: + _pre() + _pre( + api_request_id="req-2", + model="claude-opus-4-1", + request={"method": "POST", "body": {"max_tokens": 32000}}, + ) + _pre(api_request_id="req-3", model="claude-opus-4-1", request=CLIPPED_REQUEST) + + assert patch_client.start_generation_calls[2].max_tokens == 32000 + + +def test_same_model_partial_params_preserve_missing_fields(patch_client: Any, env_creds: None) -> None: + _pre(request={"body": {"max_tokens": 8192, "temperature": 0.7, "top_p": 0.9, "tool_choice": "auto"}}) + _pre(api_request_id="req-2", request={"body": {"temperature": 0.0}}) + _pre(api_request_id="req-3", request=CLIPPED_REQUEST) + + for start in patch_client.start_generation_calls[1:]: + assert start.max_tokens == 8192 + assert start.temperature == 0.0 + assert start.top_p == 0.9 + assert start.tool_choice == "auto" + assert start.metadata["hermes.request_facts_reused"] is True + + +def test_a_body_of_only_sampling_params_still_carries_forward(patch_client: Any, env_creds: None) -> None: + _pre(request={"method": "POST", "body": {"max_tokens": 64000, "temperature": 1}}, tool_count=0) + _pre(api_request_id="req-2", request=CLIPPED_REQUEST, tool_count=0) + + start = patch_client.start_generation_calls[1] + assert start.max_tokens == 64000 + assert start.temperature == 1.0 + + +def test_a_capture_never_crosses_into_another_session(patch_client: Any, env_creds: None) -> None: + _pre() + _hooks.on_session_end(session_id="sess-1") + + _pre(api_request_id="req-2", session_id="sess-2", request=CLIPPED_REQUEST) + + start = patch_client.start_generation_calls[-1] + assert start.tools == [] + assert start.system_prompt == "" + + +def test_a_capture_outlives_the_turn_that_made_it(patch_client: Any, env_creds: None) -> None: + """``on_session_end`` fires once per user message, not once per session. + + Hermes calls ``run_conversation`` per turn and finalizes it at the end of + each (``agent/turn_finalizer.py`` in 0.19.0). Turn 2's first request + already carries the grown history and so arrives clipped, which is exactly + when the capture has to still be there. + """ + _pre() + _hooks.on_session_end(session_id="sess-1") + + _pre(api_request_id="req-2", request=CLIPPED_REQUEST) + + start = patch_client.start_generation_calls[-1] + assert [tool.name for tool in start.tools] == ["read_file", "shell"] + assert start.system_prompt == "be helpful" + + +def test_a_clipped_field_does_not_overwrite_the_capture_it_borrows_from(patch_client: Any, env_creds: None) -> None: + """The first two sanitizer passes leave a value that reads as present. + + A prompt cut to its first line and a tool list cut to one entry are worse + than the complete copy already held, so neither is exported nor stored. + """ + _pre() + _pre(api_request_id="req-2", request=CLIPPED_FIELDS_REQUEST) + _pre(api_request_id="req-3", request=CLIPPED_REQUEST) + + for start in patch_client.start_generation_calls[1:]: + assert [tool.name for tool in start.tools] == ["read_file", "shell"] + assert start.system_prompt == "be helpful" + + +def test_a_shorter_clip_does_not_replace_a_longer_one(patch_client: Any, env_creds: None) -> None: + """Pass 2 cuts a string to 1000 chars where pass 1 cut it to 8000.""" + + def clipped_to(kept: int) -> dict[str, Any]: + return {"method": "POST", "body": {"system": "S" * kept + f"...[truncated {12000 - kept} chars]"}} + + _pre(request=clipped_to(8000)) + _pre(api_request_id="req-2", request=clipped_to(1000)) + + assert patch_client.start_generation_calls[1].system_prompt.startswith("S" * 8000) + + +def test_the_record_says_when_a_field_came_from_an_earlier_request(patch_client: Any, env_creds: None) -> None: + """A swapped toolset makes a reused field stale, so the record admits it.""" + _pre() + _pre(api_request_id="req-2", request=CLIPPED_REQUEST) + + first, second = patch_client.start_generation_calls + assert first.metadata["hermes.request_facts_reused"] is False + assert second.metadata["hermes.request_facts_reused"] is True + + +def test_the_capture_survives_a_request_the_sampler_dropped( + patch_client: Any, env_creds: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Sampling must not eat the readable payloads and keep the clipped ones. + + Only the earliest requests of a session arrive complete, so gating the read + on the sample rate leaves every sampled-in request with nothing behind it. + """ + cfg = _client._get_plugin_config() + assert cfg is not None + monkeypatch.setattr(cfg, "sample_rate", 0.0) + _pre() + assert patch_client.start_generation_calls == [], "the generation itself is skipped" + + monkeypatch.setattr(cfg, "sample_rate", 1.0) + _pre(api_request_id="req-2", request=CLIPPED_REQUEST) + + start = patch_client.start_generation_calls[0] + assert [tool.name for tool in start.tools] == ["read_file", "shell"] + assert start.system_prompt == "be helpful" + + +def test_the_capture_map_is_bounded(patch_client: Any, env_creds: None) -> None: + """Nothing clears an entry, so the bound is what keeps a long process flat.""" + for n in range(_state._SESSION_FACTS_MAX + 1): + _pre(api_request_id=f"req-{n}", session_id=f"sess-{n}") + + assert _state.session_facts_get("sess-0") is None + assert _state.session_facts_get(f"sess-{_state._SESSION_FACTS_MAX}") is not None + + +def test_the_capture_map_evicts_the_least_recently_used_session(patch_client: Any, env_creds: None) -> None: + for n in range(_state._SESSION_FACTS_MAX): + _pre(api_request_id=f"req-{n}", session_id=f"sess-{n}") + assert _state.session_facts_get("sess-0") is not None + _pre(api_request_id="req-new", session_id="sess-new") + + assert _state.session_facts_get("sess-1") is None + assert _state.session_facts_get("sess-0") is not None + + +def test_model_cache_eviction_keeps_shared_facts(patch_client: Any, env_creds: None) -> None: + _pre(model="model-0") + for n in range(1, _state._SESSION_FACTS_MODELS_MAX): + _pre(api_request_id=f"req-{n}", model=f"model-{n}", request={"body": {"max_tokens": n}}) + assert _state.session_facts_get("sess-1", "model-0") is not None + _pre(api_request_id="req-new", model="model-new", request=CLIPPED_REQUEST) + + entry = _state._SESSION_REQUEST_FACTS["sess-1"] + assert len(entry.models) == _state._SESSION_FACTS_MODELS_MAX + assert "model-1" not in entry.models + assert "model-0" in entry.models + + _pre(api_request_id="req-return", model="model-0", request=CLIPPED_REQUEST) + assert patch_client.start_generation_calls[-1].max_tokens == 8192 + _pre(api_request_id="req-evicted", model="model-1", request=CLIPPED_REQUEST) + start = patch_client.start_generation_calls[-1] + assert start.max_tokens is None + assert start.temperature is None + assert start.system_prompt == "be helpful" + assert [tool.name for tool in start.tools] == ["read_file", "shell"] + assert start.metadata["hermes.request_facts_reused"] is True + assert len(entry.models) == _state._SESSION_FACTS_MODELS_MAX + + +def test_shared_facts_improve_across_models(patch_client: Any, env_creds: None) -> None: + _pre(request=CLIPPED_FIELDS_REQUEST) + _pre(api_request_id="req-2", model="other-model") + _pre(api_request_id="req-3", request=CLIPPED_REQUEST) + + start = patch_client.start_generation_calls[-1] + assert start.system_prompt == "be helpful" + assert [tool.name for tool in start.tools] == ["read_file", "shell"] + + +def test_the_history_system_prompt_is_the_last_resort(patch_client: Any, env_creds: None) -> None: + """No request payload at all: a pre-0.16.0 hermes, or a collapsed envelope. + + Current releases put no system message in ``conversation_history``, so this + path is a fallback rather than the normal source. + """ + _pre(request=None, conversation_history=[{"role": "system", "content": "from the history"}, *CONVO]) + + assert patch_client.start_generation_calls[0].system_prompt == "from the history" + + +def test_a_clipped_request_still_records_the_tool_count(patch_client: Any, env_creds: None) -> None: + """``tools: []`` beside a non-zero count reads as lost schemas, not no tools.""" + _pre(request=CLIPPED_REQUEST, tool_count=17) + + start = patch_client.start_generation_calls[0] + assert start.tools == [] + assert start.metadata["hermes.tool_count"] == 17 + + +def test_the_truncation_note_names_the_host_knob_once( + patch_client: Any, env_creds: None, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.DEBUG): + for n in range(3): + _pre(api_request_id=f"req-{n}", request=CLIPPED_REQUEST) + + notes = [r for r in caplog.records if "HERMES_PLUGIN_PAYLOAD_MAX_CHARS" in r.getMessage()] + assert len(notes) == 1 + + +def test_generations_carry_the_builtin_tags(patch_client: Any, env_creds: None) -> None: + """The cross-plugin tags, so hermes filters like cursor and codex do. + + The SDK merges the client tags underneath the seed tags, so the export sees + the union whichever side a tag rides on. + """ + _pre() + + cfg = _client._get_plugin_config() + assert cfg is not None + tags = {**_client._to_client_config(cfg).tags, **patch_client.start_generation_calls[0].tags} + assert tags["entrypoint"] == "hermes" + assert "cwd" not in tags + assert "git.branch" not in tags + assert tags["agento11y.framework.name"] == "hermes" + assert tags["agento11y.framework.source"] == "plugin" + assert tags["agento11y.framework.language"] == "python" + + +def test_the_identity_tags_ride_on_the_client_config(patch_client: Any, env_creds: None) -> None: + """Only client tags reach spans and metrics, as agento11y.tag..""" + cfg = _client._get_plugin_config() + assert cfg is not None + + tags = _client._to_client_config(cfg).tags + + assert tags["agento11y.framework.name"] == "hermes" + assert tags["agento11y.framework.source"] == "plugin" + assert tags["agento11y.framework.language"] == "python" + assert tags["entrypoint"] == "hermes" + assert "git.branch" not in tags + assert "cwd" not in tags, "one metric series per working directory is not worth the label" + + +def test_a_tool_execution_is_typed_as_a_function(patch_client: Any, env_creds: None) -> None: + _hooks.on_post_tool_call(tool_name="read_file", session_id="sess-1", tool_call_id="call-1") + + assert patch_client.start_tool_execution_calls[0].tool_type == "function" + + +def test_effective_version_reads_the_shared_name(patch_client: Any, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTO11Y_AGENT_VERSION", "1.2.3") + _pre() + + assert patch_client.start_generation_calls[0].effective_version == "1.2.3" + + +def test_the_deprecated_version_name_still_works_and_warns( + patch_client: Any, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.delenv("AGENTO11Y_AGENT_VERSION", raising=False) + monkeypatch.setenv("AGENTO11Y_HERMES_AGENT_VERSION", "0.9") + + with caplog.at_level(logging.WARNING): + _pre() + _pre(api_request_id="req-2") + + assert patch_client.start_generation_calls[0].effective_version == "0.9" + deprecations = [r for r in caplog.records if "AGENTO11Y_HERMES_AGENT_VERSION" in r.getMessage()] + assert len(deprecations) == 1 + + +def test_a_failed_tool_sets_the_exec_error_before_the_result(patch_client: Any, env_creds: None) -> None: + _hooks.on_post_tool_call( + tool_name="read_file", + args={"path": "/nope"}, + result="", + session_id="sess-1", + tool_call_id="call-1", + status="error", + error_type="FileNotFoundError", + error_message="boom", + ) + + rec = patch_client._next_tool_recorder + assert rec.calls == ["set_exec_error", "set_result"] + assert str(rec.set_exec_error_calls[0]) == "boom" + + +def test_a_failed_tool_without_a_message_falls_back(patch_client: Any, env_creds: None) -> None: + _hooks.on_post_tool_call( + tool_name="read_file", + session_id="sess-1", + tool_call_id="call-1", + status="error", + error_type="FileNotFoundError", + ) + + assert str(patch_client._next_tool_recorder.set_exec_error_calls[0]) == "FileNotFoundError" + + +def test_a_successful_tool_sets_no_exec_error(patch_client: Any, env_creds: None) -> None: + _hooks.on_post_tool_call( + tool_name="read_file", + result="contents", + session_id="sess-1", + tool_call_id="call-1", + status="ok", + ) + + assert patch_client._next_tool_recorder.set_exec_error_calls == [] + + +@pytest.mark.parametrize("status", ["blocked", "cancelled", "BLOCKED", "CANCELLED"]) +def test_a_blocked_or_cancelled_tool_has_no_execution(patch_client: Any, status: str) -> None: + _hooks.on_post_tool_call(tool_name="read_file", session_id="sess-1", status=status) + + assert patch_client.start_tool_execution_calls == [] + + +@pytest.mark.parametrize("sample_request", [True, False]) +@pytest.mark.parametrize("previous_model", [False, True]) +def test_a_tool_execution_carries_the_requesting_model( + patch_client: Any, env_creds: None, monkeypatch: pytest.MonkeyPatch, sample_request: bool, previous_model: bool +) -> None: + """post_tool_call carries no model, so the metric needs the cached one.""" + if previous_model: + _pre(api_request_id="previous", model="old-model", provider="old-provider") + samples = iter([sample_request, True]) + monkeypatch.setattr(_hooks, "_should_sample", lambda: next(samples)) + _pre(model="claude-sonnet-4-6", provider="anthropic") + + _hooks.on_post_tool_call(tool_name="read_file", session_id="sess-1", tool_call_id="call-1") + + start = patch_client.start_tool_execution_calls[0] + assert start.request_model == "claude-sonnet-4-6" + assert start.request_provider == "anthropic" + + +# --- the generations of one turn form a chain --- +# +# A tool loop is a DAG: call N+1's input is call N's output plus the tool +# results. Chained per turn_id, so a session is a set of chains rather than one +# long line. MoA fan-out puts concurrent requests in one turn and will chain +# them in an arbitrary order; that is left as it is. + + +def test_the_second_call_of_a_turn_names_the_first(patch_client: Any, env_creds: None) -> None: + _pre(api_request_id="req-1") + _post(api_request_id="req-1") + _pre(api_request_id="req-2") + + first, second = patch_client.start_generation_calls + assert first.parent_generation_ids == [] + assert second.parent_generation_ids == [first.id] + + +def test_a_superseded_attempt_is_not_the_next_calls_parent(patch_client: Any, env_creds: None) -> None: + """The chain is written at close time, and a displaced attempt never closes clean.""" + _pre(api_request_id="req-1") + _pre(api_request_id="req-1") + _post(api_request_id="req-1") + _pre(api_request_id="req-2") + + abandoned, kept, following = patch_client.start_generation_calls + assert abandoned.id != kept.id + assert following.parent_generation_ids == [kept.id] + + +def test_a_new_turn_starts_a_new_chain(patch_client: Any, env_creds: None) -> None: + _pre(api_request_id="req-1", turn_id="turn-1") + _post(api_request_id="req-1", turn_id="turn-1") + + _pre(api_request_id="req-2", turn_id="turn-2") + + assert patch_client.start_generation_calls[1].parent_generation_ids == [] + + +def test_post_llm_call_ends_the_turns_chain(patch_client: Any, env_creds: None) -> None: + _pre() + _post() + + _hooks.on_post_llm_call(task_id="task-1", session_id="sess-1", turn_id="turn-1") + + assert _state.turn_last_gen_get("turn-1") == "" + + +def test_a_failed_call_is_not_a_parent(patch_client: Any, env_creds: None) -> None: + _pre(api_request_id="req-1") + _hooks.on_api_request_error(api_request_id="req-1", error="boom", status_code=500) + + _pre(api_request_id="req-2") + + assert patch_client.start_generation_calls[1].parent_generation_ids == [] + + +def test_the_link_maps_drop_their_oldest_entries(patch_client: Any, env_creds: None) -> None: + """A process that runs for days must not grow them without limit.""" + overflow = _state._MAX_ENTRIES + 10 + for index in range(overflow): + _state.gen_link_put(f"req-{index}", _state.GenLink(generation_id=f"gen-{index}")) + _state.turn_last_gen_put(f"turn-{index}", f"gen-{index}", "sess-1") + + assert _state.gen_link_get("req-0") is None + assert _state.turn_last_gen_get("turn-0") == "" + assert _state.gen_link_get(f"req-{overflow - 1}") is not None + assert _state.turn_last_gen_get(f"turn-{overflow - 1}") == f"gen-{overflow - 1}" + + +# --- tool executions linked to the call that requested them --- + + +def test_a_tool_span_names_the_generation_that_requested_it(patch_client: Any, env_creds: None) -> None: + _pre() + generation_id = patch_client.start_generation_calls[0].id + assert generation_id + + _hooks.on_post_tool_call( + tool_name="read_file", + session_id="sess-1", + tool_call_id="call-1", + api_request_id="req-1", + ) + + attributes = patch_client._next_tool_recorder.span.attributes + assert attributes["agento11y.generation.parent_generation_ids"] == [generation_id] + + +def test_a_tool_span_is_started_inside_the_generations_trace( + patch_client: Any, + env_creds: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The SDK parents the span off the ambient context, so that is what we set.""" + _pre() + generation_context = patch_client._next_gen_recorder.span.get_span_context() + ambient: list[Any] = [] + original = patch_client.start_tool_execution + + def capture(start: Any) -> Any: + ambient.append(otel_trace.get_current_span().get_span_context()) + return original(start) + + monkeypatch.setattr(patch_client, "start_tool_execution", capture) + + _hooks.on_post_tool_call( + tool_name="read_file", + session_id="sess-1", + tool_call_id="call-1", + api_request_id="req-1", + ) + + assert ambient[0].trace_id == generation_context.trace_id + assert ambient[0].span_id == generation_context.span_id + assert not otel_trace.get_current_span().get_span_context().is_valid, "the context must be detached again" + + +def test_a_tool_of_an_unknown_request_is_still_recorded(patch_client: Any, env_creds: None) -> None: + """Fail open: no link resolves, so the tool becomes its own root span.""" + _pre() + + _hooks.on_post_tool_call( + tool_name="read_file", + args={"path": "/tmp/x"}, + result="contents", + session_id="sess-1", + tool_call_id="call-1", + api_request_id="req-does-not-exist", + ) + + rec = patch_client._next_tool_recorder + assert rec.exited + assert rec.set_result_calls[0]["arguments"] == {"path": "/tmp/x"} + assert rec.set_result_calls[0]["result"] == "contents" + assert "agento11y.generation.parent_generation_ids" not in rec.span.attributes + + +def test_a_tool_without_a_request_id_is_still_recorded(patch_client: Any, env_creds: None) -> None: + _pre() + + _hooks.on_post_tool_call(tool_name="read_file", session_id="sess-1", tool_call_id="call-1") + + rec = patch_client._next_tool_recorder + assert rec.exited + assert "agento11y.generation.parent_generation_ids" not in rec.span.attributes + + +def test_a_recorder_without_a_span_does_not_break_the_hook( + patch_client: Any, + env_creds: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``NoopToolExecutionRecorder`` has no span, and neither does a stubbed one.""" + _pre() + original = patch_client.start_tool_execution + + def spanless(start: Any) -> Any: + recorder = original(start) + del recorder.span + return recorder + + monkeypatch.setattr(patch_client, "start_tool_execution", spanless) + + _hooks.on_post_tool_call( + tool_name="read_file", + session_id="sess-1", + tool_call_id="call-1", + api_request_id="req-1", + ) + + assert patch_client._next_tool_recorder.exited + + +@pytest.mark.parametrize( + ("shared_version", "legacy_version", "expected_version", "expected_effective_version"), + [("1.2.3", "0.9", "1.2.3", "1.2.3"), ("", "0.9", "", "0.9"), ("", "", "", "")], +) +def test_the_exported_spans_share_a_trace_and_carry_the_client_tags( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + env_creds: None, + shared_version: str, + legacy_version: str, + expected_version: str, + expected_effective_version: str, +) -> None: + """Use the real SDK because the fake client starts no spans.""" + import agento11y + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + from grafana_agento11y_hermes import _otel + + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + monkeypatch.setenv("AGENTO11Y_AGENT_VERSION", shared_version) + monkeypatch.setenv("AGENTO11Y_HERMES_AGENT_VERSION", legacy_version) + real_client = agento11y.Client + generations: list[Any] = [] + + def factory(config: Any) -> Any: + config.tracer = provider.get_tracer("test") + config.generation_export.protocol = "none" + client = real_client(config) + monkeypatch.setattr(client, "_enqueue_generation", generations.append) + # The only real client the suite builds, and nothing else closes it: + # reset_module_state drops the reference without shutting it down, and + # Client.__init__ has already started a flush timer thread that would + # then wake once a second for the rest of the run. Zeroing the interval + # is not the way out, because the SDK clamps a non-positive one to 1ms. + request.addfinalizer(client.shutdown) + return client + + monkeypatch.setattr(agento11y, "Client", factory) + monkeypatch.setattr(_otel, "setup_if_needed", lambda cfg: True) + + _pre() + _hooks.on_post_tool_call( + tool_name="read_file", + session_id="sess-1", + tool_call_id="call-1", + api_request_id="req-1", + ) + _post() + + assert len(generations) == 1 + assert generations[0].agent_version == expected_version + assert generations[0].effective_version == expected_effective_version + spans = {span.name.split()[0]: span for span in exporter.get_finished_spans()} + generation, tool = spans["generateText"], spans["execute_tool"] + assert tool.context.trace_id == generation.context.trace_id + assert tool.parent is not None and tool.parent.span_id == generation.context.span_id + assert attributes(tool)["agento11y.generation.parent_generation_ids"] == ( + attributes(generation)["agento11y.generation.id"], + ) + for span in (generation, tool): + assert attributes(span).get("gen_ai.agent.version", "") == expected_version + assert attributes(span)["agento11y.tag.agento11y.framework.name"] == "hermes" + assert attributes(span)["agento11y.tag.entrypoint"] == "hermes" + # mode stays SYNC, which is what makes the operation generateText. See + # "mode stays SYNC" in CLAUDE.md for why streaming is not detectable here. + assert attributes(generation)["gen_ai.operation.name"] == "generateText" + + +def test_current_hermes_stops_maintaining_the_legacy_convo(patch_client: Any, env_creds: None) -> None: + """Bookkeeping only the pre-v2026.6.5 path reads.""" + _pre() + + _hooks.on_pre_llm_call(session_id="sess-1", conversation_history=list(CONVO)) + _hooks.on_post_tool_call(tool_name="read_file", session_id="sess-1", tool_call_id="call-1") + + assert _state.convo_get(("", "sess-1")) == [] + + +# --- flushing the failure path --- +# +# Hermes one-shot fires no session hook when a turn dies on a provider error, +# and exits via os._exit (hermes_cli/main.py:_exit_after_oneshot), which skips +# the SDK's atexit flush. The error hook is the last chance to export. + + +def test_api_request_error_flushes(patch_client: Any, env_creds: None) -> None: + _pre() + + _hooks.on_api_request_error(api_request_id="req-1", error="boom", status_code=500) + + assert patch_client.flush_calls == 1 + + +def test_api_request_error_does_not_flush_for_an_unknown_id(patch_client: Any, env_creds: None) -> None: + """Nothing was closed, so there is nothing new to export.""" + _pre() + + _hooks.on_api_request_error(api_request_id="req-other", error="boom") + + assert patch_client.flush_calls == 0 + + +def test_error_flush_timeout_zero_skips_the_flush( + patch_client: Any, env_creds: None, monkeypatch: pytest.MonkeyPatch +) -> None: + cfg = _client._get_plugin_config() + assert cfg is not None + monkeypatch.setattr(cfg, "error_flush_timeout", 0.0) + _pre() + + _hooks.on_api_request_error(api_request_id="req-1", error="boom", status_code=500) + + assert patch_client._next_gen_recorder.exited, "the generation still closes" + assert patch_client.flush_calls == 0 + + +def test_a_hanging_flush_does_not_block_the_hook( + patch_client: Any, env_creds: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Fail open: a stuck exporter must not stall the hermes loop.""" + release = threading.Event() + + def hanging_flush() -> None: + release.wait(30) + + monkeypatch.setattr(patch_client, "flush", hanging_flush) + cfg = _client._get_plugin_config() + assert cfg is not None + monkeypatch.setattr(cfg, "error_flush_timeout", 0.05) + _pre() + + started = time.monotonic() + _hooks.on_api_request_error(api_request_id="req-1", error="boom", status_code=500) + elapsed = time.monotonic() - started + release.set() + + assert elapsed < 5, f"hook waited {elapsed}s on a hanging flush" + + +def test_session_finalize_flushes(patch_client: Any, env_creds: None) -> None: + """The only session hook the interactive failure path gets.""" + _hooks.on_session_finalize(session_id="sess-1") + + assert patch_client.flush_calls == 1 + + +def test_session_finalize_closes_a_still_open_generation(patch_client: Any, env_creds: None) -> None: + _pre() + rec = patch_client._next_gen_recorder + + _hooks.on_session_finalize(session_id="sess-1") + + assert rec.exited diff --git a/plugins/hermes/tests/test_message_mapping.py b/plugins/hermes/tests/test_message_mapping.py new file mode 100644 index 000000000..8daabe985 --- /dev/null +++ b/plugins/hermes/tests/test_message_mapping.py @@ -0,0 +1,322 @@ +"""Turning hermes payloads into SDK messages. + +Hermes hands over whatever the provider used, so the same tool call arrives as +a dict on one route and as an object with attributes on another, and its +arguments arrive as a JSON string about as often as a dict. This is the layer +that flattens that, and it has to drop what it cannot read instead of raising: +it runs inside the argument list of ``set_result``. +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from agento11y import MessageRole + +from grafana_agento11y_hermes import _client, _config, _hooks + + +class _Function: + def __init__(self, name: str, arguments: Any) -> None: + self.name = name + self.arguments = arguments + + +class _ToolCall: + """A provider SDK's tool call: attributes, not keys.""" + + def __init__(self, id: str, function: Any) -> None: + self.id = id + self.function = function + + +class _AssistantObject: + def __init__(self, content: Any, tool_calls: Any = None) -> None: + self.content = content + self.tool_calls = tool_calls + + +# --- tool call flattening --- + + +def test_a_dict_shaped_tool_call_is_read() -> None: + calls = _hooks._serialize_tool_calls([{"id": "c1", "function": {"name": "bash", "arguments": {"command": "ls"}}}]) + assert calls == [{"id": "c1", "name": "bash", "arguments": {"command": "ls"}}] + + +def test_an_object_shaped_tool_call_is_read() -> None: + calls = _hooks._serialize_tool_calls([_ToolCall("c1", _Function("bash", {"command": "ls"}))]) + assert calls == [{"id": "c1", "name": "bash", "arguments": {"command": "ls"}}] + + +def test_an_object_tool_call_without_a_function_is_kept_nameless() -> None: + calls = _hooks._serialize_tool_calls([_ToolCall("c1", None)]) + assert calls == [{"id": "c1", "name": "", "arguments": None}] + + +@pytest.mark.parametrize( + ("arguments", "expected"), + ( + # OpenAI sends the arguments as a JSON string. + ('{"command": "ls"}', {"command": "ls"}), + # Anthropic sends them already decoded. + ({"command": "ls"}, {"command": "ls"}), + # A string that is not JSON stays a string rather than costing the call. + ("not json at all", "not json at all"), + ('{"unterminated": ', '{"unterminated": '), + (None, None), + ), +) +def test_tool_call_arguments_are_decoded_where_possible(arguments: Any, expected: Any) -> None: + calls = _hooks._serialize_tool_calls([{"id": "c1", "function": {"name": "bash", "arguments": arguments}}]) + assert calls[0]["arguments"] == expected + + +def test_no_tool_calls_is_an_empty_list() -> None: + assert _hooks._serialize_tool_calls(None) == [] + assert _hooks._serialize_tool_calls([]) == [] + + +# --- message conversion --- + + +def test_a_user_message_becomes_one_text_part() -> None: + msg = _hooks._to_sdk_message({"role": "user", "content": "hi"}) + assert msg is not None + assert msg.role == MessageRole.USER + assert len(msg.parts) == 1 + + +def test_an_empty_user_message_carries_no_parts() -> None: + msg = _hooks._to_sdk_message({"role": "user", "content": ""}) + assert msg is not None + assert msg.parts == [] + + +def test_a_tool_result_message_is_keyed_by_its_call_id() -> None: + msg = _hooks._to_sdk_message({"role": "tool", "tool_call_id": "c1", "content": "a.txt"}) + assert msg is not None + assert msg.role == MessageRole.TOOL + assert len(msg.parts) == 1 + + +def test_an_assistant_message_carries_text_and_tool_calls_together() -> None: + msg = _hooks._to_sdk_message( + { + "role": "assistant", + "content": "running it", + "tool_calls": [{"id": "c1", "function": {"name": "bash", "arguments": {"command": "ls"}}}], + } + ) + assert msg is not None + assert msg.role == MessageRole.ASSISTANT + assert len(msg.parts) == 2, "one text part and one tool-call part" + + +def test_an_assistant_message_with_only_tool_calls_has_no_text_part() -> None: + msg = _hooks._to_sdk_message( + {"role": "assistant", "content": None, "tool_calls": [{"id": "c1", "function": {"name": "bash"}}]} + ) + assert msg is not None + assert len(msg.parts) == 1 + + +def test_circular_tool_call_arguments_stop_at_the_depth_limit() -> None: + circular: dict[str, Any] = {} + circular["self"] = circular + msg = _hooks._to_sdk_message( + {"role": "assistant", "tool_calls": [{"id": "c1", "function": {"name": "bash", "arguments": circular}}]} + ) + assert msg is not None + assert json.loads(msg.parts[0].tool_call.input_json) == { + "self": {"self": {"self": {"self": {"self": ""}}}} + } + + +def test_tool_call_arguments_that_cannot_be_sanitized_become_empty_input(monkeypatch: pytest.MonkeyPatch) -> None: + def fail(*args: Any, **kwargs: Any) -> Any: + raise ValueError("cannot sanitize") + + monkeypatch.setattr(_hooks._redact, "safe_value", fail) + msg = _hooks._to_sdk_message( + {"role": "assistant", "tool_calls": [{"id": "c1", "function": {"name": "bash", "arguments": {}}}]} + ) + + assert msg.parts[0].tool_call.input_json == b"" + + +@pytest.mark.parametrize("max_chars", [None, 80]) +@pytest.mark.parametrize("encoded", [False, True]) +def test_generation_tool_payloads_respect_the_string_limit( + monkeypatch: pytest.MonkeyPatch, max_chars: int | None, encoded: bool +) -> None: + if max_chars is not None: + monkeypatch.setattr(_client, "_CONFIG", _config.PluginConfig(max_chars=max_chars)) + limit = max_chars if max_chars is not None else 12000 + payload = "x" * (limit + 100) + arguments = {"value": payload} + msg = _hooks._to_sdk_message( + { + "role": "assistant", + "tool_calls": [ + {"id": "c1", "function": {"name": "bash", "arguments": json.dumps(arguments) if encoded else arguments}} + ], + } + ) + result = _hooks._to_sdk_message({"role": "tool", "tool_call_id": "c1", "content": payload}) + + expected = "x" * limit + "... [truncated 100 chars]" + assert json.loads(msg.parts[0].tool_call.input_json) == {"value": expected} + assert result.parts[0].tool_result.content == expected + assert arguments == {"value": payload} + + +def test_generation_tool_arguments_respect_structural_limits() -> None: + arguments = {"items": list(range(100)), "nested": {"a": {"b": {"c": {"d": "deep"}}}}} + msg = _hooks._to_sdk_message( + {"role": "assistant", "tool_calls": [{"function": {"name": "bash", "arguments": arguments}}]} + ) + + recorded = json.loads(msg.parts[0].tool_call.input_json) + assert recorded["items"] == list(range(50)) + assert recorded["nested"]["a"]["b"]["c"]["d"] == "" + + +def test_an_unknown_role_is_dropped() -> None: + assert _hooks._to_sdk_message({"role": "developer", "content": "x"}) is None + assert _hooks._to_sdk_message({"content": "no role at all"}) is None + + +@pytest.mark.parametrize("messages", (None, "a string", {"role": "user"}, 7)) +def test_a_message_list_that_is_not_a_list_maps_to_nothing(messages: Any) -> None: + assert _hooks._to_sdk_messages(messages) == [] + + +def test_non_dict_entries_and_system_messages_are_skipped() -> None: + out = _hooks._to_sdk_messages( + [ + "a raw string", + 7, + None, + {"role": "system", "content": "you are helpful"}, + {"role": "user", "content": "hi"}, + {"role": "developer", "content": "dropped as unknown"}, + ] + ) + assert len(out) == 1 + assert out[0].role == MessageRole.USER + + +# --- the assistant response on post_api_request --- + + +def test_no_assistant_message_maps_to_no_output() -> None: + assert _hooks._assistant_to_sdk_messages(None) == [] + + +def test_an_object_shaped_assistant_message_is_read() -> None: + out = _hooks._assistant_to_sdk_messages(_AssistantObject("done")) + assert len(out) == 1 + assert out[0].role == MessageRole.ASSISTANT + + +def test_an_object_shaped_assistant_message_keeps_its_tool_calls() -> None: + out = _hooks._assistant_to_sdk_messages( + _AssistantObject(None, [_ToolCall("c1", _Function("bash", '{"command": "ls"}'))]) + ) + assert len(out) == 1 + assert len(out[0].parts) == 1 + + +def test_an_assistant_message_of_an_unreadable_type_maps_to_no_output() -> None: + """``getattr`` finds nothing, so the message has no content and no calls.""" + out = _hooks._assistant_to_sdk_messages(object()) + assert len(out) == 1 + assert out[0].parts == [] + + +# --- token usage --- + + +@pytest.mark.parametrize( + ("usage", "expected_input", "expected_output"), + ( + ({"input_tokens": 10, "output_tokens": 3}, 10, 3), + # OpenAI names them differently. + ({"prompt_tokens": 10, "completion_tokens": 3}, 10, 3), + ({}, 0, 0), + # A count reported as text must not abort the close. + ({"input_tokens": "ten"}, 0, 0), + (None, 0, 0), + ("not a dict", 0, 0), + ), +) +def test_token_usage_reads_every_provider_spelling(usage: Any, expected_input: int, expected_output: int) -> None: + built = _hooks._build_token_usage(usage) + assert built.input_tokens == expected_input + assert built.output_tokens == expected_output + + +@pytest.mark.parametrize( + ("key", "attribute"), + ( + ("cache_read_tokens", "cache_read_input_tokens"), + ("cache_read_input_tokens", "cache_read_input_tokens"), + ("cache_write_tokens", "cache_write_input_tokens"), + ("cache_creation_input_tokens", "cache_write_input_tokens"), + ("cache_write_input_tokens", "cache_write_input_tokens"), + ("reasoning_tokens", "reasoning_tokens"), + ("total_tokens", "total_tokens"), + ), +) +def test_cache_and_reasoning_counts_map_to_one_field_each(key: str, attribute: str) -> None: + assert getattr(_hooks._build_token_usage({key: 7}), attribute) == 7 + + +# --- system prompt splitting --- + + +@pytest.mark.parametrize("messages", (None, "a string", 7)) +def test_splitting_a_non_list_yields_nothing(messages: Any) -> None: + assert _hooks._split_system_prompt(messages) == ("", []) + + +def test_multiple_system_messages_join_into_one_prompt() -> None: + prompt, rest = _hooks._split_system_prompt( + [ + {"role": "system", "content": "first"}, + "not a dict", + {"role": "system", "content": ""}, + {"role": "system", "content": "second"}, + {"role": "user", "content": "hi"}, + ] + ) + assert prompt == "first\n\nsecond" + assert rest == [{"role": "user", "content": "hi"}] + + +# --- span context lookup --- + + +def test_a_recorder_without_a_span_has_no_context() -> None: + assert _hooks._span_context_of(object()) is None + + +def test_a_span_that_cannot_answer_loses_the_link_and_not_the_generation() -> None: + class Hostile: + @property + def span(self) -> Any: + raise RuntimeError("no span for you") + + assert _hooks._span_context_of(Hostile()) is None + + +# --- json round trips used by the legacy convo path --- + + +def test_serialized_arguments_survive_a_round_trip() -> None: + """The legacy path re-encodes tool arguments before storing them.""" + calls = _hooks._serialize_tool_calls([{"id": "c1", "function": {"name": "bash", "arguments": '{"a": 1}'}}]) + assert json.dumps(calls[0]["arguments"]) == '{"a": 1}' diff --git a/plugins/hermes/tests/test_otel_autosetup.py b/plugins/hermes/tests/test_otel_autosetup.py new file mode 100644 index 000000000..4fb36bcc0 --- /dev/null +++ b/plugins/hermes/tests/test_otel_autosetup.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest +from opentelemetry import metrics, trace +from opentelemetry.metrics._internal import _ProxyMeterProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.trace import ProxyTracerProvider + +from grafana_agento11y_hermes import _client, _config, _otel + + +@pytest.fixture(autouse=True) +def reset_otel(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + _client._reset_for_tests() + # Force a fresh ProxyTracerProvider for isolation. Safe: opentelemetry + # exposes the proxy as a no-op default that downstream code tolerates. + monkeypatch.setattr(trace, "_TRACER_PROVIDER", None, raising=False) + monkeypatch.setattr( + trace, + "_TRACER_PROVIDER_SET_ONCE", + trace._TRACER_PROVIDER_SET_ONCE.__class__(), + raising=False, + ) + monkeypatch.setattr(metrics._internal, "_METER_PROVIDER", None, raising=False) + monkeypatch.setattr( + metrics._internal, + "_METER_PROVIDER_SET_ONCE", + metrics._internal._METER_PROVIDER_SET_ONCE.__class__(), + raising=False, + ) + yield + _client._reset_for_tests() + + +def _proxy_tracer_active() -> bool: + return isinstance(trace.get_tracer_provider(), ProxyTracerProvider) + + +def _proxy_meter_active() -> bool: + return isinstance(metrics.get_meter_provider(), _ProxyMeterProvider) + + +@pytest.fixture +def otel_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Standard OTLP env — exporters read these themselves.""" + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost/otlp") + monkeypatch.setenv( + "OTEL_EXPORTER_OTLP_HEADERS", + "Authorization=Basic c3RhY2stMTpnbGNfb3RscF9zZWNyZXQ=", + ) + + +def _make_cfg(*, otel_auto: bool = True, otel_configured: bool = True) -> _config.PluginConfig: + return _config.PluginConfig( + otel_auto=otel_auto, + otel_configured=otel_configured, + ) + + +def test_auto_setup_installs_both_providers_when_proxies_are_global(otel_env) -> None: + assert _proxy_tracer_active(), "test fixture should leave a ProxyTracerProvider in place" + assert _proxy_meter_active(), "test fixture should leave a proxy MeterProvider in place" + cfg = _make_cfg(otel_auto=True) + ok = _otel.setup_if_needed(cfg) + assert ok is True + assert isinstance(trace.get_tracer_provider(), TracerProvider) + assert isinstance(metrics.get_meter_provider(), MeterProvider) + + +def test_auto_setup_is_idempotent(otel_env) -> None: + cfg = _make_cfg(otel_auto=True) + _otel.setup_if_needed(cfg) + first_tracer = trace.get_tracer_provider() + first_meter = metrics.get_meter_provider() + _otel.setup_if_needed(cfg) + assert trace.get_tracer_provider() is first_tracer + assert metrics.get_meter_provider() is first_meter + + +def test_auto_setup_skipped_when_user_has_both_providers(otel_env) -> None: + custom_tracer = TracerProvider() + custom_meter = MeterProvider() + trace.set_tracer_provider(custom_tracer) + metrics.set_meter_provider(custom_meter) + cfg = _make_cfg(otel_auto=True) + ok = _otel.setup_if_needed(cfg) + assert ok is True + assert trace.get_tracer_provider() is custom_tracer + assert metrics.get_meter_provider() is custom_meter + + +def test_auto_setup_installs_only_missing_provider(otel_env) -> None: + custom_tracer = TracerProvider() + trace.set_tracer_provider(custom_tracer) + cfg = _make_cfg(otel_auto=True) + ok = _otel.setup_if_needed(cfg) + assert ok is True + assert trace.get_tracer_provider() is custom_tracer + assert isinstance(metrics.get_meter_provider(), MeterProvider) + + +def test_auto_setup_disabled_returns_false_with_proxies(otel_env) -> None: + cfg = _make_cfg(otel_auto=False) + ok = _otel.setup_if_needed(cfg) + assert ok is False + assert _proxy_tracer_active(), "no provider must be installed when auto is disabled" + assert _proxy_meter_active(), "no meter provider must be installed when auto is disabled" + + +def test_auto_setup_disabled_uses_existing_user_providers(otel_env) -> None: + custom_tracer = TracerProvider() + custom_meter = MeterProvider() + trace.set_tracer_provider(custom_tracer) + metrics.set_meter_provider(custom_meter) + cfg = _make_cfg(otel_auto=False) + ok = _otel.setup_if_needed(cfg) + assert ok is True + assert trace.get_tracer_provider() is custom_tracer + assert metrics.get_meter_provider() is custom_meter + + +def test_no_otel_env_is_no_op_for_otel() -> None: + cfg = _make_cfg(otel_configured=False) + ok = _otel.setup_if_needed(cfg) + assert ok is False + assert _proxy_tracer_active() + assert _proxy_meter_active() + + +def test_default_service_name_is_hermes(otel_env, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OTEL_SERVICE_NAME", raising=False) + monkeypatch.delenv("OTEL_RESOURCE_ATTRIBUTES", raising=False) + + cfg = _make_cfg(otel_auto=True) + _otel.setup_if_needed(cfg) + + provider = trace.get_tracer_provider() + assert isinstance(provider, TracerProvider) + assert provider.resource.attributes.get("service.name") == "hermes" + + +def test_otel_service_name_env_wins(otel_env, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OTEL_SERVICE_NAME", "my-app") + + cfg = _make_cfg(otel_auto=True) + _otel.setup_if_needed(cfg) + + provider = trace.get_tracer_provider() + assert isinstance(provider, TracerProvider) + assert provider.resource.attributes.get("service.name") == "my-app" + + +# --- OTLP auth-header fallback --- + +_FALLBACK = {"Authorization": "Basic c3RhY2stMTpnbGNfc2VjcmV0", "X-Scope-OrgID": "stack-1"} + + +def test_exporter_headers_none_without_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OTEL_EXPORTER_OTLP_HEADERS", raising=False) + monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", raising=False) + assert _otel._exporter_headers("OTEL_EXPORTER_OTLP_TRACES_HEADERS", {}) is None + + +def test_exporter_headers_returns_copy_of_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OTEL_EXPORTER_OTLP_HEADERS", raising=False) + monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", raising=False) + out = _otel._exporter_headers("OTEL_EXPORTER_OTLP_TRACES_HEADERS", _FALLBACK) + assert out == _FALLBACK + assert out is not _FALLBACK + + +def test_exporter_headers_suppressed_by_generic_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OTEL_EXPORTER_OTLP_HEADERS", "Authorization=Basic xyz") + monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", raising=False) + assert _otel._exporter_headers("OTEL_EXPORTER_OTLP_TRACES_HEADERS", _FALLBACK) is None + + +def test_exporter_headers_suppressed_by_signal_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OTEL_EXPORTER_OTLP_HEADERS", raising=False) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", "Authorization=Basic xyz") + assert _otel._exporter_headers("OTEL_EXPORTER_OTLP_TRACES_HEADERS", _FALLBACK) is None + + +def _capture_exporters(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + """Patch the OTLP exporters with subclasses that record their kwargs.""" + import opentelemetry.exporter.otlp.proto.http.metric_exporter as me + import opentelemetry.exporter.otlp.proto.http.trace_exporter as te + + captured: dict[str, Any] = {} + + class CapSpan(te.OTLPSpanExporter): + def __init__(self, *a: Any, **kw: Any) -> None: + captured["span"] = kw.get("headers") + captured["span_endpoint"] = kw.get("endpoint") + super().__init__(*a, **kw) + + class CapMetric(me.OTLPMetricExporter): + def __init__(self, *a: Any, **kw: Any) -> None: + captured["metric"] = kw.get("headers") + captured["metric_endpoint"] = kw.get("endpoint") + super().__init__(*a, **kw) + + monkeypatch.setattr(te, "OTLPSpanExporter", CapSpan) + monkeypatch.setattr(me, "OTLPMetricExporter", CapMetric) + return captured + + +def test_fallback_headers_passed_to_exporters(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost/otlp") + for var in ( + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_METRICS_HEADERS", + ): + monkeypatch.delenv(var, raising=False) + captured = _capture_exporters(monkeypatch) + + cfg = _config.PluginConfig(otel_auto=True, otel_configured=True, otel_auth_headers=dict(_FALLBACK)) + assert _otel.setup_if_needed(cfg) is True + assert captured["span"] == _FALLBACK + assert captured["metric"] == _FALLBACK + + +def test_user_headers_env_suppresses_fallback(otel_env, monkeypatch: pytest.MonkeyPatch) -> None: + """otel_env sets OTEL_EXPORTER_OTLP_HEADERS, so the fallback must not apply.""" + captured = _capture_exporters(monkeypatch) + + cfg = _config.PluginConfig(otel_auto=True, otel_configured=True, otel_auth_headers=dict(_FALLBACK)) + assert _otel.setup_if_needed(cfg) is True + assert captured["span"] is None + assert captured["metric"] is None + + +# --- branded OTLP endpoint --- + + +def test_standard_endpoint_is_left_to_the_exporters(otel_env, monkeypatch: pytest.MonkeyPatch) -> None: + captured = _capture_exporters(monkeypatch) + + assert _otel.setup_if_needed(_make_cfg()) is True + assert captured["span_endpoint"] is None + assert captured["metric_endpoint"] is None + + +def test_branded_endpoint_is_passed_per_signal(monkeypatch: pytest.MonkeyPatch) -> None: + """The branded alias is the one name the exporters cannot read themselves.""" + monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False) + monkeypatch.setenv("AGENTO11Y_OTEL_EXPORTER_OTLP_ENDPOINT", "https://otlp.example/otlp") + captured = _capture_exporters(monkeypatch) + + cfg = _config.load() + assert cfg.otel_configured is True + assert _otel.setup_if_needed(cfg) is True + assert captured["span_endpoint"] == "https://otlp.example/otlp/v1/traces" + assert captured["metric_endpoint"] == "https://otlp.example/otlp/v1/metrics" + + +@pytest.mark.parametrize( + ("base", "expected"), + [ + ("https://otlp.example/otlp", "https://otlp.example/otlp/v1/traces"), + ("https://otlp.example/otlp/", "https://otlp.example/otlp/v1/traces"), + ("https://otlp.example/otlp///", "https://otlp.example/otlp/v1/traces"), + ], +) +def test_signal_endpoint_normalizes_trailing_slashes(base: str, expected: str) -> None: + assert _otel._signal_endpoint(base, "/v1/traces") == expected + + +def test_auth_source_reflects_what_was_passed() -> None: + """The log suffix tracks the headers kwarg, not whether credentials exist.""" + assert _otel._auth_source(True) == " (auth from AGENTO11Y_AUTH_*)" + assert _otel._auth_source(False) == "" + + +def test_install_reports_derived_auth_only_when_headers_are_used(otel_env, monkeypatch: pytest.MonkeyPatch) -> None: + """otel_env sets OTEL_EXPORTER_OTLP_HEADERS, so the derived headers lose.""" + _capture_exporters(monkeypatch) + cfg = _config.PluginConfig(otel_auto=True, otel_configured=True, otel_auth_headers=dict(_FALLBACK)) + + _, derived = _otel._install_tracer_provider(cfg) + assert derived is False + + monkeypatch.delenv("OTEL_EXPORTER_OTLP_HEADERS", raising=False) + _, derived = _otel._install_tracer_provider(cfg) + assert derived is True + + +def test_a_provider_that_cannot_be_built_disables_the_channel( + otel_env, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """An unbuildable exporter is a no-op channel, never a raise into hermes.""" + import logging + + def explode(*_: Any, **__: Any) -> Any: + raise RuntimeError("exporter refused to build") + + monkeypatch.setattr(_otel, "_install_tracer_provider", explode) + + with caplog.at_level(logging.WARNING): + assert _otel.setup_if_needed(_make_cfg()) is False + + assert [r for r in caplog.records if "failed to set up OTel providers" in r.getMessage()] + assert _otel._INSTALLED_TRACER_PROVIDER is None + assert _proxy_tracer_active(), "a failed install must not leave a half-wired provider" + + +def test_a_failed_setup_is_not_retried(otel_env, monkeypatch: pytest.MonkeyPatch) -> None: + attempts: list[int] = [] + + def explode(*_: Any, **__: Any) -> Any: + attempts.append(1) + raise RuntimeError("exporter refused to build") + + monkeypatch.setattr(_otel, "_install_tracer_provider", explode) + + _otel.setup_if_needed(_make_cfg()) + _otel.setup_if_needed(_make_cfg()) + + assert len(attempts) == 1, "setup is idempotent, including after a failure" diff --git a/plugins/hermes/tests/test_package_checks.py b/plugins/hermes/tests/test_package_checks.py new file mode 100644 index 000000000..fb172b895 --- /dev/null +++ b/plugins/hermes/tests/test_package_checks.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import io +import json +import os +import runpy +import subprocess +import sys +import tarfile +import zipfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +CHECKS = runpy.run_path(str(ROOT / "scripts/check-package.py")) + + +@pytest.mark.parametrize("kind", ["wheel", "sdist"]) +@pytest.mark.parametrize("version,valid", [("0.10.0", True), ("0.9.0", False)]) +def test_artifact_version(tmp_path, kind, version, valid): + data = f"Name: grafana-agento11y-hermes\nVersion: {version}\n".encode() + if kind == "wheel": + artifact = tmp_path / "plugin.whl" + with zipfile.ZipFile(artifact, "w") as wheel: + wheel.writestr("grafana_agento11y_hermes.dist-info/METADATA", data) + else: + artifact = tmp_path / "plugin.tar.gz" + with tarfile.open(artifact, "w:gz") as sdist: + entry = tarfile.TarInfo("grafana_agento11y_hermes/PKG-INFO") + entry.size = len(data) + sdist.addfile(entry, io.BytesIO(data)) + if valid: + CHECKS["check_metadata"](artifact, "0.10.0") + else: + with pytest.raises(ValueError, match="Artifact version"): + CHECKS["check_metadata"](artifact, "0.10.0") + + +def test_check_runner_clears_credentials(tmp_path): + uv = tmp_path / "uv" + uv.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\n" + "if sys.argv[2:4] in (['cache', 'dir'], ['python', 'dir']):\n" + " print('/tmp')\n" + "else:\n" + " print(json.dumps({'env': dict(os.environ), 'args': sys.argv[1:]}))\n" + ) + uv.chmod(0o755) + result = subprocess.run( + ["bash", str(ROOT / "scripts/run-check.sh"), "test", "3.12"], + env={ + **os.environ, + "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}", + "HOME": str(tmp_path), + "AGENTO11Y_AUTH_TOKEN": "dummy-token", + "SIGIL_AUTH_TOKEN": "dummy-legacy-token", + "OPENAI_API_KEY": "dummy-provider-key", + "ANTHROPIC_API_KEY": "dummy-provider-key", + "OTEL_EXPORTER_OTLP_ENDPOINT": "https://unused.invalid", + "OTEL_EXPORTER_OTLP_HEADERS": "dummy-header", + "PYTHONPATH": "dummy-source-path", + "UV_ENV_FILE": "dummy-env-file", + }, + text=True, + capture_output=True, + check=True, + ) + captured = json.loads(result.stdout) + env = captured["env"] + assert env["HOME"] != str(tmp_path) + assert not Path(env["HOME"]).exists() + assert not any(key.startswith(("AGENTO11Y_", "SIGIL_", "OTEL_", "OPENAI_", "ANTHROPIC_")) for key in env) + assert "PYTHONPATH" not in env + assert "UV_ENV_FILE" not in env + assert captured["args"] == [ + "--no-config", + "run", + "--locked", + "--isolated", + "--no-env-file", + "--python", + "3.12", + "python", + "-m", + "pytest", + "--cov", + ] diff --git a/plugins/hermes/tests/test_privacy.py b/plugins/hermes/tests/test_privacy.py new file mode 100644 index 000000000..02dbc1a86 --- /dev/null +++ b/plugins/hermes/tests/test_privacy.py @@ -0,0 +1,185 @@ +"""Exercise privacy through real SDK recorders and in-memory exporters.""" + +from __future__ import annotations + +import copy +from dataclasses import asdict +from typing import Any + +import pytest +from agento11y import Client, ContentCaptureMode +from agento11y.models import ExportGenerationResult, ExportGenerationsResponse +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from grafana_agento11y_hermes import _client, _compat, _config, _hooks, _redact + +SECRET = "glc_abcdefghijklmnopqrstuvwxyz1234" + + +class MemoryExporter: + def __init__(self) -> None: + self.generations: list[Any] = [] + + def export_generations(self, request: Any) -> ExportGenerationsResponse: + self.generations.extend(copy.deepcopy(request.generations)) + return ExportGenerationsResponse( + results=[ExportGenerationResult(generation_id=g.id, accepted=True) for g in request.generations] + ) + + def shutdown(self) -> None: + pass + + +@pytest.mark.parametrize( + ("canonical", "legacy", "expected"), + [ + (None, None, "metadata_only"), + ("", None, "metadata_only"), + (" ", None, "metadata_only"), + ("default", None, "metadata_only"), + ("invalid", None, "metadata_only"), + (" FULL ", None, "full"), + ("no_tool_content", None, "no_tool_content"), + ("full_with_metadata_spans", None, "full_with_metadata_spans"), + (None, "full", "full"), + (" ", "full", "full"), + ("metadata_only", "full", "metadata_only"), + ("invalid", "full", "metadata_only"), + ], +) +def test_capture_mode_table(monkeypatch, canonical, legacy, expected) -> None: + for key, value in (("AGENTO11Y_CONTENT_CAPTURE_MODE", canonical), ("SIGIL_CONTENT_CAPTURE_MODE", legacy)): + if value is not None: + monkeypatch.setenv(key, value) + assert _client._to_client_config(_config.PluginConfig()).content_capture == ContentCaptureMode(expected) + + +@pytest.mark.parametrize("mode", [None, "full", "no_tool_content", "full_with_metadata_spans"]) +@pytest.mark.parametrize( + ("redact_setting", "redact_inputs"), + [(None, True), ("", True), ("invalid", True), ("true", True), ("false", False), (" OFF ", False)], +) +@pytest.mark.parametrize("secret", [SECRET, "ghp_" + "a" * 36]) +def test_real_sdk_privacy(monkeypatch, mode, redact_setting, redact_inputs, secret) -> None: + if mode is not None: + monkeypatch.setenv("AGENTO11Y_CONTENT_CAPTURE_MODE", mode) + if redact_setting is not None: + monkeypatch.setenv("AGENTO11Y_REDACT_INPUT_MESSAGES", redact_setting) + spans = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(spans)) + exports = MemoryExporter() + plugin_config = _config.PluginConfig(generations_configured=True, error_flush_timeout=0) + config = _client._to_client_config(plugin_config) + config.tracer = provider.get_tracer("hermes-privacy-test") + config.generation_exporter = exports + client = Client(config) + monkeypatch.setattr(_client, "_CLIENT", client) + monkeypatch.setattr(_client, "_CONFIG", plugin_config) + history = [ + {"role": "user", "content": f"user {secret}"}, + { + "role": "assistant", + "tool_calls": [{"id": secret, "function": {"name": secret, "arguments": {"secret": secret}}}], + }, + {"role": "tool", "tool_call_id": secret, "content": secret}, + ] + request = {"body": {"tools": [{"name": "read", "description": secret, "input_schema": {"default": secret}}]}} + original = copy.deepcopy(request) + try: + _hooks.on_pre_api_request( + api_request_id="request-1", + session_id="session", + task_id=secret, + model="test-model", + provider="test-provider", + conversation_history=history, + system_prompt=f"system {secret}", + request=request, + ) + _hooks.on_post_api_request( + api_request_id="request-1", + assistant_message={"role": "assistant", "content": f"assistant {secret}; sort key: name"}, + usage={"input_tokens": 10, "output_tokens": 2}, + ) + _hooks.on_post_tool_call( + api_request_id="request-1", + session_id="session", + tool_name="read", + tool_call_id="call-1", + args={"token": "unstructured-credential", "value": secret}, + result={"password": "unstructured-credential", "value": secret}, + status="error", + error_message=f"{secret}; sort key: name", + ) + _hooks.on_pre_api_request(api_request_id="request-2", session_id="session", model="test-model") + _hooks.on_api_request_error( + api_request_id="request-2", error={"type": secret, "message": f"{secret}; sort key: name"} + ) + client.flush() + assert len(exports.generations) == 2 + generation = exports.generations[0] + assert generation.usage.input_tokens == 10 + assert generation.metadata["hermes.task_id"] != secret + serialized = repr(asdict(generation)) + if mode is None: + assert all(not part.text for message in generation.input + generation.output for part in message.parts) + assert all(not tool.description and not tool.input_schema_json for tool in generation.tools) + assert not generation.system_prompt + assert secret not in serialized + else: + assert generation.output and generation.system_prompt + assert secret not in repr(generation.output) + assert "sort key: name" in repr(generation.output) + assert "sort key: name" in exports.generations[1].call_error + assert secret not in repr(generation.tools) + assert (secret not in repr(generation.input)) == redact_inputs + assert secret not in repr(asdict(exports.generations[1])) + finished = spans.get_finished_spans() + assert len(finished) == 3 + if redact_inputs or mode in (None, "full_with_metadata_spans"): + for span in finished: + assert secret not in repr(dict(span.attributes or {})) + assert secret not in repr([dict(event.attributes or {}) for event in span.events]) + assert secret not in str(span.status.description) + tool = next(span for span in finished if span.name.startswith("execute_tool")) + assert secret not in repr(dict(tool.attributes or {})) + assert "unstructured-credential" not in repr(dict(tool.attributes or {})) + assert secret not in repr([dict(event.attributes or {}) for event in tool.events]) + assert secret not in str(tool.status.description) + if mode == "full": + assert "[REDACTED" in repr(dict(tool.attributes or {})) + assert "sort key: name" in str(tool.status.description) + else: + assert "gen_ai.tool.call.arguments" not in (tool.attributes or {}) + assert request == original + assert history[0]["content"] == f"user {secret}" + finally: + client.shutdown() + provider.shutdown() + + +def test_redact_before_truncation_and_copy() -> None: + original = {SECRET: [SECRET, {"password": "DATABASE_PASSWORD=long-secret-value"}]} + result = _redact.safe_value(original, max_chars=12) + assert SECRET[:12] not in repr(result) + assert SECRET in original + assert _redact.redact_record(b"token=" + SECRET.encode()) != b"token=" + SECRET.encode() + + +@pytest.mark.parametrize("new", ["", " ", "new"]) +def test_legacy_alias_precedence(new) -> None: + env = {"SIGIL_ENDPOINT": "primary", "SIGIL_API_ENDPOINT": "secondary", "AGENTO11Y_ENDPOINT": new} + _compat.apply_legacy_env(env) + assert env["AGENTO11Y_ENDPOINT"] == (new if new.strip() else "primary") + assert env["SIGIL_ENDPOINT"] == "primary" + + +@pytest.mark.parametrize("suffix", ["AUTH_TOKEN", "CONTENT_CAPTURE_MODE", "HEADERS", "HERMES_ERROR_FLUSH_TIMEOUT"]) +def test_legacy_values_are_not_logged(suffix, caplog) -> None: + env = {f"SIGIL_{suffix}": SECRET} + _compat.apply_legacy_env(env) + assert SECRET not in caplog.text + assert env[f"AGENTO11Y_{suffix}"] == SECRET diff --git a/plugins/hermes/tests/test_redact.py b/plugins/hermes/tests/test_redact.py new file mode 100644 index 000000000..954633c94 --- /dev/null +++ b/plugins/hermes/tests/test_redact.py @@ -0,0 +1,147 @@ +"""Tests for the structural payload redactor.""" + +from __future__ import annotations + +import pytest + +from grafana_agento11y_hermes import _redact + + +def test_truncate_long_string_uses_caller_max_chars() -> None: + long = "a" * 20000 + result = _redact.safe_value(long, max_chars=12000) + assert result.startswith("a" * 12000) + assert "truncated" in result + + +def test_short_max_chars_truncates_aggressively() -> None: + result = _redact.safe_value("a" * 50, max_chars=10) + assert result.startswith("a" * 10) + assert "truncated" in result + + +def test_max_chars_kwarg_is_required() -> None: + # The signature mandates max_chars; calling without it must error. + try: + _redact.safe_value("hi") # ty: ignore[missing-argument] + except TypeError: + return + raise AssertionError("safe_value should require max_chars") + + +def test_depth_limit_caps_at_4() -> None: + # depth > 4 returns sentinel — top-level dict is depth 0, so the value at l5 + # is encountered at depth 5 and replaced. + nested = {"l1": {"l2": {"l3": {"l4": {"l5": "deep"}}}}} + out = _redact.safe_value(nested, max_chars=12000) + cur = out + for level in ("l1", "l2", "l3", "l4", "l5"): + cur = cur[level] + assert cur == "" + + +def test_within_depth_limit_preserves_values() -> None: + nested = {"l1": {"l2": {"l3": "ok"}}} + out = _redact.safe_value(nested, max_chars=12000) + assert out == {"l1": {"l2": {"l3": "ok"}}} + + +def test_dict_entry_cap_is_50() -> None: + big_dict = {f"k{i}": i for i in range(200)} + out = _redact.safe_value(big_dict, max_chars=12000) + assert len(out) == 50 + + +def test_list_entry_cap_is_50() -> None: + big_list = list(range(200)) + out = _redact.safe_value(big_list, max_chars=12000) + assert len(out) == 50 + assert out[0] == 0 + assert out[-1] == 49 + + +def test_scalars_pass_through_unchanged() -> None: + assert _redact.safe_value(None, max_chars=12000) is None + assert _redact.safe_value(42, max_chars=12000) == 42 + assert _redact.safe_value(3.14, max_chars=12000) == 3.14 + assert _redact.safe_value(True, max_chars=12000) is True + + +def test_bytes_become_descriptor() -> None: + out = _redact.safe_value(b"hello", max_chars=12000) + assert out == {"type": "bytes", "len": 5} + + +def test_parse_json_strings_when_requested() -> None: + s = '{"a": 1, "b": [1, 2, 3]}' + out = _redact.safe_value(s, max_chars=12000, parse_json_strings=True) + assert out == {"a": 1, "b": [1, 2, 3]} + + +def test_unparseable_json_string_returned_as_string() -> None: + s = "not json {{" + out = _redact.safe_value(s, max_chars=12000, parse_json_strings=True) + assert out == "not json {{" + + +def test_a_string_over_max_chars_is_never_parsed_as_json() -> None: + """The guard exists so a multi-megabyte tool result is not decoded whole.""" + big = '{"a": "' + "x" * 200 + '"}' + out = _redact.safe_value(big, max_chars=50, parse_json_strings=True) + assert isinstance(out, str), "over the cap it stays a string and gets truncated" + assert "truncated" in out + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + # A hermes tool result is often JSON with a hint glued onto the end. + ('{"ok": true} [Hint: run tests next]', {"ok": True, "_hint": "[Hint: run tests next]"}), + ('{"ok": true} see also the log', {"ok": True, "_trailing_text": "see also the log"}), + # A top-level list is wrapped so the trailing text has somewhere to go. + ("[1, 2] [Hint: more]", {"data": [1, 2], "_hint": "[Hint: more]"}), + ("[1, 2] tail", {"data": [1, 2], "_trailing_text": "tail"}), + # The payload already owning the key must not have it overwritten. + ('{"_hint": "mine"} [Hint: theirs]', {"_hint": "mine", "_trailing_text": "[Hint: theirs]"}), + # Only whitespace after the document is not trailing text. + ('{"ok": true} \n', {"ok": True}), + (r'{"token": "abc\"def"}', "[REDACTED:invalid-json]"), + ( + '{"token": "plain-secret"} [Hint: retry]', + {"token": "[REDACTED:json-secret-field]", "_hint": "[Hint: retry]"}, + ), + ), +) +def test_text_trailing_a_json_document_is_kept_beside_it(value: str, expected: object) -> None: + assert _redact.safe_value(value, max_chars=12000, parse_json_strings=True) == expected + + +def test_the_parse_cap_is_measured_on_the_whole_document() -> None: + """The boundary the guard draws: at the cap it parses, one char over it does not.""" + doc = '{"a": "' + "x" * 20 + '"}' + assert isinstance(_redact.safe_value(doc, max_chars=len(doc), parse_json_strings=True), dict) + assert isinstance(_redact.safe_value(doc, max_chars=len(doc) - 1, parse_json_strings=True), str) + + +def test_a_set_is_recorded_as_a_list() -> None: + out = _redact.safe_value({"tags"}, max_chars=12000) + assert out == ["tags"] + + +def test_an_object_with_attributes_is_recorded_as_its_dict() -> None: + class Thing: + def __init__(self) -> None: + self.name = "x" + + assert _redact.safe_value(Thing(), max_chars=12000) == {"name": "x"} + + +def test_object_without_dict_attribute_falls_back_to_repr() -> None: + class Opaque: + __slots__ = () + + def __repr__(self) -> str: + return "" + + out = _redact.safe_value(Opaque(), max_chars=12000) + assert out == "" diff --git a/plugins/hermes/tests/test_register.py b/plugins/hermes/tests/test_register.py new file mode 100644 index 000000000..073f5a75d --- /dev/null +++ b/plugins/hermes/tests/test_register.py @@ -0,0 +1,32 @@ +"""Verify register(ctx) binds exactly the expected hooks.""" + +from __future__ import annotations + +import grafana_agento11y_hermes + +EXPECTED_HOOKS = { + "pre_llm_call", + "post_llm_call", + "pre_api_request", + "post_api_request", + "api_request_error", + "post_tool_call", + "on_session_end", + "on_session_finalize", +} + + +def test_register_binds_exactly_expected_hooks(ctx) -> None: + grafana_agento11y_hermes.register(ctx) + assert set(ctx.hooks.keys()) == EXPECTED_HOOKS + + +def test_register_handlers_are_callable(ctx) -> None: + grafana_agento11y_hermes.register(ctx) + for name, handler in ctx.hooks.items(): + assert callable(handler), f"handler for {name} is not callable" + + +def test_register_does_not_bind_session_start(ctx) -> None: + grafana_agento11y_hermes.register(ctx) + assert "on_session_start" not in ctx.hooks diff --git a/plugins/hermes/tests/test_request_facts.py b/plugins/hermes/tests/test_request_facts.py new file mode 100644 index 000000000..7d197cde0 --- /dev/null +++ b/plugins/hermes/tests/test_request_facts.py @@ -0,0 +1,301 @@ +"""Reading the provider request that ``pre_api_request`` carries. + +The bodies here are the literal provider payloads hermes builds per +``api_mode``, and the degraded ones are what its payload sanitizer leaves +behind once ``HERMES_PLUGIN_PAYLOAD_MAX_CHARS`` is crossed. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +import pytest + +from grafana_agento11y_hermes import _request + +ANTHROPIC_TOOL = {"name": "read_file", "description": "d", "input_schema": {"type": "object"}} +OPENAI_TOOL = {"type": "function", "function": {"name": "read_file", "parameters": {"type": "object"}}} +RESPONSES_TOOL = {"type": "function", "name": "read_file", "parameters": {"type": "object"}} + + +@pytest.mark.parametrize( + ("body", "kwargs", "expected"), + [ + # anthropic_messages, plain string. + ({"system": "be helpful"}, {}, "be helpful"), + # anthropic_messages under cache_control or OAuth: a content-block list. + ( + {"system": [{"type": "text", "text": "be helpful", "cache_control": {"type": "ephemeral"}}]}, + {}, + "be helpful", + ), + # bedrock_converse: blocks with no ``type`` key. + ({"system": [{"text": "be helpful"}]}, {}, "be helpful"), + # codex_responses. + ({"instructions": "be helpful"}, {}, "be helpful"), + # chat_completions keeps it in the message list, not on the body. + ({}, {"request_messages": [{"role": "system", "content": "be helpful"}]}, "be helpful"), + # GPT-5 and Codex models take it as ``developer``. + ({}, {"request_messages": [{"role": "developer", "content": "be helpful"}]}, "be helpful"), + # A leading user message is not a system prompt. + ({}, {"request_messages": [{"role": "user", "content": "hi"}]}, ""), + # The post-0.20.1 kwarg is unclipped, so it beats the sanitized body. + ( + {"system": "clipped...[truncated 900 chars]"}, + {"system_prompt": "full text"}, + "full text", + ), + ({}, {}, ""), + ], +) +def test_system_prompt_is_read_from_every_api_mode_shape(body: dict, kwargs: dict[str, Any], expected: str) -> None: + facts = _request.parse({"method": "POST", "body": body}, **kwargs) + + assert facts.system_prompt == expected + assert not facts.truncated + + +@pytest.mark.parametrize( + ("tools", "expected_names"), + [ + ([ANTHROPIC_TOOL], ["read_file"]), + ([OPENAI_TOOL], ["read_file"]), + ([RESPONSES_TOOL], ["read_file"]), + ([ANTHROPIC_TOOL, RESPONSES_TOOL], ["read_file", "read_file"]), + # The sentinel a clipped list ends with has no name to attribute a call + # to, so the SDK mapper drops it. + ([ANTHROPIC_TOOL, {"_truncated_items": 3}], ["read_file"]), + ([], []), + (None, []), + ], +) +def test_tool_definitions_cover_the_three_schema_shapes(tools: Any, expected_names: list[str]) -> None: + facts = _request.parse({"method": "POST", "body": {"tools": tools}}) + + assert [tool.name for tool in facts.tools] == expected_names + + +@pytest.mark.parametrize("tool", [ANTHROPIC_TOOL, OPENAI_TOOL, RESPONSES_TOOL]) +def test_the_input_schema_survives_the_mapping(tool: dict) -> None: + facts = _request.parse({"method": "POST", "body": {"tools": [tool]}}) + + assert json.loads(facts.tools[0].input_schema_json) == {"type": "object"} + + +@pytest.mark.parametrize( + "request_payload", + [ + # The whole envelope replaced once the payload is still over the cap. + {"_truncated": True, "original_type": "dict", "preview": "{'model': 'claude"}, + # No ``request`` kwarg at all. + None, + # A body that is not the provider mapping. + {"method": "POST", "body": "clipped...[truncated 40000 chars]"}, + {"method": "POST"}, + ], +) +def test_a_degraded_payload_reports_itself_rather_than_raising(request_payload: Any) -> None: + facts = _request.parse(request_payload) + + assert facts.truncated + assert facts.system_prompt == "" + assert facts.tools == [] + assert facts.max_tokens is None + + +def test_the_unsanitized_kwargs_still_read_through_a_collapsed_envelope() -> None: + """Hermes clips the body but passes these two as it built them.""" + facts = _request.parse( + {"_truncated": True, "preview": "..."}, + request_messages=[{"role": "system", "content": "be helpful"}], + ) + + assert facts.system_prompt == "be helpful" + assert facts.truncated, "the tool schemas are still lost" + + +@pytest.mark.parametrize( + ("body", "expected"), + [ + # anthropic_messages. + ({"max_tokens": 8192, "temperature": 0.7, "top_p": 0.95}, (8192, 0.7, 0.95)), + ({"max_tokens": "8192", "temperature": "0.7"}, (8192, 0.7, None)), + # The OpenAI routes that reject ``max_tokens``: direct OpenAI, Azure, + # GitHub Copilot, and every gpt-4o / gpt-4.1 / gpt-5 / o-series model. + ({"max_completion_tokens": 8192}, (8192, None, None)), + # codex_responses. + ({"max_output_tokens": 4096}, (4096, None, None)), + # bedrock_converse nests all three. + ({"inferenceConfig": {"maxTokens": 4096, "temperature": 0.2, "topP": 0.9}}, (4096, 0.2, 0.9)), + # Zero is a setting, not an absence. + ({"temperature": 0.0}, (None, 0.0, None)), + ({"inferenceConfig": {"temperature": 0.0}}, (None, 0.0, None)), + # A cap is the exception: zero would cap the response at nothing, and + # hermes's own reader skips it rather than reporting it. + ({"max_tokens": 0}, (None, None, None)), + ({"max_tokens": -1}, (None, None, None)), + # An unusable cap falls through to the next name, as it does in hermes. + ({"max_tokens": 0, "max_completion_tokens": 8192}, (8192, None, None)), + ({"max_tokens": "warm", "max_output_tokens": 4096}, (4096, None, None)), + ({}, (None, None, None)), + ({"max_tokens": None, "temperature": "warm", "top_p": []}, (None, None, None)), + ], +) +def test_sampling_params_are_read_under_every_route_name(body: dict, expected: tuple) -> None: + facts = _request.parse({"method": "POST", "body": body}) + + assert (facts.max_tokens, facts.temperature, facts.top_p) == expected + + +def test_bedrock_tools_are_read_through_the_toolspec_envelope() -> None: + """Converse wraps the Anthropic shape twice; unwrapped, the mapper reads it.""" + body = { + "toolConfig": { + "tools": [ + { + "toolSpec": { + "name": "read_file", + "description": "read a file", + "inputSchema": {"json": {"type": "object"}}, + } + }, + {"_truncated_items": 3}, + ] + } + } + + facts = _request.parse({"method": "POST", "body": body}) + + assert [tool.name for tool in facts.tools] == ["read_file"] + assert json.loads(facts.tools[0].input_schema_json) == {"type": "object"} + assert facts.tools_clipped, "the sentinel is read before the mapper drops it" + + +@pytest.mark.parametrize( + ("body", "kwargs", "clipped_prompt", "clipped_tools"), + [ + # Pass 1 and pass 2 both leave a readable value with a marker on it. + ({"system": "be helpful...[truncated 11000 chars]"}, {}, True, False), + ({"system": [{"type": "text", "text": "be...[truncated 4 chars]"}]}, {}, True, False), + ({"tools": [ANTHROPIC_TOOL, {"_truncated_items": 30}]}, {}, False, True), + # A marker anywhere under the tool list counts: a clipped description + # or schema is a tool definition we would export wrong. + ({"tools": [{"name": "read_file", "description": "d...[truncated 900 chars]"}]}, {}, False, True), + ({"system": "be helpful", "tools": [ANTHROPIC_TOOL]}, {}, False, False), + # The unsanitized kwargs never carry a marker. + ({}, {"request_messages": [{"role": "system", "content": "be helpful"}]}, False, False), + ], +) +def test_a_field_hermes_shortened_in_place_is_flagged( + body: dict, kwargs: dict[str, Any], clipped_prompt: bool, clipped_tools: bool +) -> None: + """The three sanitizer passes are not one state. + + Only the third loses the body. The first two leave a value that reads as + present, which is why a clipped field has to announce itself. + """ + facts = _request.parse({"method": "POST", "body": body}, **kwargs) + + assert facts.system_prompt_clipped is clipped_prompt + assert facts.tools_clipped is clipped_tools + assert not facts.truncated, "the envelope survived; only a field was shortened" + + +def test_a_complete_prompt_wins_over_a_clipped_one_ahead_of_it() -> None: + """Preference order yields to fidelity. + + ``request_messages`` skips the sanitizer, so where both hold the prompt the + unclipped copy is the one to export. + """ + facts = _request.parse( + {"method": "POST", "body": {"system": "be help...[truncated 900 chars]"}}, + request_messages=[{"role": "system", "content": "be helpful, at length"}], + ) + + assert facts.system_prompt == "be helpful, at length" + assert not facts.system_prompt_clipped + + +@pytest.mark.parametrize( + ("tool_choice", "expected"), + [ + ({"type": "auto"}, "auto"), + # Which tool was forced is the content of the choice, so it is kept. + ({"type": "tool", "name": "read_file"}, "tool:read_file"), + ({"type": "function", "function": {"name": "read_file"}}, "function:read_file"), + ("required", "required"), + ({}, None), + (None, None), + (7, None), + ], +) +def test_tool_choice_is_coerced_to_the_string_the_seed_takes(tool_choice: Any, expected: str | None) -> None: + facts = _request.parse({"method": "POST", "body": {"tool_choice": tool_choice}}) + + assert facts.tool_choice == expected + + +def test_an_unreadable_tool_list_does_not_cost_the_rest_of_the_body(caplog: pytest.LogCaptureFixture) -> None: + """Fail open, and fail narrow: the tool mapping is the fragile read. + + It runs through a private SDK path, so it gets its own guard rather than + taking the sampling params down with it. + """ + + class Hostile: + def __iter__(self) -> Any: + raise RuntimeError("boom") + + with caplog.at_level(logging.DEBUG, logger="grafana_agento11y_hermes._request"): + facts = _request.parse( + {"method": "POST", "body": {"system": "be helpful", "max_tokens": 8192, "tools": Hostile()}} + ) + + assert "could not read the request tools" in caplog.text + assert facts.tools == [] + assert facts.system_prompt == "be helpful" + assert facts.max_tokens == 8192, "a broken tool mapping must not cost the fields beside it" + + +def test_an_unreadable_body_never_reaches_the_hook(caplog: pytest.LogCaptureFixture) -> None: + """``parse`` never raises, whatever hermes reshaped the body into.""" + + class HostileBody(dict): + def get(self, *_: Any, **__: Any) -> Any: + raise RuntimeError("body refused to answer") + + with caplog.at_level(logging.DEBUG, logger="grafana_agento11y_hermes._request"): + facts = _request.parse({"method": "POST", "body": HostileBody(system="be helpful")}) + + assert "could not read the request body" in caplog.text + assert facts.system_prompt == "" + assert facts.max_tokens is None + + +def test_an_unreadable_envelope_never_reaches_the_hook(caplog: pytest.LogCaptureFixture) -> None: + """The read that picks the body out is guarded too, being upstream of the rest.""" + + class HostileRequest(dict): + def get(self, *_: Any, **__: Any) -> Any: + raise RuntimeError("envelope refused to answer") + + with caplog.at_level(logging.DEBUG, logger="grafana_agento11y_hermes._request"): + facts = _request.parse(HostileRequest(body={"system": "be helpful"})) + + assert "could not read the request envelope" in caplog.text + assert facts.truncated is True + assert facts.tools == [] + + +def test_the_clip_marker_scan_stops_at_its_depth_limit() -> None: + """A tool schema can nest arbitrarily; the scan for a clip marker cannot.""" + marker = {_request._CLIP_SENTINEL: 5} + within: Any = marker + for _ in range(_request._MAX_SCAN_DEPTH): + within = {"properties": within} + assert _request._carries_clip_marker(within) is True + + beyond = {"properties": within} + assert _request._carries_clip_marker(beyond) is False, "past the limit it reports nothing rather than recursing" diff --git a/plugins/hermes/tests/test_tags.py b/plugins/hermes/tests/test_tags.py new file mode 100644 index 000000000..2c12637b9 --- /dev/null +++ b/plugins/hermes/tests/test_tags.py @@ -0,0 +1,390 @@ +"""Opt-in client labels and filesystem-only Git resolution.""" + +from __future__ import annotations + +import os +import pathlib + +import pytest + +from grafana_agento11y_hermes import _tags + + +@pytest.fixture(autouse=True) +def isolated_tags(monkeypatch: pytest.MonkeyPatch): + for key in tuple(os.environ): + if key.startswith(("AGENTO11Y_", "SIGIL_")): + monkeypatch.delenv(key) + _tags._reset_for_tests() + yield + _tags._reset_for_tests() + + +def _repo(root: pathlib.Path, head: str) -> pathlib.Path: + """A directory holding a ``.git`` directory whose HEAD is ``head``.""" + git_dir = root / ".git" + git_dir.mkdir(parents=True) + (git_dir / "HEAD").write_text(head) + return root + + +# --- HEAD parsing --- + + +@pytest.mark.parametrize( + ("head", "expected"), + ( + ("ref: refs/heads/main\n", "main"), + ("ref: refs/heads/feature/nested-name\n", "feature/nested-name"), + ("ref: refs/heads/main", "main"), + # Detached HEAD: the short sha stands in for a branch name. + ("4d1f0c2b9a8e7f60514233aabbccddeeff001122\n", "4d1f0c2b9a8e"), + # A ref outside refs/heads (a tag checkout) names no branch. + ("ref: refs/tags/v1.0.0\n", ""), + ("", ""), + ("not a head at all\n", ""), + # Too short to be a sha. + ("abc123\n", ""), + ), +) +def test_head_contents_resolve_to_a_branch(tmp_path: pathlib.Path, head: str, expected: str) -> None: + assert _tags._git_branch(str(_repo(tmp_path, head))) == expected + + +def test_a_git_dir_without_a_head_file_names_no_branch(tmp_path: pathlib.Path) -> None: + (tmp_path / ".git").mkdir() + assert _tags._git_branch(str(tmp_path)) == "" + + +def test_no_repository_names_no_branch(tmp_path: pathlib.Path) -> None: + assert _tags._git_branch(str(tmp_path)) == "" + + +# --- the walk towards the root --- + + +def test_the_branch_is_found_from_a_subdirectory(tmp_path: pathlib.Path) -> None: + _repo(tmp_path, "ref: refs/heads/main\n") + deep = tmp_path / "src" / "pkg" + deep.mkdir(parents=True) + assert _tags._git_branch(str(deep)) == "main" + + +def test_the_walk_stops_after_six_parents(tmp_path: pathlib.Path) -> None: + """A repository further up than ``_MAX_PARENTS`` is deliberately not found.""" + _repo(tmp_path, "ref: refs/heads/main\n") + deep = tmp_path.joinpath(*[f"d{i}" for i in range(_tags._MAX_PARENTS)]) + deep.mkdir(parents=True) + assert _tags._find_git_dir(str(deep)) == "" + # One level closer is inside the limit. + assert _tags._find_git_dir(str(deep.parent)) == str(tmp_path / ".git") + + +def test_the_walk_terminates_at_the_filesystem_root() -> None: + assert _tags._find_git_dir(os.sep) == "" + + +# --- worktree and submodule pointers --- + + +def test_an_absolute_gitdir_pointer_is_followed(tmp_path: pathlib.Path) -> None: + real = tmp_path / "store" / "worktrees" / "wt1" + real.mkdir(parents=True) + (real / "HEAD").write_text("ref: refs/heads/side-branch\n") + + checkout = tmp_path / "checkout" + checkout.mkdir() + (checkout / ".git").write_text(f"gitdir: {real}\n") + + assert _tags._git_branch(str(checkout)) == "side-branch" + + +def test_a_relative_gitdir_pointer_resolves_against_the_pointer_file(tmp_path: pathlib.Path) -> None: + real = tmp_path / "store" / "modules" / "sub" + real.mkdir(parents=True) + (real / "HEAD").write_text("ref: refs/heads/submodule-branch\n") + + checkout = tmp_path / "checkout" + checkout.mkdir() + (checkout / ".git").write_text("gitdir: ../store/modules/sub\n") + + assert _tags._git_branch(str(checkout)) == "submodule-branch" + + +def test_a_git_file_without_a_pointer_is_ignored(tmp_path: pathlib.Path) -> None: + (tmp_path / ".git").write_text("this is not a gitdir pointer\n") + assert _tags._find_git_dir(str(tmp_path)) == "" + + +def test_an_unreadable_git_entry_is_ignored(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: + (tmp_path / ".git").write_text("gitdir: /somewhere\n") + + def deny(*_: object, **__: object) -> object: + raise PermissionError("nope") + + monkeypatch.setattr("builtins.open", deny) + assert _tags._resolve_git_entry(str(tmp_path / ".git")) == "" + + +# --- the resolved tag set --- + + +def test_tags_carry_only_opted_in_branch(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS", "true") + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS_NAMES", "branch") + _repo(tmp_path, "ref: refs/heads/main\n") + monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) + assert _tags.builtin_tags() == { + "entrypoint": "hermes", + "git.branch": "main", + } + + +def test_an_unresolvable_branch_is_omitted_rather_than_sent_empty( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS", "on") + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS_NAMES", "branch") + monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) + assert "git.branch" not in _tags.builtin_tags() + + +def test_an_unresolvable_cwd_leaves_only_the_entrypoint(monkeypatch: pytest.MonkeyPatch) -> None: + """A deleted working directory makes getcwd raise; the tags still resolve.""" + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS", "on") + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS_NAMES", "repo,branch") + + def deny() -> str: + raise OSError("cwd is gone") + + monkeypatch.setattr(os, "getcwd", deny) + assert _tags.builtin_tags() == {"entrypoint": "hermes"} + + +def test_resolution_is_cached_after_the_first_call(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS", "on") + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS_NAMES", "branch") + calls: list[int] = [] + + def counting_getcwd() -> str: + calls.append(1) + return str(tmp_path) + + monkeypatch.setattr(os, "getcwd", counting_getcwd) + _tags.builtin_tags() + _tags.builtin_tags() + assert len(calls) == 1, "the .git walk must stay off the per-request path" + + +def test_default_tags_are_identity_only(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: + _repo(tmp_path, "ref: refs/heads/main\n") + monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) + + client, seed = _tags.client_tags(), _tags.seed_tags() + assert seed == {} + assert "cwd" not in client + assert client == { + "agento11y.framework.name": "hermes", + "agento11y.framework.source": "plugin", + "agento11y.framework.language": "python", + "entrypoint": "hermes", + } + + +def test_the_cached_dict_cannot_be_mutated_by_a_caller(monkeypatch: pytest.MonkeyPatch) -> None: + tags = _tags.builtin_tags() + tags["entrypoint"] = "tampered" + assert _tags.builtin_tags()["entrypoint"] == "hermes" + + +@pytest.mark.parametrize("switch", ["", "0", "false", "NO", "off", "invalid", "user,repo"]) +def test_off_never_resolves(switch, monkeypatch): + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS", switch) + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS_NAMES", "all") + monkeypatch.setattr(_tags, "_git_branch", lambda _: pytest.fail("git lookup")) + monkeypatch.setattr(_tags, "_git_repo", lambda _: pytest.fail("repo lookup")) + monkeypatch.setattr(os, "getcwd", lambda: pytest.fail("cwd lookup")) + monkeypatch.setattr(_tags.getpass, "getuser", lambda: pytest.fail("user lookup")) + assert _tags.builtin_tags() == {"entrypoint": "hermes"} + + +@pytest.mark.parametrize("switch", ["1", "true", " YES ", "On"]) +@pytest.mark.parametrize( + ("names", "expected"), + [ + ("", ["user", "repo", "branch"]), + (" ALL ", ["user", "repo", "branch"]), + ("USER, user,, BRANCH,unknown", ["user", "branch"]), + (",,", []), + ("unknown", []), + ("repo", ["repo"]), + ("branch", ["branch"]), + ], +) +def test_selection_table(switch, names, expected, monkeypatch): + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS", switch) + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS_NAMES", names) + assert _tags._selected_tags() == expected + + +@pytest.mark.parametrize("suffix", ["AUTO_CODING_AGENT_TAGS", "AUTO_CODING_AGENT_TAGS_NAMES", "USER_ID", "TAGS"]) +@pytest.mark.parametrize( + ("canonical", "legacy", "expected"), + [ + (" new ", "old", "new"), + ("", " old ", "old"), + (" ", "old", "old"), + ("", "", ""), + ("false", "true", "false"), + ], +) +def test_alias_precedence(suffix, canonical, legacy, expected, monkeypatch): + monkeypatch.setenv("AGENTO11Y_" + suffix, canonical) + monkeypatch.setenv("SIGIL_" + suffix, legacy) + assert _tags._env(suffix) == expected + + +def test_legacy_selection_and_user(monkeypatch): + monkeypatch.setenv("SIGIL_AUTO_CODING_AGENT_TAGS", "true") + monkeypatch.setenv("SIGIL_AUTO_CODING_AGENT_TAGS_NAMES", "user") + monkeypatch.setenv("SIGIL_USER_ID", " legacy ") + assert _tags.builtin_tags() == {"entrypoint": "hermes", "user": "legacy"} + + +@pytest.mark.parametrize("name", ["user", "repo", "branch"]) +@pytest.mark.parametrize("value", ["", " ", " normal ", " 😀" * 129]) +def test_values_trimmed_capped_or_omitted(name, value, monkeypatch): + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS", "true") + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS_NAMES", name) + monkeypatch.setattr(_tags.getpass, "getuser", lambda: value) + monkeypatch.setattr(_tags, "_git_repo", lambda _: value) + monkeypatch.setattr(_tags, "_git_branch", lambda _: value) + tags = _tags.client_tags() + key = _tags._TAG_KEYS[name] + if value.strip(): + assert tags[key] == value.strip()[:128] + else: + assert key not in tags + + +def test_configured_user_wins_without_account_lookup(monkeypatch): + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS", "true") + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS_NAMES", "user") + monkeypatch.setenv("AGENTO11Y_USER_ID", " configured ") + monkeypatch.setattr(_tags.getpass, "getuser", lambda: pytest.fail("account lookup")) + assert _tags.client_tags()["user"] == "configured" + + +def test_account_failure_keeps_other_tags(monkeypatch): + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS", "true") + + def deny(): + raise KeyError("account unavailable") + + monkeypatch.setattr(_tags.getpass, "getuser", deny) + monkeypatch.setattr(_tags, "_git_repo", lambda _: "owner/repo") + monkeypatch.setattr(_tags, "_git_branch", lambda _: "main") + assert _tags.builtin_tags() == {"entrypoint": "hermes", "repo": "owner/repo", "git.branch": "main"} + + +def test_explicit_tags_skip_resolution_and_sdk_collisions(monkeypatch): + monkeypatch.setenv("AGENTO11Y_AUTO_CODING_AGENT_TAGS", "true") + explicit = { + "user": "x" * 150, + "repo": "chosen", + "git.branch": "manual", + "cwd": "/explicit", + "entrypoint": "custom", + "agento11y.framework.name": "custom", + } + monkeypatch.setenv("AGENTO11Y_TAGS", ",".join(f"{k}={v}" for k, v in explicit.items())) + monkeypatch.setattr(os, "getcwd", lambda: pytest.fail("cwd lookup")) + monkeypatch.setattr(_tags.getpass, "getuser", lambda: pytest.fail("user lookup")) + assert not (explicit.keys() & _tags.client_tags().keys()) + assert _tags._explicit_tags() == explicit + assert _tags.seed_tags() == {} + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("", {}), + ("missing,=value,key=, = ,ok = a=b,ok=last", {"ok": "last"}), + ("user=explicit, repo = owner/repo ", {"user": "explicit", "repo": "owner/repo"}), + ], +) +def test_explicit_pair_parsing(raw, expected, monkeypatch): + monkeypatch.setenv("AGENTO11Y_TAGS", raw) + assert _tags._explicit_tags() == expected + + +@pytest.mark.parametrize( + ("remote", "expected"), + [ + ("", ""), + (" ", ""), + ("git@host:owner/name.git", "owner/name"), + ("https://user:secret@host/group/sub/name.git?token=secret#fragment", "group/sub/name"), + ("ssh://git@host:2222/owner/name.git/", "owner/name"), + ("file:///srv/repos/name.git/", "name"), + ("/srv/git:odd/name.git", "name"), + ("../name.git", "name"), + ("https://host", ""), + ("https://[broken/name", ""), + ("g:owner/name", "owner/name"), + ("host:", ""), + ], +) +def test_remote_repository_names(remote, expected): + assert _tags._repo_from_remote(remote) == expected + + +@pytest.mark.parametrize("config", ["", "broken config", '[remote "other"]\nurl = x']) +def test_repo_falls_back_to_checkout_name(config, tmp_path): + root = _repo(tmp_path / "checkout", "ref: refs/heads/main") + (root / ".git" / "config").write_text(config) + assert _tags._git_repo(str(root)) == "checkout" + + +def test_repo_without_git_is_omitted(tmp_path): + assert _tags._git_repo(str(tmp_path)) == "" + + +@pytest.mark.parametrize("absolute", [True, False]) +@pytest.mark.parametrize("remote", [True, False]) +def test_worktree_repo_uses_common_directory(absolute, remote, tmp_path): + root = _repo(tmp_path / "main", "ref: refs/heads/main") + common = root / ".git" + worktree_git = common / "worktrees" / "side" + worktree_git.mkdir(parents=True) + (worktree_git / "commondir").write_text(str(common) if absolute else "../..") + (worktree_git / "HEAD").write_text("ref: refs/heads/side") + checkout = tmp_path / "side" + checkout.mkdir() + (checkout / ".git").write_text(f"gitdir: {worktree_git}") + if remote: + (common / "config").write_text('[remote "origin"]\nurl = https://secret@host/owner/repo.git\n') + assert _tags._git_repo(str(checkout)) == ("owner/repo" if remote else "main") + assert _tags._git_branch(str(checkout)) == "side" + + +def test_gitdir_name_fallback_for_submodule(tmp_path): + store = tmp_path / "module.git" + store.mkdir() + checkout = tmp_path / "checkout" + checkout.mkdir() + (checkout / ".git").write_text(f"gitdir: {store}") + assert _tags._git_repo(str(checkout)) == "module" + + +@pytest.mark.parametrize("filename", ["HEAD", "config", "commondir"]) +def test_invalid_utf8_is_ignored(filename, tmp_path): + _repo(tmp_path, "ref: refs/heads/main") + (tmp_path / ".git" / filename).write_bytes(b"\xff") + assert _tags._read_git_file(str(tmp_path / ".git" / filename)) == "" + assert _tags._git_repo(str(tmp_path)) == tmp_path.name + + +def test_invalid_utf8_git_pointer_is_ignored(tmp_path): + (tmp_path / ".git").write_bytes(b"\xff") + assert _tags._find_git_dir(str(tmp_path)) == "" diff --git a/plugins/hermes/tests/test_version.py b/plugins/hermes/tests/test_version.py new file mode 100644 index 000000000..61d8f2dd3 --- /dev/null +++ b/plugins/hermes/tests/test_version.py @@ -0,0 +1,60 @@ +"""Tests for the generation-export User-Agent token.""" + +from __future__ import annotations + +import builtins +from typing import Any + +import pytest + +from grafana_agento11y_hermes import _version + + +@pytest.fixture +def sdk_version_import_fails(monkeypatch: pytest.MonkeyPatch) -> None: + """Make ``from agento11y.version import user_agent`` fail, as an older SDK does.""" + real_import = builtins.__import__ + + # The parameters are spelled out rather than taken as *args, because the + # shim is checked against the real ``__import__`` signature. + def guarded(name: str, globals: Any = None, locals: Any = None, fromlist: Any = (), level: int = 0) -> Any: + if name == "agento11y.version": + raise ImportError("no version module in this SDK") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", guarded) + + +def test_plugin_user_agent_format() -> None: + ua = _version.plugin_user_agent() + plugin, sdk = ua.split(" ", 1) + assert plugin.startswith("agento11y-plugin-hermes/") + assert plugin.split("/", 1)[1] # non-empty version + assert sdk.startswith("agento11y-sdk-python/") + assert sdk.split("/", 1)[1] + + +def test_plugin_user_agent_falls_back_when_metadata_missing(monkeypatch) -> None: + def boom(name: str) -> str: + raise _version.PackageNotFoundError(name) + + monkeypatch.setattr(_version, "version", boom) + ua = _version.plugin_user_agent() + assert ua.startswith("agento11y-plugin-hermes/dev ") + + +def test_the_sdk_token_falls_back_to_package_metadata(sdk_version_import_fails: None) -> None: + """An SDK without ``agento11y.version`` still gets a version-carrying token.""" + sdk = _version._sdk_user_agent() + assert sdk.startswith("agento11y-sdk-python/") + assert sdk.split("/", 1)[1] not in ("", "unknown") + + +def test_the_sdk_token_is_unknown_when_nothing_can_report_a_version( + sdk_version_import_fails: None, monkeypatch: pytest.MonkeyPatch +) -> None: + def boom(name: str) -> str: + raise _version.PackageNotFoundError(name) + + monkeypatch.setattr(_version, "version", boom) + assert _version._sdk_user_agent() == "agento11y-sdk-python/unknown" diff --git a/plugins/hermes/uv.lock b/plugins/hermes/uv.lock new file mode 100644 index 000000000..e6ef9244d --- /dev/null +++ b/plugins/hermes/uv.lock @@ -0,0 +1,788 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[[package]] +name = "agento11y" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/c8/5635669915cd7c70328a430533ee9df1b0561b69c90c941d2dfeea81002d/agento11y-0.17.0.tar.gz", hash = "sha256:13d285a7367f65e43649523665e3a35934fc90532abe671da5cebea7be34dccd", size = 246418, upload-time = "2026-08-25T19:06:52.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/2e/a897d5959d7ed806a4fbb6adebff9e97401163476b44052f4d1498e08649/agento11y-0.17.0-py3-none-any.whl", hash = "sha256:3ddfeeac88d0c5cebd78bdda8c775fb89342f7aaf12a18f0470aa66fbab14e00", size = 145181, upload-time = "2026-08-25T19:06:50.985Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/74bcab964c9a7a61f2bb71e8179b0f13e6fa98f7ce00fd168aab291e4a2e/googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071", size = 150967, upload-time = "2026-08-06T06:24:51.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/51/186c02b8549b69ccda44429cf6ff5081e4b61a602ddfe6a8020d1be31d1b/googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79", size = 300626, upload-time = "2026-08-06T06:23:46.696Z" }, +] + +[[package]] +name = "grafana-agento11y-hermes" +version = "0.10.0" +source = { editable = "." } +dependencies = [ + { name = "agento11y" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "agento11y", specifier = ">=0.17,<0.18" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.27" }, + { name = "opentelemetry-sdk", specifier = ">=1.27" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-cov", specifier = ">=6.0" }, + { name = "ruff", specifier = ">=0.16.3" }, + { name = "ty", specifier = "==0.0.73" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/f6/3b781cd07a715ea5f5125ae264226e7fc4d87603d6d3955022cabfdc5da2/grpcio-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f", size = 6338720, upload-time = "2026-07-23T15:19:13.177Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/d14833d15d5984e366f1b027fa78bd038c9b028c66880bffb0f5a4d25ee2/grpcio-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223", size = 12178773, upload-time = "2026-07-23T15:19:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/6b/98/8acbb416544e7871132d8e42a07ed70c802d70e6a16c6009e505a34d32a4/grpcio-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0", size = 6921203, upload-time = "2026-07-23T15:19:17.824Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/0fdbfaf4fc54e5c88f6bce4008a065092fe7fbc4460eb5617ae8b20fd505/grpcio-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d", size = 7648508, upload-time = "2026-07-23T15:19:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ea/107b9dbb2ed3ad14dd774fd3dde7d29ff9938a6c198654becb2c3a0e9a6a/grpcio-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9", size = 7079466, upload-time = "2026-07-23T15:19:21.478Z" }, + { url = "https://files.pythonhosted.org/packages/3b/06/9fa9941089e6fae83b060b6ce61c1e81053e52decae43197245f45e07d36/grpcio-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745", size = 7605583, upload-time = "2026-07-23T15:19:23.74Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/f10fb56062dc2771c630827a82d9ad0ecd05cad572ea3b08d49f6631680a/grpcio-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617", size = 8637810, upload-time = "2026-07-23T15:19:25.536Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/f84927258f6a1b6ea6dea661fdc6de859b35e560c96f3012d15ccd39f85e/grpcio-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969", size = 8008021, upload-time = "2026-07-23T15:19:27.863Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/cdf72161397ccd29d4ca2192f641524536c9cf54ad948c9dd0e0e01138fa/grpcio-1.83.0-cp311-cp311-win32.whl", hash = "sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45", size = 4404376, upload-time = "2026-07-23T15:19:30.137Z" }, + { url = "https://files.pythonhosted.org/packages/df/ed/e0ffeb4c848699c194dc9fb6a29ab29bcb2b6aac8c416bf18c51bfe8242c/grpcio-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49", size = 5164469, upload-time = "2026-07-23T15:19:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463", size = 27225, upload-time = "2026-07-16T15:25:38.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/29/6ae42ba32b153ae0a44ae125f0caff2188bbe62d99c82d1768da30864e72/opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e", size = 19624, upload-time = "2026-07-16T15:25:19.096Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8", size = 25806, upload-time = "2026-07-16T15:25:39.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/d0/fdeb1a98d8d3a6205f5f297c51b4a9bfe65126ab60339669bbe3dd54c2e2/opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3", size = 21850, upload-time = "2026-07-16T15:25:20.006Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "ty" +version = "0.0.73" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/90/c4e1bb4cead3b644c3e258a27f9b05c7dc5eb0ec96a4f5282194edae9e0d/ty-0.0.73.tar.gz", hash = "sha256:823d4ce0d237bfc7eb6bcee70842f2c0706113813a16951077840743712f4b74", size = 6712739, upload-time = "2026-08-19T03:12:43.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/0f/f5e1801e55cc631f2db193276675b30561b963a2403da832bffb5d100267/ty-0.0.73-py3-none-linux_armv6l.whl", hash = "sha256:90a946082bf9bc446b5e72973d9f4ff1222a240b2ca4c9e6eed61eb913e30810", size = 12715452, upload-time = "2026-08-19T03:12:06.673Z" }, + { url = "https://files.pythonhosted.org/packages/54/32/515dd05074c213b433524ab97eb003b0132ae7e358e0d75633ba7a314ed8/ty-0.0.73-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b7d6b5c6a6db7ea95fbbc16af514ef44a27a29a2fe1dc798900790364d170209", size = 12301870, upload-time = "2026-08-19T03:12:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/50/4d/085b4889f0d4bbe4af8b96242d4a1cb209fff95967cfa239ea141983719b/ty-0.0.73-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6f657f463e01372d8688f235be164750c8db722c97da27fa4903aa8d40b203", size = 12111741, upload-time = "2026-08-19T03:12:11.067Z" }, + { url = "https://files.pythonhosted.org/packages/95/f6/d6ec277cadfecf03ad4c18551b67c4c6eb7807a0560d801db14be99d7a89/ty-0.0.73-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2de468e33fd44c9ff1c43473a7316f4289480f5cba8995a67b6d22aee39ca9", size = 12196124, upload-time = "2026-08-19T03:12:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/75/b7/ce78d8707563af9cae9bbd25328bfbc4931035085bd20089adf0c418f70e/ty-0.0.73-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2942fa0ef795a66034cdc8d75a72f453442f3b58ff2f69b4da05b7b954765b55", size = 12488557, upload-time = "2026-08-19T03:12:15.252Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e8/329b9851b23502758c5c98e8cc875ea2a1b4c9674b4ca3a86da56a5063d3/ty-0.0.73-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e0f1ef14f642e18ac4e7a616a2796dcf7a5d82e28cd17f9796494acc7c4aabb", size = 13215606, upload-time = "2026-08-19T03:12:17.225Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/67fedfd2cb77516ef0066b1642f487dba0eb3006493cf3475b15f5b8b228/ty-0.0.73-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16981e15fdceedb37d0aff76c5ac25914595dfee2675af95335550064251ad22", size = 13665497, upload-time = "2026-08-19T03:12:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b3/154f4dd48ec5eebc186ab4b822c6e62f982fc5ddfd262d6e3903c2acba44/ty-0.0.73-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644b2bec8a2e2e4957a942ae81d6cff5571c489bb5a8675e4d3886de537a694d", size = 13351231, upload-time = "2026-08-19T03:12:21.353Z" }, + { url = "https://files.pythonhosted.org/packages/35/5f/d462496903fbe453fb76363f8478be929c8e6ff21e6928c57dcd7e5fa21f/ty-0.0.73-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338d565be3186f50ff8e9d10483685549c2d23f0754485d5ede3b54f4319188a", size = 12782586, upload-time = "2026-08-19T03:12:23.667Z" }, + { url = "https://files.pythonhosted.org/packages/87/52/ec6d24b74abe3ec324204c1c71e6d0c6c76a17ffc15fd51d603b0a302abe/ty-0.0.73-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:11c7b6d839309d2c102cb3a4c03d817176bbfab5b2fccc95a75ec5c9597421c9", size = 13247134, upload-time = "2026-08-19T03:12:25.956Z" }, + { url = "https://files.pythonhosted.org/packages/26/20/cc74650fec56a54786c6d7c89e09576fcad3092be34cf21715d39a406a9b/ty-0.0.73-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:488572db7ff97fb50ea36a76250f2d617c9727d143da6c7bf0623276eb0fc507", size = 12309344, upload-time = "2026-08-19T03:12:28.122Z" }, + { url = "https://files.pythonhosted.org/packages/89/bd/4b0a9087f4315d7fbadf77a3ce44c816cc9ffabed1ced06cc5be81fbc414/ty-0.0.73-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1b958ebceefbbf594e59eb8d3d55bbd033ce634026fcba3e4bc3179e78e45bb7", size = 12502319, upload-time = "2026-08-19T03:12:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/11/80/0a925074911fe111912ea29d9eed309bcc183f43d2fb3eef07db056a0beb/ty-0.0.73-py3-none-musllinux_1_2_i686.whl", hash = "sha256:91a32993b3c34e42c3f323ad6c0399cb596bd1c27e9b7f20db7cd64c1067b68e", size = 12753688, upload-time = "2026-08-19T03:12:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/6b/aeccaf89efbc2e112bd415340a22e2669ec998aa397242503e747b712ca4/ty-0.0.73-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bab8a19fbf51f479bddb2a12c5fabfe52f918a5590362321ed5d89b44eb62c15", size = 13069050, upload-time = "2026-08-19T03:12:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3e/eae485fd86c1585943fd4e1746b0757b2da01e2c43136ebe8c686fe1c7f1/ty-0.0.73-py3-none-win32.whl", hash = "sha256:03347a612f0fa020b19bfd8dbd521db6ecc75d377a3e4d4f6e6c2e62871da4cc", size = 12053187, upload-time = "2026-08-19T03:12:37.565Z" }, + { url = "https://files.pythonhosted.org/packages/a7/01/9b8b983786e3ce34924e372e8b76b92b508273ab65c589fc7e88cc03ee17/ty-0.0.73-py3-none-win_amd64.whl", hash = "sha256:cedd05122ded0b5dcc55431a370e974b747f99c41c290a3d2ab8c1867f197519", size = 12693838, upload-time = "2026-08-19T03:12:39.483Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/25333bbfea6a5dc064371d2002d3d4807db90b84d5448f9106b2712b0fbc/ty-0.0.73-py3-none-win_arm64.whl", hash = "sha256:e47068f8369dea5d641a26a2ad0a947a320b02ff87099b07e95de0323245a4dc", size = 12443573, upload-time = "2026-08-19T03:12:41.449Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] diff --git a/redaction/README.md b/redaction/README.md index bb50672a1..13a1555bd 100644 --- a/redaction/README.md +++ b/redaction/README.md @@ -13,6 +13,8 @@ and the opencode plugin reuses the JS one: | shared `agento11y` binary | `plugins/agento11y/internal/redact/patterns_gen.go` | | opencode plugin | imports the JS table through `@grafana/agento11y-core` | +The [Hermes plugin](../plugins/hermes/README.md) reuses Python SDK 0.17.x redaction, with no separate engine or generated pattern table. Hermes sanitizes tool-execution spans separately because they do not pass through the generation sanitizer. Published PyPI `0.10.0` has no shared secret redaction; see the [installation guide](../plugins/hermes/README.md#install). + Do not edit a generated file. `mise run check:redaction` regenerates the five tables into a temporary directory and diffs them against the tree, so a hand edit fails CI with the file name and the command to run. @@ -78,9 +80,7 @@ case-insensitive matching does not follow the host culture. ## Which tier runs on which field -The table says what a pattern matches; the caller decides which tier runs. Both -the SDKs' generation sanitizer and every coding-agent plugin split it the same -way: tier 1 + tier 2 on a user prompt, a system prompt and a tool payload, +The table says what a pattern matches; the caller decides which tier runs. The SDKs' generation sanitizer and the coding-agent plugins, including Hermes, split it the same way: tier 1 + tier 2 on a user prompt, a system prompt and a tool payload, tier 1 only on assistant text, reasoning, a conversation title and an error message. Prose is left on tier 1 because the tier 2 heuristics rewrite the word after any `key:` in a sentence. `docs/concepts/content-capture-modes.md` has the @@ -95,9 +95,9 @@ choice. The SDKs redact addresses by default (`RedactEmailAddresses`). The routinely carry commit authors and reviewer addresses, and redacting them costs more context than it protects. -The pi plugin is the exception among the plugins. It redacts through the SDK's -generation sanitizer instead of its own mapper, so it takes the SDK default and -does redact addresses. Unlike the tiering, this has not been unified. +Pi and Hermes use their SDK sanitizers and redact email addresses. +Hermes also redacts addresses on tool-execution spans. Unlike the tiering, +email handling has not been unified across plugins. ## Known limitations