feat(sink): add hermes sink for OpenAI-compatible /v1/runs delivery#6
Open
IYENTeam wants to merge 23 commits into
Open
feat(sink): add hermes sink for OpenAI-compatible /v1/runs delivery#6IYENTeam wants to merge 23 commits into
IYENTeam wants to merge 23 commits into
Conversation
- 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.
changeroa
reviewed
May 6, 2026
changeroa
left a comment
There was a problem hiding this comment.
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
HermesSinkso 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.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.mdanddocs/HERMES_ROLES.md).What ships
src/sink/hermes.rs(new, 312 LOC)src/sink/mod.rs::SinkTarget::Hermessrc/discord.rs,src/slack.rs(peer clients reject Hermes targets explicitly)src/dispatch.rs::sink_target_keysrc/config.rs::HermesConfigunder[providers.hermes](base_url, auth_token, optional instructions/model)src/config.rs::validaterejectssink = "hermes"routes when provider not configuredsrc/router.rsaccepts"hermes"as a sinksrc/daemon.rsregisters sink when[providers.hermes].is_configured(), applying optional overridesWhy
POST /v1/runs(not chat completions)OpenClawSink::senddrops the response body)What this sink deliberately does NOT do
These are stated as invariants in the module docstring so a future maintainer can't accidentally break the role separation.
Configuration example
Test plan
New tests cover:
is_configuredboundary cases (None, empty, whitespace-only)endpointstrips trailing slash and appends/v1/runsinstructions,input[].content[].text,metadata,stream: falsemodelpin-throughwith_instructionsoverride (default constant must NOT leak when overridden)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 = Ato Hermes, another routesrepo = Bto 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 calldocs/HERMES_ROLES.md— operator-facing role contract, parallel todocs/OPENCLAW_ROLES.mdexamples/config.toml—hermes-botpre-registered in[safety].system_actorsandallowed_label_actorsso swapping deciders doesn't require a config edit there