mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-04 03:49:25 +00:00
* fix(github): drop redundant pull_request_review_comment fan-out noise
GitHub fires one pull_request_review_comment webhook per inline comment
attached to a pull_request_review submission, in addition to the single
pull_request_review event for the review itself. A bot reviewer like
CodeRabbit commonly leaves 20-30 inline comments per review, flooding
the webhook with near-duplicate deliveries that carry nothing an agent
doesn't already have -- it fetches every inline comment itself via
`gh api` when it processes the parent pull_request_review event.
Filter these out in fanout_event() before the registry lookup / per-
agent loop, since the redundancy is a property of the event itself, not
of any specific agent binding. A companion comment is identified by
pull_request_review_id being set (it belongs to a review) and
in_reply_to_id being absent (it is not itself a reply within an
existing thread -- that case is a genuine new interaction and must
still fire).
* fix(channels): scope review-comment suppression to bindings that also see the review
The redundant pull_request_review_comment filter suppressed every
companion comment unconditionally, before the registry lookup even ran.
That premise only holds for a binding that also subscribes to
pull_request_review on the same repo -- events are opt-in per binding,
so a binding registered for pull_request_review_comment alone never
receives the parent review event and the companion comments were its
only delivery of the review's inline content. Suppressing those too was
a silent, total loss for that binding, not noise reduction.
Move the check into the per-agent loop so it only suppresses a matched
binding's companion comment when that same binding also has an active
pull_request_review trigger on this repo, reusing the existing registry
lookup rather than re-walking bindings by hand. Bindings subscribed to
pull_request_review_comment alone now always fire, matching the
opt-in-per-binding contract documented in triggers.py.
* fix(channels): close require_mention gap in review-comment redundancy gate
willem-bd's second review round found that the per-binding redundancy gate
(commit b3791b33) treated a binding as "covered" purely by checking whether
it also registers a `pull_request_review` trigger on this repo, ignoring
whether that trigger itself requires a mention. A `pull_request_review_comment`
payload never carries the paired review's own top-level body, so there is no
way to verify from a comment delivery whether that trigger's own
`require_mention` check would actually pass. A human `@mention` living only
in one inline comment (not the review summary) could therefore be lost
twice: the review event filtered out by its own `no_mention` gate, and the
one inline comment that carries the mention dropped here as "redundant" --
the same silent-loss shape as the original bug, through a narrower path.
The gate now also requires the paired `pull_request_review` trigger's
resolved `require_mention` to be false before treating it as coverage,
trading a small amount of residual redundancy (an extra companion delivery
when the review would have fired anyway) for zero silent loss.
While in the same code, also addressed two smaller review notes:
- The redundant-comment skip reason now prefers the companion's own trigger
verdict when that verdict is also a skip (e.g. its own `require_mention`
independently fails), instead of always reporting the generic
`redundant_review_comment` label.
- `_pr_review_prompt` now tells the agent to fetch a review's inline
comments via `gh api .../reviews/{id}/comments` -- the redundancy gate's
suppression is only genuinely redundant if the agent actually recovers
that content from the parent review event, and nothing previously told it
to (zhfeng's review).
Tests: reproduces willem-bd's exact scenario (dual-subscribed binding,
require_mention on the review trigger, mention present only in the inline
comment) and confirms it fails on the prior code and passes with the fix;
adds a multi-agent-independence test and a skip-reason-precedence test;
adds prompt tests locking the new fetch-hint text and its missing-id guard.
Fail-before/pass-after verified via patch-file revert (not stash, to avoid
colliding with sibling worktrees).
223 lines
10 KiB
Python
223 lines
10 KiB
Python
"""Translate GitHub webhook payloads into prompts for the agent.
|
|
|
|
Each supported event has its own template. The output is a single
|
|
human-readable string fed in as a ``role: user`` message to the agent.
|
|
|
|
Design notes:
|
|
|
|
* The prompt is descriptive ("a PR was opened on …"), not imperative
|
|
("review this PR"), so the agent's SOUL.md gets to define behavior.
|
|
The dispatcher appends one terse instruction at the end so a stock
|
|
``lead_agent`` SOUL still does something useful.
|
|
* The channel layer is **log-only on the outbound path** — the agent's
|
|
final message goes to ``gateway.log`` and is NOT posted to GitHub.
|
|
If the agent wants to reply on the PR/issue, it must call ``gh`` (or
|
|
the equivalent REST API) **during** the run. We do not promise the
|
|
agent that "your final message will be posted." That used to be true;
|
|
it isn't any more.
|
|
* We embed the comment body verbatim because that's the most useful
|
|
signal — the agent needs to see what the human actually typed. We
|
|
do not try to escape it; agents understand markdown.
|
|
* We never include the raw payload JSON; that's noise.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
|
|
def _truncate(text: str | None, limit: int = 4000) -> str:
|
|
"""Trim long fields so a single bad payload doesn't blow the context window."""
|
|
if not text:
|
|
return ""
|
|
if len(text) <= limit:
|
|
return text
|
|
return text[: limit - 20] + "\n\n[…truncated…]"
|
|
|
|
|
|
def _pull_request_prompt(payload: dict[str, Any]) -> str:
|
|
pr = payload.get("pull_request") or {}
|
|
repo = (payload.get("repository") or {}).get("full_name") or "(unknown repo)"
|
|
number = pr.get("number") or payload.get("number")
|
|
title = pr.get("title") or "(no title)"
|
|
author = (pr.get("user") or {}).get("login") or "(unknown)"
|
|
url = pr.get("html_url") or "(no url)"
|
|
action = payload.get("action") or "opened"
|
|
body = _truncate(pr.get("body"))
|
|
|
|
return (
|
|
f"A pull request was {action} on {repo}:\n\n"
|
|
f" #{number} {title}\n"
|
|
f" Author: {author}\n"
|
|
f" URL: {url}\n\n"
|
|
f"Description:\n{body or '(no description)'}\n\n"
|
|
f"Decide what action (if any) to take for this pull request and carry it out. "
|
|
f"Your final assistant message is for the run log only — it will NOT be "
|
|
f"posted to GitHub. If you want to reply on the PR, call `gh pr comment` "
|
|
f"(or `gh pr review`) yourself during the run."
|
|
)
|
|
|
|
|
|
def _render_parent_context(parent: dict[str, Any], kind: str) -> str:
|
|
"""Render the issue/PR the event hangs off as a header block.
|
|
|
|
``kind`` is ``"issue"`` or ``"pull request"`` — used in the heading
|
|
only. The webhook payload's ``issue``/``pull_request`` object already
|
|
carries the title, body, and author for the parent, so no extra API
|
|
call is needed for first-level context.
|
|
"""
|
|
title = parent.get("title") or "(no title)"
|
|
author = (parent.get("user") or {}).get("login") or "(unknown)"
|
|
url = parent.get("html_url") or "(no url)"
|
|
body = _truncate(parent.get("body"))
|
|
return f"Parent {kind}:\n Title: {title}\n Author: {author}\n URL: {url}\n\n Description:\n{body or '(no description)'}\n"
|
|
|
|
|
|
def _issue_comment_prompt(payload: dict[str, Any]) -> str:
|
|
repo = (payload.get("repository") or {}).get("full_name") or "(unknown repo)"
|
|
issue = payload.get("issue") or {}
|
|
number = issue.get("number")
|
|
is_pr = "pull_request" in issue
|
|
target = "pull request" if is_pr else "issue"
|
|
parent_block = _render_parent_context(issue, target)
|
|
comment = payload.get("comment") or {}
|
|
author = (comment.get("user") or {}).get("login") or "(unknown)"
|
|
url = comment.get("html_url") or "(no url)"
|
|
body = _truncate(comment.get("body"))
|
|
return (
|
|
f"A new comment was posted on {target} #{number} in {repo}.\n\n"
|
|
f"{parent_block}\n"
|
|
f"New comment:\n"
|
|
f" Author: {author}\n"
|
|
f" URL: {url}\n\n"
|
|
f" Body:\n{body or '(empty comment)'}\n\n"
|
|
f"Decide what action (if any) to take in response to this comment, in the context "
|
|
f"of the parent {target} above. Your final assistant message is for the run log "
|
|
f"only — it will NOT be posted to GitHub. If you want to reply, call "
|
|
f"`gh issue comment {number} --repo {repo} --body-file -` yourself during the run."
|
|
)
|
|
|
|
|
|
def _pr_review_comment_prompt(payload: dict[str, Any]) -> str:
|
|
repo = (payload.get("repository") or {}).get("full_name") or "(unknown repo)"
|
|
pr = payload.get("pull_request") or {}
|
|
number = pr.get("number")
|
|
parent_block = _render_parent_context(pr, "pull request")
|
|
comment = payload.get("comment") or {}
|
|
author = (comment.get("user") or {}).get("login") or "(unknown)"
|
|
path = comment.get("path") or "(unknown file)"
|
|
line = comment.get("line") or comment.get("original_line") or "?"
|
|
diff_hunk = _truncate(comment.get("diff_hunk"), limit=2000)
|
|
body = _truncate(comment.get("body"))
|
|
return (
|
|
f"A new review comment was posted on pull request #{number} in {repo}.\n\n"
|
|
f"{parent_block}\n"
|
|
f"Review comment:\n"
|
|
f" Author: {author}\n"
|
|
f" File: {path}:{line}\n\n"
|
|
f" Diff context:\n```\n{diff_hunk}\n```\n\n"
|
|
f" Body:\n{body or '(empty comment)'}\n\n"
|
|
f"Decide what action (if any) to take in response to this review comment, in the "
|
|
f"context of the parent pull request above. Your final assistant message is for the "
|
|
f"run log only — it will NOT be posted to GitHub. If you want to reply, call "
|
|
f"`gh pr comment {number} --repo {repo} --body-file -` yourself during the run."
|
|
)
|
|
|
|
|
|
def _pr_review_prompt(payload: dict[str, Any]) -> str:
|
|
repo = (payload.get("repository") or {}).get("full_name") or "(unknown repo)"
|
|
pr = payload.get("pull_request") or {}
|
|
number = pr.get("number")
|
|
parent_block = _render_parent_context(pr, "pull request")
|
|
review = payload.get("review") or {}
|
|
review_id = review.get("id")
|
|
state = review.get("state") or "(unknown state)"
|
|
author = (review.get("user") or {}).get("login") or "(unknown)"
|
|
body = _truncate(review.get("body"))
|
|
# This payload carries only the review's own top-level summary. Any
|
|
# inline comments the reviewer left arrive as SEPARATE
|
|
# `pull_request_review_comment` webhook deliveries, and the dispatcher
|
|
# suppresses those as redundant fan-out for a binding that (like this
|
|
# one) also listens for `pull_request_review` on this repo — see
|
|
# `dispatcher.py::_is_redundant_review_comment`. That suppression is
|
|
# only genuinely redundant if the agent actually recovers the inline
|
|
# content from here, so tell it how (PR #4131 review, Concern 1,
|
|
# zhfeng): without this, the filter and the prompt were working against
|
|
# each other — the filter suppressed the only events that carried the
|
|
# inline content, and nothing ever told the agent to go get it. Omitted
|
|
# when `review.id` (or the PR number) is missing/malformed so the
|
|
# instruction never renders a broken `gh api` path.
|
|
fetch_hint = ""
|
|
if review_id is not None and number is not None:
|
|
fetch_hint = f"This review's inline comments are not included in this message. Before deciding what to do, fetch them with `gh api repos/{repo}/pulls/{number}/reviews/{review_id}/comments`.\n\n"
|
|
return (
|
|
f"A pull request review was submitted on #{number} in {repo}.\n\n"
|
|
f"{parent_block}\n"
|
|
f"Review:\n"
|
|
f" Reviewer: {author}\n"
|
|
f" State: {state}\n\n"
|
|
f" Body:\n{body or '(no review body)'}\n\n"
|
|
f"{fetch_hint}"
|
|
f"Decide what action (if any) to take in response to this review, in the context "
|
|
f"of the parent pull request above. Your final assistant message is for the run "
|
|
f"log only — it will NOT be posted to GitHub. If you want to reply (or push a fix), "
|
|
f"call `gh pr comment {number} --repo {repo} --body-file -` (and the usual "
|
|
f"`git clone` / `gh pr checkout` / `git push` flow for code changes) yourself "
|
|
f"during the run."
|
|
)
|
|
|
|
|
|
def _issues_prompt(payload: dict[str, Any]) -> str:
|
|
repo = (payload.get("repository") or {}).get("full_name") or "(unknown repo)"
|
|
issue = payload.get("issue") or {}
|
|
number = issue.get("number")
|
|
title = issue.get("title") or "(no title)"
|
|
author = (issue.get("user") or {}).get("login") or "(unknown)"
|
|
url = issue.get("html_url") or "(no url)"
|
|
body = _truncate(issue.get("body"))
|
|
action = payload.get("action") or "opened"
|
|
return (
|
|
f"An issue was {action} on {repo}:\n\n"
|
|
f" #{number} {title}\n"
|
|
f" Author: {author}\n"
|
|
f" URL: {url}\n\n"
|
|
f"Description:\n{body or '(no description)'}\n\n"
|
|
f"Decide what action (if any) to take for this issue and carry it out. "
|
|
f"Your final assistant message is for the run log only — it will NOT be "
|
|
f"posted to GitHub. If you want to reply on the issue (or open a PR), call "
|
|
f"`gh issue comment {number} --repo {repo} --body-file -` / `gh pr create` "
|
|
f"yourself during the run."
|
|
)
|
|
|
|
|
|
def _ping_prompt(payload: dict[str, Any]) -> str:
|
|
# Ping events arrive when a webhook is first installed. We don't
|
|
# normally fire on them but include a template for completeness.
|
|
zen = payload.get("zen") or "(no zen)"
|
|
hook_id = (payload.get("hook") or {}).get("id")
|
|
return f"GitHub sent a ping event. zen={zen!r} hook_id={hook_id}\n\nNo action required."
|
|
|
|
|
|
_EVENT_BUILDERS: dict[str, Any] = {
|
|
"ping": _ping_prompt,
|
|
"pull_request": _pull_request_prompt,
|
|
"issue_comment": _issue_comment_prompt,
|
|
"pull_request_review_comment": _pr_review_comment_prompt,
|
|
"pull_request_review": _pr_review_prompt,
|
|
"issues": _issues_prompt,
|
|
}
|
|
|
|
|
|
def build_prompt(event: str, payload: dict[str, Any]) -> str:
|
|
"""Return the prompt string for a webhook delivery.
|
|
|
|
Unknown events get a generic stub so the dispatcher can still kick
|
|
off a run without crashing — useful when a new event type is
|
|
enabled before this module is updated.
|
|
"""
|
|
builder = _EVENT_BUILDERS.get(event)
|
|
if builder is None:
|
|
repo = (payload.get("repository") or {}).get("full_name") or "(unknown repo)"
|
|
return f"GitHub event {event!r} fired on {repo}. action={payload.get('action')!r}"
|
|
return builder(payload)
|