The gitea-events subscription woke the agent on every delivery. That is an
unbounded loop as soon as she is given a prompt that tells her to answer on
the PR: her answer is itself a pull_request_comment, which wakes her again.
Adds a Hermes route script that drops the deliveries that must never reach an
LLM call: luna's own comments (the loop guard), "deleted" actions (the body
is still in the payload, so acting on one means acting on a request that was
explicitly withdrawn), non-pull-request comments, empty bodies, and edits
that did not actually change the body — a label or attachment change fires
"edited" too. Everything else passes through unchanged.
Mounted READ-ONLY from the nix store rather than written into hermesHome.
Hermes resolves route scripts under ~/.hermes/scripts, which here is inside
/opt/data — HERMES_WRITE_SAFE_ROOT — so a filter written there would be a
loop guard sitting in the writable root of the agent it constrains. Deleting
it fails closed (Hermes treats a missing script as "ignore"), but rewriting
it to always-allow would silently restore the loop. Read-only from the store
makes that impossible and keeps the guard in git.
The script also normalises changes.body.from to always exist. Gitea omits
`changes` entirely on created events, and Hermes replaces the prompt payload
with whatever JSON the script emits, so guaranteeing the key here means a
prompt referencing {changes.body.from} renders empty instead of leaving an
unfilled placeholder.
Note the stdout contract (gateway/platforms/webhook.py): only exactly
"[SILENT]", empty output, or a nonzero exit drop a delivery. Any OTHER text
on stdout lets it through and is attached as script_output — so a stray
debug print would silently defeat the filter. All diagnostics go to stderr,
and gitea-pr-comment-filter-test.py asserts that discipline along with each
drop rule (25 cases). Run it after any edit: the fail-closed behaviour means
a syntax error produces silence, not an error.
--events is still unset; event selection remains runtime-tunable policy.
The filter covers only what must not be.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
108 lines
4.3 KiB
Python
108 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Hermes webhook filter for Gitea pull_request_comment deliveries.
|
|
|
|
Contract (gateway/platforms/webhook.py): the payload arrives on stdin as JSON.
|
|
STDOUT IS A PROTOCOL CHANNEL, not a log:
|
|
|
|
- exactly "[SILENT]" -> delivery ignored, no agent run, no tokens spent
|
|
- a JSON object -> REPLACES the payload used by the prompt template
|
|
- any other text -> delivery is ALLOWED THROUGH and the text is attached
|
|
as script_output
|
|
|
|
That last case is why every diagnostic here goes to stderr. A stray print()
|
|
would not drop an event, it would let one through.
|
|
|
|
Empty stdout, a nonzero exit, a missing script, or a timeout also count as
|
|
"ignored", so this script fails CLOSED: if it breaks, nothing reaches the
|
|
agent rather than everything. That is the right direction for a loop guard,
|
|
but it does mean a syntax error silently disables the whole integration --
|
|
run the test file next to this one after editing.
|
|
|
|
Two jobs:
|
|
|
|
1. Filter. Drop the deliveries that must never wake the agent -- above all
|
|
luna's own comments, which would otherwise loop forever: the prompt tells
|
|
her to reply on the PR, and her reply is itself a pull_request_comment.
|
|
2. Normalise. Guarantee changes.body.from always exists, so the prompt's
|
|
{changes.body.from} renders as empty rather than as an unfilled
|
|
placeholder on "created" events, where Gitea omits `changes` entirely.
|
|
"""
|
|
import json
|
|
import sys
|
|
|
|
# Comment authors whose comments must never wake the agent. luna is the agent
|
|
# herself (loop guard). Add "ci-bot" here if CI ever starts commenting on PRs
|
|
# and you do not want her reacting to build output.
|
|
IGNORED_AUTHORS = {"luna"}
|
|
|
|
# Gitea's HookIssueCommentAction values are created / edited / deleted.
|
|
# "deleted" is dropped: the payload still carries the comment body, so letting
|
|
# it through would have her act on a request that was explicitly withdrawn.
|
|
ALLOWED_ACTIONS = {"created", "edited"}
|
|
|
|
|
|
def ignore(reason: str) -> None:
|
|
print(f"gitea-pr-comment-filter: ignoring delivery: {reason}", file=sys.stderr)
|
|
print("[SILENT]")
|
|
raise SystemExit(0)
|
|
|
|
|
|
def main() -> None:
|
|
try:
|
|
payload = json.loads(sys.stdin.read())
|
|
except (ValueError, OSError) as exc:
|
|
ignore(f"unparseable payload: {exc}")
|
|
|
|
if not isinstance(payload, dict):
|
|
ignore("payload is not a JSON object")
|
|
|
|
comment = payload.get("comment") or {}
|
|
issue = payload.get("issue") or {}
|
|
action = (payload.get("action") or "").strip().lower()
|
|
author = ((comment.get("user") or {}).get("login") or "").strip()
|
|
|
|
if action not in ALLOWED_ACTIONS:
|
|
ignore(f"action={action or '<missing>'}")
|
|
|
|
if author.lower() in IGNORED_AUTHORS:
|
|
ignore(f"author={author} is the agent itself (loop guard)")
|
|
|
|
# Belt and braces: the route already filters to pull_request_comment, but
|
|
# if that filter is ever loosened this keeps issue comments out. Only
|
|
# enforced when the key is actually present.
|
|
if "is_pull" in payload and not payload.get("is_pull"):
|
|
ignore("not a pull request comment (is_pull=false)")
|
|
|
|
body = (comment.get("body") or "").strip()
|
|
if not body:
|
|
ignore("empty comment body")
|
|
|
|
# Gitea omits `changes` on created events and populates changes.body.from
|
|
# with the pre-edit text on edits. Normalise it to a plain string so the
|
|
# prompt template always resolves, and drop no-op edits (a label or
|
|
# attachment change can fire "edited" without touching the body).
|
|
changes = payload.get("changes") or {}
|
|
previous = ((changes.get("body") or {}).get("from") or "") if isinstance(changes, dict) else ""
|
|
if action == "edited":
|
|
if previous.strip() == body:
|
|
ignore("edited but comment body is unchanged")
|
|
if not previous.strip():
|
|
print(
|
|
"gitea-pr-comment-filter: edited delivery carries no previous body; "
|
|
"passing through so the agent can reconcile from the PR thread",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
payload["changes"] = {"body": {"from": previous}}
|
|
|
|
print(
|
|
"gitea-pr-comment-filter: allowing comment id=%s action=%s author=%s pr=%s"
|
|
% (comment.get("id"), action, author, issue.get("number")),
|
|
file=sys.stderr,
|
|
)
|
|
json.dump(payload, sys.stdout)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|