Skip to content

feat(sink): add hermes sink for OpenAI-compatible /v1/runs delivery#6

Open
IYENTeam wants to merge 23 commits into
mainfrom
feat/hermes-sink
Open

feat(sink): add hermes sink for OpenAI-compatible /v1/runs delivery#6
IYENTeam wants to merge 23 commits into
mainfrom
feat/hermes-sink

Conversation

@IYENTeam
Copy link
Copy Markdown
Owner

@IYENTeam IYENTeam commented May 6, 2026

Summary

  • Add a new HermesSink so clawhip can route GitHub events to Hermes Agent (https://github.com/nousresearch/hermes-agent) as a peer of OpenClaw — i.e. as a decision authority for IYEN label-driven workflows.
  • Wire it through the existing sink/router/dispatch/daemon machinery using the same pattern OpenClawSink and IyenSystemSink follow.
  • 312 LOC new + 80 LOC modifications. 5 new unit tests; full suite still green (314 passed, 0 failed).

Why

Hermes plays the same role OpenClawSink plays — receive a clawhip-routed event, decide the lane, apply the lane label using its own bot identity. The label that exits Hermes is the only signal IYENsystem reads, identical to the OpenClaw flow. clawhip stays in its lane (router only); Hermes does the deciding and the GitHub write.

This unblocks running an IYEN deployment with Hermes instead of OpenClaw as the decider. The companion skill (decision rubric + GitHub label call) lives in the IYENsystem repo (a parallel PR adds integrations/hermes-skill/iyen-triage/SKILL.md and docs/HERMES_ROLES.md).

What ships

Piece Location
Sink implementation src/sink/hermes.rs (new, 312 LOC)
Sink target variant src/sink/mod.rs::SinkTarget::Hermes
Match-arm coverage src/discord.rs, src/slack.rs (peer clients reject Hermes targets explicitly)
Dispatch routing key src/dispatch.rs::sink_target_key
Config src/config.rs::HermesConfig under [providers.hermes] (base_url, auth_token, optional instructions/model)
Validation src/config.rs::validate rejects sink = "hermes" routes when provider not configured
Router label mapping src/router.rs accepts "hermes" as a sink
Daemon wiring src/daemon.rs registers sink when [providers.hermes].is_configured(), applying optional overrides

Why POST /v1/runs (not chat completions)

  • Returns 202 + run_id immediately → matches the fire-and-forget contract clawhip sinks use today (compare: OpenClawSink::send drops the response body)
  • Lets Hermes do tool calling (GitHub label apply) inside the same run rather than forcing clawhip to parse a streaming reply and apply the label itself — which would push clawhip out of its "router only" lane
  • Doesn't tie the decision lifetime to the HTTP request — Hermes can take as long as it needs without clawhip holding a TCP slot

What this sink deliberately does NOT do

  • Parse Hermes's reasoning / decision (we never see it; the label coming back through GitHub is the decision)
  • Apply GitHub labels (Hermes does that with its own bot identity, same as OpenClaw)
  • Retry on Hermes errors (best-effort delivery is the dispatch contract; if Hermes is down the route just drops, like OpenClaw)

These are stated as invariants in the module docstring so a future maintainer can't accidentally break the role separation.

Configuration example

[providers.hermes]
base_url = "http://127.0.0.1:8000"
auth_token = "<hermes gateway bearer>"
# Optional:
# instructions = "...custom IYEN prompt..."
# model = "openai/gpt-4o"

[[routes]]
event = "github.issue-opened"
sink = "hermes"

[[routes]]
event = "github.pull-request-opened"
sink = "hermes"

Test plan

cargo build           # clean
cargo test            # 314 passed, 0 failed (added 5 new hermes tests)

New tests cover:

  1. is_configured boundary cases (None, empty, whitespace-only)
  2. endpoint strips trailing slash and appends /v1/runs
  3. body shape — instructions, input[].content[].text, metadata, stream: false
  4. model pin-through
  5. with_instructions override (default constant must NOT leak when overridden)
  6. Contract test: default instructions must mention every iyen:* label IYEN's workflows trigger on. Adding a new lane label forces an update here.

Mutually exclusive with OpenClaw

A deployment runs one decision authority at a time. Per-repo split is fine (one route filters repo = A to Hermes, another routes repo = B to OpenClaw); two deciders on the same repo would race on the label.

Companion changes (separate PR in iyensystem repo)

  • integrations/hermes-skill/iyen-triage/SKILL.md — Hermes-side decision rubric and GitHub label call
  • docs/HERMES_ROLES.md — operator-facing role contract, parallel to docs/OPENCLAW_ROLES.md
  • examples/config.tomlhermes-bot pre-registered in [safety].system_actors and allowed_label_actors so swapping deciders doesn't require a config edit there

iyen and others added 23 commits April 9, 2026 18:51
- src/source/opencode.rs: polls opencode serve API for session events
  - opencode.session.created: new session detected
  - opencode.session.ended: session disappeared
  - opencode.session.idle: no activity for N minutes
  - opencode.message.assistant: new assistant response
  - opencode.message.tool: tool invocations
- config: [monitors.opencode] with url, poll_interval, idle_threshold
- Follows existing Source trait pattern (tmux, git, github, workspace)
- 🟢 session.created, 🔴 ended, 💤 idle, 🤖 assistant, 🔧 tool
- Compact: emoji [short_id] summary
- Alert: emoji **kind** + title + summary
- Replaces raw JSON fallback for opencode.* events
- First poll records state silently (no created/idle events)
- Pre-fetch message counts to avoid replaying old messages
- Mark already-idle sessions to prevent false alerts
- Log warmup completion with session count
- New sink type: openclaw (uses OpenClaw Gateway cron wake API)
- Config: [providers.openclaw] gateway_url + gateway_token
- Route support: sink = "openclaw" routes events to OpenClaw sessions
- SinkTarget::OpenClaw variant added across all match arms
- Validation: openclaw routes require configured provider
…se events

- Add github_issue_opened_rich() with html_url, labels, body_preview
- Compact format: 🆕 [repo#num](url) title `label`
- Alert format: 🚨 **New Issue** with body preview
- Webhook handler: parse labels, body, html_url; handle issues.closed
…kflow

PR status-changed events now hit /hooks/pr-review (OpenClaw agent action)
instead of /hooks/wake, enabling forced auto-review sessions.
Other events still use /hooks/wake.
- Sink sends review prompt with PR content for /hooks/pr-review
- Check batched event_kinds for PR detection
- Add issues.reopened and pull_request.reopened handlers
Issue opened events now route to /hooks/issue-triage (agent action)
for automatic worker spawn, alongside PR review routing.
…us-file diff

- base_payload now includes 'path' as alias of state_file (fixes {path} template not resolving)
- read_json falls back to Value::String for plain-text files (.status-file, .close-status)
- new diff_status_tag_file handler for .status-file and .close-status
- recognizes both STATUS: and CLOSE: prefixes (CONTINUE/BLOCKED/DONE)
- emits workspace.status.<name>.<state> events
Mirror the OpenClawSink pattern but target IYENsystem's single
`POST /event` endpoint instead of branching by hooks path. The sink
maps a clawhip `SinkMessage` onto IYENsystem's `IncomingEvent` shape:

  {
    "event_type": "<clawhip event_kind, e.g. github.issue-opened>",
    "repo":       "<payload.repo>",
    "number":     <payload.number>,
    "action":     "<payload.action OR derived from kind suffix>",
    "payload":    <full clawhip payload, sender/label fields included>
  }

The full payload is forwarded verbatim so IYENsystem's SafetyPolicy
gate can read `payload.sender.login` and `payload.label.name` for
label-trigger actor/label allowlist checks. Existing clawhip GitHub
source emissions (`{repo, number, title, ...}`) flow through as-is;
when clawhip is later extended with sender/label-aware emissions, the
sink already passes them through without further changes.

Wiring matches OpenClawSink across the existing surfaces:
  - `[providers.iyensystem] url, auth_token` in config.toml
  - `IyenSystemConfig::is_configured()` requires both fields non-empty
  - `SinkTarget::IyenSystem` variant; non-iyensystem clients return a
    typed error rather than panicking
  - Route validator accepts `sink = "iyensystem"` only when the
    provider block is configured
  - Daemon registers the sink when `is_configured()` and prints a
    confirmation line on boot
  - Existing `ProvidersConfig` struct literals in tests gain
    `..ProvidersConfig::default()` so adding a new provider field
    doesn't keep cascading test changes

Five unit tests cover the new surface: configuration gate (both
non-empty required), endpoint URL trimming, full body shape, explicit
`payload.action` precedence over derived action, and safe defaults
when payload omits `repo`/`number`. The tests don't hit the network —
the request path is exercised at integration time once IYENsystem's
side is wired against this sink.
Three clippy errors were tripping `cargo clippy -- -D warnings` on the
CI Clippy job (and therefore the Build job depending on it):

  - src/source/opencode.rs:88  dead_code on SessionInfo.summary
  - src/source/opencode.rs:94  dead_code on SessionTime.created
  - src/source/opencode.rs:117 too_many_arguments on poll_opencode (8/7)

The two struct fields exist only to mirror the JSON shape returned by
opencode's `/session` endpoint — they're parsed for forward-compatibility
even though the daemon doesn't read them yet. Annotate the structs with
`#[allow(dead_code)]` and a one-line note pointing back to the upstream
shape, so future readers don't strip the attribute and re-introduce the
regression.

`poll_opencode` legitimately needs all eight arguments (client, base_url,
tx, state, idle threshold, plus three render hints). Threading them
through a wrapper struct adds noise without changing behavior, so a
local `#[allow(clippy::too_many_arguments)]` is the right tradeoff.

Also apply `cargo fmt --all` to satisfy the Format job — this rolls in
small mechanical reformattings across files that were already in the
tree (config, daemon, dispatch, render, router, sources). No behavioral
changes.

Verified locally:
  - cargo fmt --all -- --check  → clean
  - cargo clippy --all-targets -- -D warnings  → clean
  - cargo test --bin clawhip iyensystem  → 5/5 pass
New constructor for the `github.issues-labeled` event kind, payload
shape carefully matched to what IYENsystem's
`SafetyPolicy::label_trigger_allowed` reads:

  {
    "repo": "<repo_name>",
    "number": <issue_number>,
    "issue": { "title": "<issue_title>" },
    "label": { "name": "<label_name>" },
    "sender": { "login": "<actor_login>" }   // omitted when unknown
  }

Top-level `repo`/`number` are required by the IyenSystemSink mapper
(it lifts those fields when constructing the IYENsystem `/event`
body). Nested `issue.title`, `label.name`, `sender.login` mirror the
GitHub webhook payload shape so IYENsystem's policy gate can validate
the (actor, label) pair without re-fetching from GitHub.

The constructor takes `sender_login: Option<String>` so callers that
can't resolve the labeler (offline mode, events API failure) can still
emit the event — IYENsystem will then drop it via its own policy
gate, which is the correct fail-closed behavior.

C2 of the labels-polling PR series: prerequisite for the
`source/github.rs` changes that detect new labels and call this
constructor.
The GitHub source already snapshots `IssueSnapshot.labels` on every
poll. Diff the previous snapshot's label set against the current one
inside `collect_issue_events`: for each label that appears in current
but was missing in previous, emit a `github.issues-labeled` event so
clawhip's IyenSystemSink can forward it to IYENsystem's label
trigger.

To attribute the labeling to the correct actor (which IYENsystem's
SafetyPolicy uses to decide whether to honor or drop the trigger),
fetch the issue's events feed (`GET /repos/.../issues/{n}/events`)
when at least one label is new and look for the most recent
`labeled` action carrying that exact label name. The fetch is best-
effort: failures yield `sender.login = None` and IYENsystem will
drop the event via its allow-list, which is the correct fail-closed
behavior. Most poll cycles add zero labels, so the events-API call
is rare in practice.

The fetch keeps `collect_issue_events` async; threading the
`reqwest::Client`, `api_base`, and `github_repo` through the
function is straightforward because `poll_issues` already has them.
Existing baseline/warmup logic (`previous.issues_ready`) keeps
working unchanged: on cold start the warmup branch suppresses all
issue events, including labeled ones, so a daemon restart on a repo
that already had the label won't replay the trigger.

Coverage:

  - `newly_added_label_emits_issues_labeled_event_with_actor_login`
    drives the full path against a mock TCP server that returns a
    GitHub events response with one `labeled` action by `openclaw-bot`,
    asserts the resulting event carries the correct kind, repo,
    number, label.name, sender.login, and issue.title.
  - `unchanged_labels_do_not_emit_labeled_event` regression-guards
    the case where prev/current label sets are equal — emits zero
    labeled events even when other fields change.
  - The two existing tests
    (`new_issue_events_apply_route_channel_and_mention_over_repo_monitor_channel`,
    `issue_comment_and_close_events_are_emitted`) became `tokio::test`
    and pass `(client, api_base, github_repo=None, ...)` — the None
    skips the events fetch and so they keep their pure-diff semantics.

15/15 source::github tests pass; clippy `-D warnings` clean.

C1 of the labels-polling PR series.
…te example

Add `github.issues-labeled` to the GitHub preset family list so
operators see the new event clawhip's GitHubSource emits when issue
labels change.

Add a route example under "Route contract" that forwards
`github.issues-labeled` to the iyensystem sink. The accompanying
comment explains the `payload.sender.login` / `payload.label.name`
contract IYENsystem's SafetyPolicy reads — without this hint, an
operator setting up the integration has to read both repos to figure
out why the trigger silently drops events.

C3 of the labels-polling PR series.
feat(source/github): poll labels and emit github.issues-labeled
…-submitted (#5)

Mirrors the labels-polling pattern: per-monitored-PR per-cycle GET
/repos/{owner}/{repo}/pulls/{n}/reviews, suppress the first cycle to
build a baseline, then emit IncomingEvent::github_pr_review_submitted
for any review id not seen in the previous snapshot. Gated by a new
opt-in emit_pr_reviews flag on GitRepoMonitor (default false).

State carrier reviews: HashMap<pr_number, HashMap<review_id,
ReviewSnapshot>>; ReviewSnapshot stores normalized state (approved /
changes_requested / commented / dismissed), body, and actor login
captured from the response. PENDING reviews are filtered out.

Forwards to IYENsystem so its ReviewResultHandler can decide on
merges; README documents the route example and adds the new event to
the GitHub family preset list.

Co-authored-by: changeroa <changeroa@gmail.com>
Hermes Agent (https://github.com/nousresearch/hermes-agent) plays the
same role as OpenClawSink — the decision authority for IYEN
label-driven workflows. clawhip routes a GitHub event here; Hermes
inspects the issue/PR, decides the lane (auto-fix / declined /
review / leave-for-human), and applies the GitHub label itself via
tool calling. clawhip never receives the decision back — the lane
label re-enters the system through GitHub → clawhip GitHubSource
→ IyenSystemSink, exactly like the OpenClaw flow.

We POST to /v1/runs (OpenAI Responses-compatible) because:
  - it returns 202 + run_id immediately and runs in the background,
    matching the fire-and-forget contract clawhip sinks use today
  - it lets Hermes do tool calling (GitHub label apply) inside the
    same run rather than forcing clawhip to parse a streaming reply
  - it doesn't tie the decision lifetime to the HTTP request

The default IYEN instructions list the canonical lane labels
(iyen:auto-fix / iyen:declined / iyen:review). Operators ship a
custom Hermes skill — see iyensystem/integrations/hermes-skill/
iyen-triage/SKILL.md for the canonical decision rubric.

Wiring:
  - SinkTarget::Hermes variant + match-arm coverage in discord/slack
    (peer clients reject Hermes targets explicitly)
  - HermesConfig in [providers.hermes] with base_url, auth_token,
    optional instructions/model overrides
  - validate() rejects routes with sink = "hermes" when
    [providers.hermes] is not configured
  - daemon.rs registers the sink only when configured, applying
    instructions/model overrides if present
  - router.rs accepts "hermes" as a sink label

Tests: 5 new unit tests in src/sink/hermes.rs covering
is_configured boundary cases, endpoint resolution, body shape
(instructions / input / metadata / stream), model pin-through,
override semantics, and a contract test that the default
instructions reference every iyen:* label.
Copy link
Copy Markdown

@changeroa changeroa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary: The PR head appears to introduce GitHub PR review polling and related events integration for clawhip, including new data models, fetchers, and tests. However, the PR title states: feat(sink): add hermes sink for OpenAI-compatible /v1/runs delivery, which does not map to the visible changes in this diff (the changes visible here align with PR reviews/CI polling rather than a Hermes/OpenAI sink integration).

Notes and suggestions:

  • Scope alignment: If the Hermes sink for OpenAI is indeed the intended target, please point to the Hermes sink module and the delivery path in the codebase. If not, consider aligning the PR title and description with the code changes (e.g., something like “feat(github): add PR review polling and CI status events”).
  • Error handling: The new logic fetches reviews, check-runs, and workflow runs. Ensure error paths gracefully degrade to the previous state instead of panicking or breaking the source loop.
  • Data model stability: Ensure newly introduced types (e.g., ReviewSnapshot, GitHubCISnapshot) serialize/deserialize consistently with existing state and that any optional fields have sane defaults.
  • Performance considerations: Multiple external API calls per PR can add latency and throttle. Consider caching, backoff strategies, or aggregating requests where feasible.
  • Testing: Review tests added for normalization and PR reviews. Ensure tests cover failure modes (HTTP errors, rate limiting) and do not rely on real network calls in CI unless properly mocked.
  • Logging: Current code emits a lot of eprintln logs for GitHub interactions. Gate verbose logging behind a debug/trace flag or redact sensitive details in production.
  • Documentation: Update developer/docs to reflect the new PR-review polling flow and data structures, including any configuration knobs (e.g., enabling/disabling PR review emission).

If you want, I can add targeted inline feedback once you confirm the precise area to annotate (and provide a quick walkthrough of any intended Hermes/OpenAI sink integration).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants