Skip to content

Commit 4b9c04f

Browse files
JetoPistolaclaude
andcommitted
[NA] [SDK] fix: entry point must resolve to the module (pip-install support) + 0.1.1
The hermes_agent.plugins entry point was 'opik = opik_hermes:register', which resolves to the register FUNCTION. Hermes loads a plugin via ep.load() then getattr(<result>, 'register') expecting a MODULE — so it found no register(), wired 0 hooks, and a pip-installed plugin produced no traces (the directory- install path was unaffected, which is why it slipped through). Fix: point the entry point at the module ('opik = opik_hermes'); getattr(module, 'register') then resolves. Verified end-to-end: real Hermes + pip-installed wheel, enabled via config.yaml plugins.enabled:[opik], logs a fresh staging trace (7 spans). - pyproject: entry point -> opik_hermes; version -> 0.1.1 - publish.yml: assert the built wheel's entry point == 'opik_hermes' (guards the regression) - e2e: new pip/wheel path (Dockerfile.wheel + run_e2e_wheel.sh + CI job) that builds the wheel, pip-installs it, and drives a turn via ENTRY-POINT discovery (no plugin dir copied) — the coverage that would have caught this. The existing mock E2E keeps testing the directory-install path; both are supported. - README (root + plugin): pip path enables via ~/.hermes/config.yaml plugins.enabled:[opik] (entry-point name 'opik'), not 'hermes plugins enable' (which only sees directory plugins); dir/GitHub install noted as separate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e2bb0fa commit 4b9c04f

7 files changed

Lines changed: 206 additions & 13 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,30 @@ jobs:
6363
env:
6464
HERMES_IMAGE: ${{ inputs.hermes_image || 'nousresearch/hermes-agent:latest' }}
6565
run: bash e2e/run_e2e_real_opik.sh
66+
67+
# PIP-INSTALL path: build the wheel from source, `pip install` it into Hermes,
68+
# and drive a turn — so the plugin is discovered via its hermes_agent.plugins
69+
# ENTRY POINT (no plugin directory copied in). The mock E2E above covers the
70+
# directory-install path; both install methods are supported, so both are
71+
# tested. This is the path that would have caught the entry-point-resolves-to-
72+
# a-function regression (opik_hermes:register vs opik_hermes).
73+
e2e-pip-wheel:
74+
name: E2E via pip (entry point)
75+
runs-on: ubuntu-latest
76+
timeout-minutes: 20
77+
steps:
78+
- uses: actions/checkout@v4
79+
- uses: actions/setup-python@v5
80+
with:
81+
python-version: "3.12"
82+
- name: Run pip/wheel E2E
83+
env:
84+
HERMES_IMAGE: ${{ inputs.hermes_image || 'nousresearch/hermes-agent:latest' }}
85+
run: bash e2e/run_e2e_wheel.sh
86+
- name: Upload journal on failure
87+
if: failure()
88+
uses: actions/upload-artifact@v4
89+
with:
90+
name: opik-e2e-wheel-journal
91+
path: /tmp/opik-e2e-wheel-journal.jsonl
92+
if-no-files-found: ignore

.github/workflows/publish.yml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,17 @@ jobs:
6767
body = zipfile.ZipFile(wheel).read(
6868
next(n for n in names if n.endswith("entry_points.txt"))
6969
).decode()
70-
assert "hermes_agent.plugins" in body and "opik_hermes:register" in body, (
71-
"hermes_agent.plugins entry point missing"
70+
# The entry point must resolve to the MODULE (opik = opik_hermes), not
71+
# opik_hermes:register — Hermes does ep.load() then getattr(mod,
72+
# "register"), so a ":register" suffix breaks plugin loading.
73+
import configparser, io
74+
cp = configparser.ConfigParser()
75+
cp.read_string(body)
76+
ep = cp["hermes_agent.plugins"]["opik"].strip()
77+
assert ep == "opik_hermes", (
78+
f"opik entry point must be 'opik_hermes' (module), got {ep!r}"
7279
)
73-
print("OK:", wheel)
80+
print("OK:", wheel, "entry point:", ep)
7481
PY
7582
7683
# --- Decide target -----------------------------------------------------

README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,27 @@ Opik Python SDK.
4646
## Install
4747

4848
Install the package into the same Python environment as Hermes, then enable
49-
the plugin:
49+
the plugin by adding it to `plugins.enabled` in `~/.hermes/config.yaml`:
5050

5151
```bash
5252
pip install opik-hermes
53-
hermes plugins enable observability/opik
53+
```
54+
55+
```yaml
56+
# ~/.hermes/config.yaml
57+
plugins:
58+
enabled: [opik]
5459
```
5560
5661
`pip install opik-hermes` pulls in the `opik` SDK automatically (it's a
5762
declared dependency) and registers the plugin with Hermes via the
58-
`hermes_agent.plugins` entry point — no manual file copying.
63+
`hermes_agent.plugins` entry point (name `opik`) — no manual file copying.
64+
Enable it through `plugins.enabled` as above; the entry-point name is `opik`.
65+
66+
> This is the **pip install** path. A directory-based install
67+
> (`hermes plugins install comet-ml/opik-hermes`, enabled via
68+
> `hermes plugins enable observability/opik`) is tracked separately — see the
69+
> follow-up work in the repo's issues.
5970

6071
## Configure
6172

e2e/Dockerfile.wheel

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# E2E image for the PIP-INSTALL path: install opik-hermes from a locally-built
2+
# wheel into Hermes' venv, so the plugin is discovered via its
3+
# ``hermes_agent.plugins`` entry point — NOT copied into a plugins directory.
4+
# This exercises the entry-point discovery path a `pip install opik-hermes` user
5+
# gets, complementing run_e2e.sh's directory-install path.
6+
#
7+
# The build context must contain the built wheel at dist/*.whl (run_e2e_wheel.sh
8+
# builds it before `docker build`).
9+
ARG HERMES_IMAGE=nousresearch/hermes-agent:latest
10+
FROM ${HERMES_IMAGE}
11+
12+
COPY dist/ /tmp/opik-hermes-dist/
13+
RUN /opt/hermes/.venv/bin/python -m pip install --no-cache-dir /tmp/opik-hermes-dist/*.whl \
14+
|| uv pip install --python /opt/hermes/.venv/bin/python --no-cache-dir /tmp/opik-hermes-dist/*.whl
15+
16+
# Build-time guard for the entry-point-loading contract: Hermes does
17+
# ep.load() then getattr(<result>, "register"), so the entry point must resolve
18+
# to the MODULE (opik = opik_hermes), not the function (opik_hermes:register).
19+
# A regression here is exactly the bug this path exists to catch.
20+
RUN /opt/hermes/.venv/bin/python - <<'PY'
21+
import types
22+
from importlib.metadata import entry_points, version
23+
e = next(x for x in entry_points(group="hermes_agent.plugins") if x.name == "opik")
24+
loaded = e.load()
25+
assert isinstance(loaded, types.ModuleType), (
26+
f"entry point must load the module, got {type(loaded)!r} — "
27+
"check pyproject: opik = opik_hermes (not opik_hermes:register)"
28+
)
29+
assert callable(getattr(loaded, "register", None)), "loaded module has no register()"
30+
print("opik-hermes", version("opik-hermes"), "entry point ->", loaded.__name__, "register OK")
31+
PY

e2e/run_e2e_wheel.sh

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env bash
2+
# End-to-end test of the PIP-INSTALL path: build the wheel from source, install
3+
# it into the latest Hermes image, and drive a turn against a mock LLM + mock
4+
# Opik — asserting the plugin was discovered via its hermes_agent.plugins ENTRY
5+
# POINT (no plugin directory copied in) and produced the expected spans.
6+
#
7+
# Complements run_e2e.sh (which tests the directory-install path). Both install
8+
# methods are supported, so CI exercises both. No real keys; the agent has no
9+
# internet at run time.
10+
#
11+
# Run from the repo root: bash e2e/run_e2e_wheel.sh
12+
set -euo pipefail
13+
14+
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
15+
NET="opik-hermes-e2e-wheel"
16+
HERMES_IMAGE="${HERMES_IMAGE:-nousresearch/hermes-agent:latest}"
17+
WORK="$(mktemp -d)"
18+
JOURNAL_DIR="$WORK/journal"
19+
HERMES_HOME="$WORK/hermes"
20+
CTX="$WORK/ctx"
21+
mkdir -p "$JOURNAL_DIR" "$HERMES_HOME" "$CTX"
22+
23+
cleanup() {
24+
docker rm -f e2e-w-mock-llm e2e-w-mock-opik e2e-w-hermes >/dev/null 2>&1 || true
25+
docker network rm "$NET" >/dev/null 2>&1 || true
26+
}
27+
trap cleanup EXIT
28+
29+
echo "==> pulling $HERMES_IMAGE"
30+
docker pull -q "$HERMES_IMAGE" >/dev/null
31+
32+
# --- build the wheel from source into the docker build context ---------------
33+
echo "==> building opik-hermes wheel from source"
34+
python3 -m venv "$WORK/.venv"
35+
"$WORK/.venv/bin/pip" install -q -U build
36+
"$WORK/.venv/bin/python" -m build --wheel --outdir "$CTX/dist" "$REPO_ROOT" >/dev/null
37+
cp "$REPO_ROOT/e2e/Dockerfile.wheel" "$CTX/Dockerfile"
38+
echo "==> wheel: $(ls "$CTX/dist")"
39+
40+
# --- build the Hermes image with the wheel pip-installed (ep assert at build) -
41+
E2E_IMAGE="opik-hermes-e2e-wheel:local"
42+
echo "==> building $E2E_IMAGE (opik-hermes pip-installed)"
43+
docker build -q --build-arg HERMES_IMAGE="$HERMES_IMAGE" \
44+
-f "$CTX/Dockerfile" -t "$E2E_IMAGE" "$CTX" >/dev/null
45+
46+
echo "==> private network (no internet; only mocks reachable)"
47+
docker network create "$NET" >/dev/null
48+
49+
echo "==> mock-opik + mock-llm"
50+
docker run -d --name e2e-w-mock-opik --network "$NET" \
51+
-e MOCK_OPIK_JOURNAL=/journal/opik-journal.jsonl -e MOCK_OPIK_PORT=5173 \
52+
-v "$REPO_ROOT/e2e/mock_opik_server.py:/srv/s.py:ro" \
53+
-v "$JOURNAL_DIR:/journal" \
54+
python:3.12-slim python /srv/s.py >/dev/null
55+
56+
docker run -d --name e2e-w-mock-llm --network "$NET" \
57+
-e MOCK_LLM_PORT=18790 \
58+
-v "$REPO_ROOT/e2e/mock_llm_server.py:/srv/s.py:ro" \
59+
python:3.12-slim python /srv/s.py >/dev/null
60+
61+
# --- Hermes home: config + .env, NO plugin dir (entry-point discovery only) --
62+
cat > "$HERMES_HOME/config.yaml" <<YAML
63+
model:
64+
default: gpt-5
65+
provider: openai-api
66+
base_url: http://e2e-w-mock-llm:18790/v1
67+
providers: {}
68+
plugins:
69+
enabled:
70+
- opik
71+
agent:
72+
max_turns: 4
73+
terminal:
74+
backend: local
75+
YAML
76+
77+
cat > "$HERMES_HOME/.env" <<ENV
78+
OPENAI_API_KEY=mock-key
79+
OPENAI_BASE_URL=http://e2e-w-mock-llm:18790/v1
80+
OPIK_URL_OVERRIDE=http://e2e-w-mock-opik:5173/api
81+
OPIK_PROJECT_NAME=hermes-e2e-wheel
82+
HERMES_OPIK_DEBUG=true
83+
ENV
84+
# NOTE: deliberately NO `cp observability/opik` here — the plugin must be found
85+
# via the pip entry point, which is the whole point of this path.
86+
87+
echo "==> running one Hermes turn (plugin from pip entry point)"
88+
if command -v timeout >/dev/null 2>&1; then TIMEOUT="timeout 180"; else TIMEOUT=""; fi
89+
$TIMEOUT docker run --rm --name e2e-w-hermes --network "$NET" \
90+
-e HERMES_UID=0 -e HERMES_GID=0 \
91+
-v "$HERMES_HOME:/opt/data" \
92+
"$E2E_IMAGE" \
93+
sh -c '
94+
hermes chat -q "Compute 2 to the power 10 and report the number." \
95+
--provider openai-api --model gpt-5 2>&1 | tail -20
96+
' < /dev/null || echo "(hermes turn exited non-zero / timed out; assertion judges from the journal)"
97+
98+
sleep 3
99+
cp "$JOURNAL_DIR/opik-journal.jsonl" /tmp/opik-e2e-wheel-journal.jsonl 2>/dev/null || true
100+
echo "==> journal saved ($(wc -l < "$JOURNAL_DIR/opik-journal.jsonl" 2>/dev/null || echo 0) rows)"
101+
102+
echo "==> asserting journal"
103+
MOCK_OPIK_JOURNAL="$JOURNAL_DIR/opik-journal.jsonl" python3 "$REPO_ROOT/e2e/assert_journal.py"

observability/opik/README.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,17 @@ This plugin is **opt-in** — it only loads when you explicitly enable it.
88

99
## Enable
1010

11+
Install from PyPI, then enable via `plugins.enabled` in `~/.hermes/config.yaml`
12+
(the entry-point name is `opik`):
13+
1114
```bash
1215
pip install opik-hermes # also pulls in the `opik` SDK
13-
hermes plugins enable observability/opik
16+
```
17+
18+
```yaml
19+
# ~/.hermes/config.yaml
20+
plugins:
21+
enabled: [opik]
1422
```
1523
1624
## Point hermes at your Opik
@@ -83,10 +91,12 @@ Without the `opik` SDK the hooks no-op silently — the plugin fails open.
8391
## Verify
8492

8593
```bash
86-
hermes plugins list # observability/opik should show "enabled"
8794
hermes chat -q "hello" # one-shot turn from the CLI
8895
```
8996

97+
On startup the plugin logs `OPIK: Started logging traces to ...` once it's
98+
enabled and connected.
99+
90100
…or open the Hermes web UI at **http://localhost:9119** and use the Chat tab.
91101
Either way, check Opik for a trace named after your message.
92102

@@ -100,6 +110,5 @@ HERMES_OPIK_DEBUG=true # verbose plugin logging
100110

101111
## Disable
102112

103-
```bash
104-
hermes plugins disable observability/opik
105-
```
113+
Remove `opik` from `plugins.enabled` in `~/.hermes/config.yaml` (or delete the
114+
whole `enabled` entry).

pyproject.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "opik-hermes"
7-
version = "0.1.0"
7+
version = "0.1.1"
88
description = "Opik observability plugin for the Hermes agent — traces conversations, LLM calls, and tool usage to Opik."
99
readme = "README.md"
1010
requires-python = ">=3.11,<3.14"
@@ -35,7 +35,12 @@ Documentation = "https://www.comet.com/docs/opik/"
3535
# Hermes discovers pip-installed plugins via this entry-point group. The
3636
# value points at the package's register(ctx) entry module.
3737
[project.entry-points."hermes_agent.plugins"]
38-
opik = "opik_hermes:register"
38+
# Points at the MODULE (not opik_hermes:register). Hermes loads the entry point
39+
# with ep.load() then does getattr(<result>, "register") expecting a module with
40+
# a register() attribute. A ":register" suffix would load the function itself,
41+
# so getattr(function, "register") is None -> "no register() function" and no
42+
# hooks are wired. See the opik_hermes package __init__ which exports register.
43+
opik = "opik_hermes"
3944

4045
[project.optional-dependencies]
4146
dev = ["pytest>=8", "pytest-asyncio>=0.23"]

0 commit comments

Comments
 (0)