Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,30 @@ jobs:
env:
HERMES_IMAGE: ${{ inputs.hermes_image || 'nousresearch/hermes-agent:latest' }}
run: bash e2e/run_e2e_real_opik.sh

# PIP-INSTALL path: build the wheel from source, `pip install` it into Hermes,
# and drive a turn — so the plugin is discovered via its hermes_agent.plugins
# ENTRY POINT (no plugin directory copied in). The mock E2E above covers the
# directory-install path; both install methods are supported, so both are
# tested. This is the path that would have caught the entry-point-resolves-to-
# a-function regression (opik_hermes:register vs opik_hermes).
e2e-pip-wheel:
name: E2E via pip (entry point)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Run pip/wheel E2E
env:
HERMES_IMAGE: ${{ inputs.hermes_image || 'nousresearch/hermes-agent:latest' }}
run: bash e2e/run_e2e_wheel.sh
- name: Upload journal on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: opik-e2e-wheel-journal
path: /tmp/opik-e2e-wheel-journal.jsonl
if-no-files-found: ignore
13 changes: 10 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,17 @@ jobs:
body = zipfile.ZipFile(wheel).read(
next(n for n in names if n.endswith("entry_points.txt"))
).decode()
assert "hermes_agent.plugins" in body and "opik_hermes:register" in body, (
"hermes_agent.plugins entry point missing"
# The entry point must resolve to the MODULE (opik = opik_hermes), not
# opik_hermes:register — Hermes does ep.load() then getattr(mod,
# "register"), so a ":register" suffix breaks plugin loading.
import configparser, io
cp = configparser.ConfigParser()
cp.read_string(body)
ep = cp["hermes_agent.plugins"]["opik"].strip()
assert ep == "opik_hermes", (
f"opik entry point must be 'opik_hermes' (module), got {ep!r}"
)
print("OK:", wheel)
print("OK:", wheel, "entry point:", ep)
PY

# --- Decide target -----------------------------------------------------
Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,27 @@ Opik Python SDK.
## Install

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

```bash
pip install opik-hermes
hermes plugins enable observability/opik
```

```yaml
# ~/.hermes/config.yaml
plugins:
enabled: [opik]
```

`pip install opik-hermes` pulls in the `opik` SDK automatically (it's a
declared dependency) and registers the plugin with Hermes via the
`hermes_agent.plugins` entry point — no manual file copying.
`hermes_agent.plugins` entry point (name `opik`) — no manual file copying.
Enable it through `plugins.enabled` as above; the entry-point name is `opik`.

> This is the **pip install** path. A directory-based install
> (`hermes plugins install comet-ml/opik-hermes`, enabled via
> `hermes plugins enable observability/opik`) is tracked separately — see the
> follow-up work in the repo's issues.

## Configure

Expand Down
31 changes: 31 additions & 0 deletions e2e/Dockerfile.wheel
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# E2E image for the PIP-INSTALL path: install opik-hermes from a locally-built
# wheel into Hermes' venv, so the plugin is discovered via its
# ``hermes_agent.plugins`` entry point — NOT copied into a plugins directory.
# This exercises the entry-point discovery path a `pip install opik-hermes` user
# gets, complementing run_e2e.sh's directory-install path.
#
# The build context must contain the built wheel at dist/*.whl (run_e2e_wheel.sh
# builds it before `docker build`).
ARG HERMES_IMAGE=nousresearch/hermes-agent:latest
FROM ${HERMES_IMAGE}

COPY dist/ /tmp/opik-hermes-dist/
RUN /opt/hermes/.venv/bin/python -m pip install --no-cache-dir /tmp/opik-hermes-dist/*.whl \
|| uv pip install --python /opt/hermes/.venv/bin/python --no-cache-dir /tmp/opik-hermes-dist/*.whl

# Build-time guard for the entry-point-loading contract: Hermes does
# ep.load() then getattr(<result>, "register"), so the entry point must resolve
# to the MODULE (opik = opik_hermes), not the function (opik_hermes:register).
# A regression here is exactly the bug this path exists to catch.
RUN /opt/hermes/.venv/bin/python - <<'PY'
import types
from importlib.metadata import entry_points, version
e = next(x for x in entry_points(group="hermes_agent.plugins") if x.name == "opik")
loaded = e.load()
assert isinstance(loaded, types.ModuleType), (
f"entry point must load the module, got {type(loaded)!r} — "
"check pyproject: opik = opik_hermes (not opik_hermes:register)"
Comment thread
JetoPistola marked this conversation as resolved.
Outdated
)
assert callable(getattr(loaded, "register", None)), "loaded module has no register()"
print("opik-hermes", version("opik-hermes"), "entry point ->", loaded.__name__, "register OK")
PY
103 changes: 103 additions & 0 deletions e2e/run_e2e_wheel.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# End-to-end test of the PIP-INSTALL path: build the wheel from source, install
# it into the latest Hermes image, and drive a turn against a mock LLM + mock
# Opik — asserting the plugin was discovered via its hermes_agent.plugins ENTRY
# POINT (no plugin directory copied in) and produced the expected spans.
#
# Complements run_e2e.sh (which tests the directory-install path). Both install
# methods are supported, so CI exercises both. No real keys; the agent has no
# internet at run time.
#
# Run from the repo root: bash e2e/run_e2e_wheel.sh
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
NET="opik-hermes-e2e-wheel"
HERMES_IMAGE="${HERMES_IMAGE:-nousresearch/hermes-agent:latest}"
WORK="$(mktemp -d)"
JOURNAL_DIR="$WORK/journal"
HERMES_HOME="$WORK/hermes"
CTX="$WORK/ctx"
mkdir -p "$JOURNAL_DIR" "$HERMES_HOME" "$CTX"

cleanup() {
docker rm -f e2e-w-mock-llm e2e-w-mock-opik e2e-w-hermes >/dev/null 2>&1 || true
docker network rm "$NET" >/dev/null 2>&1 || true
}
trap cleanup EXIT

echo "==> pulling $HERMES_IMAGE"
docker pull -q "$HERMES_IMAGE" >/dev/null

# --- build the wheel from source into the docker build context ---------------
echo "==> building opik-hermes wheel from source"
python3 -m venv "$WORK/.venv"
"$WORK/.venv/bin/pip" install -q -U build
"$WORK/.venv/bin/python" -m build --wheel --outdir "$CTX/dist" "$REPO_ROOT" >/dev/null
cp "$REPO_ROOT/e2e/Dockerfile.wheel" "$CTX/Dockerfile"
echo "==> wheel: $(ls "$CTX/dist")"

# --- build the Hermes image with the wheel pip-installed (ep assert at build) -
E2E_IMAGE="opik-hermes-e2e-wheel:local"
echo "==> building $E2E_IMAGE (opik-hermes pip-installed)"
docker build -q --build-arg HERMES_IMAGE="$HERMES_IMAGE" \
-f "$CTX/Dockerfile" -t "$E2E_IMAGE" "$CTX" >/dev/null

echo "==> private network (no internet; only mocks reachable)"
docker network create "$NET" >/dev/null

echo "==> mock-opik + mock-llm"
docker run -d --name e2e-w-mock-opik --network "$NET" \
-e MOCK_OPIK_JOURNAL=/journal/opik-journal.jsonl -e MOCK_OPIK_PORT=5173 \
-v "$REPO_ROOT/e2e/mock_opik_server.py:/srv/s.py:ro" \
-v "$JOURNAL_DIR:/journal" \
python:3.12-slim python /srv/s.py >/dev/null

docker run -d --name e2e-w-mock-llm --network "$NET" \
-e MOCK_LLM_PORT=18790 \
-v "$REPO_ROOT/e2e/mock_llm_server.py:/srv/s.py:ro" \
python:3.12-slim python /srv/s.py >/dev/null

# --- Hermes home: config + .env, NO plugin dir (entry-point discovery only) --
cat > "$HERMES_HOME/config.yaml" <<YAML
model:
default: gpt-5
provider: openai-api
base_url: http://e2e-w-mock-llm:18790/v1
providers: {}
plugins:
enabled:
- opik
agent:
max_turns: 4
terminal:
backend: local
YAML

cat > "$HERMES_HOME/.env" <<ENV
OPENAI_API_KEY=mock-key
OPENAI_BASE_URL=http://e2e-w-mock-llm:18790/v1
OPIK_URL_OVERRIDE=http://e2e-w-mock-opik:5173/api
OPIK_PROJECT_NAME=hermes-e2e-wheel
HERMES_OPIK_DEBUG=true
ENV
# NOTE: deliberately NO `cp observability/opik` here — the plugin must be found
# via the pip entry point, which is the whole point of this path.

echo "==> running one Hermes turn (plugin from pip entry point)"
if command -v timeout >/dev/null 2>&1; then TIMEOUT="timeout 180"; else TIMEOUT=""; fi
$TIMEOUT docker run --rm --name e2e-w-hermes --network "$NET" \
-e HERMES_UID=0 -e HERMES_GID=0 \
-v "$HERMES_HOME:/opt/data" \
"$E2E_IMAGE" \
sh -c '
hermes chat -q "Compute 2 to the power 10 and report the number." \
--provider openai-api --model gpt-5 2>&1 | tail -20
' < /dev/null || echo "(hermes turn exited non-zero / timed out; assertion judges from the journal)"

sleep 3
cp "$JOURNAL_DIR/opik-journal.jsonl" /tmp/opik-e2e-wheel-journal.jsonl 2>/dev/null || true
echo "==> journal saved ($(wc -l < "$JOURNAL_DIR/opik-journal.jsonl" 2>/dev/null || echo 0) rows)"

echo "==> asserting journal"
MOCK_OPIK_JOURNAL="$JOURNAL_DIR/opik-journal.jsonl" python3 "$REPO_ROOT/e2e/assert_journal.py"
19 changes: 14 additions & 5 deletions observability/opik/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@ This plugin is **opt-in** — it only loads when you explicitly enable it.

## Enable

Install from PyPI, then enable via `plugins.enabled` in `~/.hermes/config.yaml`
(the entry-point name is `opik`):

```bash
pip install opik-hermes # also pulls in the `opik` SDK
hermes plugins enable observability/opik
```

```yaml
# ~/.hermes/config.yaml
plugins:
enabled: [opik]
```

## Point hermes at your Opik
Expand Down Expand Up @@ -83,10 +91,12 @@ Without the `opik` SDK the hooks no-op silently — the plugin fails open.
## Verify

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

On startup the plugin logs `OPIK: Started logging traces to ...` once it's
enabled and connected.

…or open the Hermes web UI at **http://localhost:9119** and use the Chat tab.
Either way, check Opik for a trace named after your message.

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

## Disable

```bash
hermes plugins disable observability/opik
```
Remove `opik` from `plugins.enabled` in `~/.hermes/config.yaml` (or delete the
whole `enabled` entry).
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

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

[project.optional-dependencies]
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
Expand Down
Loading