feat: PM-Agent also need to look at PR comments
gate / gate (pull_request) Successful in 2m58s
images / images (pull_request) Successful in 1m6s
gate / conformance (pull_request) Has been skipped

From Gitea issue #116.

Authored by the PM agent's worker; the merge gate is the approver. Refs #116.

Agent: pm-agent (issue #116, mode revise)
This commit is contained in:
pm-agent committed 2026-11-07 06:02:58 +00:00
1 parent 2fdb95393c
commit e8e9de8611
7 files changed
+471 -189

No files matched your search

+107 -72
View File
@@ -267,9 +267,8 @@ class Config:
self.pr_max_changed_files = env_int("PM_PR_MAX_CHANGED_FILES", 200)
# PM_PLAN: discover, assemble and PRINT, ask no model, post nothing. The dry-run mode
# for this path. PM_DRY_RUN implies it, because on the PR path the reply IS the
# action under test *and* it is the continuation marker, so posting it would make a
# dry run not dry. PM_PLAN also implies pr_mentions, since its whole purpose is to
# look before the knob is turned on.
# action under test, so posting it would make a dry run not dry. PM_PLAN also implies
# pr_mentions, since its whole purpose is to look before the knob is turned on.
self.pr_plan = env_bool("PM_PLAN", False)
if self.pr_plan:
self.pr_mentions = True
@@ -442,14 +441,16 @@ class Gitea:
WHY THIS AND NOT A SCAN. The operator asked for "a way to check for '@'s for the bot
account instead of scouring the entire pull request list and associated threads"
(issue #116). A mention makes Gitea subscribe the mentioned account to the thread and
raise a notification, and — the part that matters for thread continuation — the
subscription then raises one for EVERY later comment on that thread, with no second
mention needed. So the endpoint answers both halves of the ask with one bounded call.
raise a notification, so one bounded call names the pull requests worth reading.
It is a HINT, never the decision: this returns candidate pull-request indices, and
the mention/continuation decision is made by re-reading that PR's comments. Nothing
is marked read, so no state is consumed and a crash loses nothing (see
plan_pr_replies for the idempotency record that does the real work).
whether any of them holds an unanswered mention is decided by re-reading that PR's
comments (plan_pr_replies). That split is why over-delivery here is harmless — the
subscription keeps raising notifications for later comments on a pull request the bot
was once mentioned on, and those now resolve to nothing, because an explicit mention
is the only trigger. Nothing is marked read, so no state is consumed and a crash
loses nothing (see plan_pr_replies for the idempotency record that does the real
work).
"""
q = "/notifications?all=true&subject-type=pull"
if since_epoch > 0:
@@ -868,9 +869,28 @@ def last_activity(issue: dict, comments) -> tuple:
#
# A mention of the bot account in a pull request's conversation, or inside an inline review
# thread, makes the PM read that pull request — its description, its changed files, its diff
# and the thread being spoken to — and reply IN THAT THREAD. Once it has replied in a thread,
# later comments in the same thread continue the conversation with no second mention, which
# is what the operator asked for in issue #116 answer (4').
# and the thread being spoken to — and reply IN THAT THREAD.
#
# AN EXPLICIT MENTION IS THE ONLY TRIGGER. There is no continuation: a comment that does not
# name the bot is never actionable, however recently the bot was mentioned or replied nearby.
# The operator settled this on issue #116 — "since there is no threads and the PR comments
# are flat, then the pm-agent should be looking for explicit mentions rather than a single
# mention and continued scrapping of comments within the PR in perpetuity" — withdrawing the
# earlier "a thread that starts with a mention should continue" answer, which had been
# premised on comments being hierarchical. So a mention is a DISCRETE, SELF-LIMITING request:
# one `@pm-agent` -> one reply where it was written, and nothing else on the pull request is
# picked up. That is applied uniformly to conversation comments, review bodies and inline
# review comments; the inline ones are genuinely threaded in Gitea, but keeping continuation
# for them alone would be a second rule the operator did not ask for.
#
# Two things that were open questions under the old rule are closed by construction rather
# than by a new termination rule: there is no open-ended continuation left to terminate, and
# an automation that comments on a pull request the bot was once mentioned on draws no reply.
# What remains — and it is the whole residual — is that ANY non-bot author who writes the
# literal handle gets one reply, because the operator's answer (3) is "anyone can talk and
# have the agent work". A quoted mention counts as a mention: Gitea has no way to tell
# `> @pm-agent what do you think?` pasted into a quote from a fresh one, so re-quoting an
# earlier mention in a new comment costs one more reply.
#
# It may ONLY reply. The change-authoring half of that issue — revise an agent-owned branch,
# or open a fresh branch and PR against `main` for somebody else's — is a separate concern
@@ -898,8 +918,8 @@ def last_activity(issue: dict, comments) -> tuple:
# SO THE FAILURE MODE CHOSEN, EXPLICITLY, IS: at-least-once MODEL CALL, at-most-once REPLY.
# A mention is never silently dropped and a reply is never duplicated; the price is that a
# crash in the wrong second costs one more decision's spend. That is the right way round for
# this service — a duplicated reply is noise in the operator's thread AND sets the
# continuation marker twice, while PM_PR_COOLDOWN_SECONDS already bounds the cost of a repeat.
# this service — a duplicated reply is noise in the operator's thread, while
# PM_PR_COOLDOWN_SECONDS already bounds the cost of a repeat.
# The alternative (write a marker BEFORE asking the model) buys cheapness by making a crash
# swallow the mention with nothing in the thread saying so, which is the failure this
# repository keeps being burned by.
@@ -910,20 +930,20 @@ def last_activity(issue: dict, comments) -> tuple:
# run. A bug that let the bot's own replies count as triggers would be an unbounded loop that
# any passer-by could light. The self-authored check is therefore the FIRST thing
# plan_pr_replies does with each comment, it is structural (the bot's own comments can only
# ever CLEAR work, never create it), and the fixtures exercise it.
# ever CLEAR work, never create it), and the fixtures exercise it. Dropping continuation
# narrows this surface further: a reply is now reachable only from a comment that spells the
# handle out, so the bot's own output — which never writes `@<bot>` — is inert twice over.
MARKER_PREFIX = "pm-agent:replied-to="
MARKER_RE = re.compile(r"<!--\s*" + re.escape(MARKER_PREFIX) + r"([^\s>]*)\s*-->")
# What "the same thread" means here. Inline review comments have a native thread identity in
# Gitea: comments on the same (path, position) are one conversation in its UI. PR conversation
# comments are a FLAT LIST with no thread identity at all, so "the same thread" needs a
# policy — and this repository already has one, which is why nothing new was invented. The
# issue path's trigger is "act iff the newest thing in this thread was not written by me",
# with the whole flat comment list treated as one conversation (see last_activity and the file
# header). The same reading is applied here: a pull request's conversation is ONE thread, and
# once the PM has replied in it, later non-bot comments in it continue the conversation.
# Flagged on issue #116 for the operator to confirm, because it is a policy and not a fact.
# The thread identity, which is now about WHERE A REPLY GOES and nothing else. It stopped
# being part of the trigger when continuation was dropped: no comment is actionable because
# of its neighbours, so no policy is needed for what counts as "the same thread" — the
# operator's point that PR conversation comments are flat is precisely why. What is still
# needed is a destination, and the two destinations differ: a mention in the conversation (or
# in a review's own body, which Gitea renders in the conversation) is answered with a
# conversation comment, while a mention on a (path, position) is answered inline there.
CONVERSATION_THREAD = "conversation"
@@ -1007,10 +1027,11 @@ def normalize_pr_entries(comments, reviews, review_comments_by_review) -> list:
These carry a thread identity of their own.
Order is conversation-then-reviews rather than a merge sorted by timestamp, on purpose:
the repository's own rule is that lexicographic comparison of Gitea's RFC 3339 strings is
only correct while every offset matches (see last_activity), and getting a global order
wrong here would mis-decide continuation. Each resource is returned by Gitea in creation
order, and continuation is only ever judged WITHIN one thread, so per-resource order is
all this needs.
only correct while every offset matches (see last_activity). Nothing in the trigger reads
across comments now that an explicit mention is the only thing that fires, so order is
used for two things only — rendering the thread in the payload, and picking which plan is
taken first when several threads hold mentions — and Gitea returns each resource in
creation order, which is all either needs.
"""
out = []
for c in comments or []:
@@ -1050,15 +1071,24 @@ def normalize_pr_entries(comments, reviews, review_comments_by_review) -> list:
def plan_pr_replies(bot_login: str, entries: list) -> list:
"""The whole trigger decision, as a PURE function over normalized comments.
THE RULE, in one line: reply to a comment iff it is not ours, it explicitly mentions us,
and no reply of ours already says it was answered. Nothing else is actionable — a comment
with no mention produces no reply and no model call no matter what sits above it, which is
what the operator asked for on issue #116 once it was established that PR comments are
flat rather than hierarchical.
Pure because this is the part that must be provable offline: it holds the loop guard, the
idempotency skip and the continuation policy, and none of the three can be exercised
against a live Gitea without first risking the behaviour they prevent. The fixtures in
mention match and the idempotency skip, and none of the three can be exercised against a
live Gitea without first risking the behaviour they prevent. The fixtures in
infra/agent/testdata/pr-mention-fixtures.json drive it.
Returns one plan per thread that needs a reply, NEWEST-ANCHORED FIRST:
Returns one plan per thread holding an unanswered mention, NEWEST-ANCHORED FIRST:
{thread, reason, keys, anchor, anchor_index, triggers}
`keys` is every comment this one reply answers — a burst of three comments in a thread
gets ONE reply covering all three, not three replies.
`reason` is always "mention" — the field is kept because it is what the log line and
PM_PLAN print as "why you are running", and a second value would mean a second trigger.
`keys` is every unanswered MENTION in that thread, so somebody who tags the bot twice in
one conversation before it answers gets ONE reply covering both, not two. Comments that
merely sit between them are not answered and not keyed.
"""
mre = mention_re(bot_login)
@@ -1076,51 +1106,36 @@ def plan_pr_replies(bot_login: str, entries: list) -> list:
plans = []
for thread, items in by_thread.items():
# "Engaged" = this thread is a conversation the PM is part of, so later comments in
# it need no second mention. ARMED BY THE MENTION, not by the PM's reply — the
# operator's wording on issue #116 is "a thread that STARTS WITH A MENTION should
# continue the conversation without subsequent mentions in follow-up questions as
# long as they remain in the same thread". The difference is not academic: somebody
# who types three comments in a row and tags the bot only in the first would, under
# the weaker reading, have the last two silently ignored until the PM happened to
# reply. Fixture (i) is that case.
#
# A marker in one of OUR OWN comments also arms it, which covers a thread whose
# arming mention has since been edited away or scrolled out of what was fetched.
engaged = False
triggers = []
trigger_indices = []
for i, e in items:
# ---- THE FIRST CHECK, AND THE ONE THAT MUST NEVER MOVE ----
# Our own words can clear work but can never create it. Everything downstream —
# the mention match AND the arming of continuation — is unreachable for a
# bot-authored comment, so a reply of ours that happens to contain our own
# handle cannot start a run. Fixture (c) is that case, and with no author gate
# on this trigger it is the whole loop guard.
# Our own words can clear work but can never create it. The mention match below
# is unreachable for a bot-authored comment, so a reply of ours that happens to
# contain our own handle cannot start a run. Fixture (c) is that case, and with
# no author gate on this trigger it is the whole loop guard.
if e["author"] == bot_login:
if markers_in(e["body"]):
engaged = True
continue
mentioned = bool(mre.search(e["body"]))
if mentioned:
engaged = True
if e["key"] in handled:
continue
if mentioned or engaged:
triggers.append(e)
trigger_indices.append(i)
# ---- AND THE ONLY WAY IN ----
# No `engaged`/armed state, by design: whatever came before this comment in the
# thread cannot make it actionable. See the section header for the operator's
# wording that withdrew continuation.
if not mre.search(e["body"]):
continue
triggers.append(e)
trigger_indices.append(i)
if not triggers:
continue
anchor_index = trigger_indices[-1]
plans.append({
"thread": thread,
"reason": "mention" if any(mre.search(t["body"]) for t in triggers)
else "continuation",
"reason": "mention",
"keys": [t["key"] for t in triggers],
"triggers": triggers,
# Reply to the NEWEST triggering comment: it is the one that asked.
# Reply to the NEWEST unanswered mention: it is the one that asked most recently.
"anchor": triggers[-1],
"anchor_index": anchor_index,
"anchor_index": trigger_indices[-1],
})
plans.sort(key=lambda p: p["anchor_index"], reverse=True)
return plans
@@ -1186,14 +1201,15 @@ def render_pr_thread(cfg: Config, pr: dict, files, diff: str, plan: dict,
f"mergeable: {pr.get('mergeable')}",
f"draft: {pr.get('draft')}",
"",
f"why you are running: **{plan['reason']}** "
+ ("(you were mentioned by name)" if plan["reason"] == "mention"
else "(you have already replied in this thread, so follow-ups continue without "
"a new mention)"),
f"why you are running: **{plan['reason']}** (you were mentioned by name — that is "
"the ONLY thing that starts a run on a pull request, so somebody asked you this "
"deliberately)",
f"the thread you are replying in: "
+ (f"the inline review thread on `{anchor['path']}` (diff position "
f"{anchor['line']})" if is_inline else "the pull request's conversation"),
f"comments this reply answers: {', '.join(plan['keys'])}",
f"the mention(s) this reply answers: {', '.join(plan['keys'])}",
"everything else below is CONTEXT, not a question put to you: comments that do not "
"mention you are shown so you can read the discussion, and are not yours to answer.",
"",
"## What you may decide here",
"",
@@ -1480,7 +1496,8 @@ class Dispatcher:
if cfg.dry_run:
warn("PM_DRY_RUN is set: decisions are posted as comments, no worker Jobs are created")
if cfg.pr_mentions:
log(f"pull-request mentions: ON — @{cfg.bot_login} on a pull request triages it; "
log(f"pull-request mentions: ON — an explicit @{cfg.bot_login} on a pull request "
"triages it, and nothing else does; "
f"discovery {cfg.pr_discovery}, cooldown {cfg.pr_cooldown}s, "
f"max {cfg.pr_max_replies} reply/replies per cycle")
if cfg.pr_plan:
@@ -1900,9 +1917,10 @@ def selftest_pr_mentions(check) -> None:
"""The pull-request-mention trigger, driven by committed Gitea-shaped fixtures.
Everything asserted here is a control rather than a nicety: the self-authored filter is
the loop guard for a trigger with no author gate, the marker scan is the idempotency
record, and the continuation rule is the policy the operator asked for. None of the three
can be exercised safely against a live Gitea for the first time.
the loop guard for a trigger with no author gate, the mention pattern is now the ONLY
way in (so a false positive is a run nobody asked for and a false negative is a dropped
question), and the marker scan is the idempotency record. None of the three can be
exercised safely against a live Gitea for the first time.
"""
# -- the mention pattern, before any fixture ------------------------------
mre = mention_re("pm-agent")
@@ -1968,14 +1986,17 @@ def selftest_pr_mentions(check) -> None:
# -- the trigger decision, case by case ------------------------------------
bot = data["bot_login"]
mre = mention_re(bot) # re-bound from the fixture, so the two cannot drift apart
cases = data.get("cases") or []
check(len(cases) >= 6, f"fixtures cover at least the six required cases ({len(cases)})")
all_plans = []
for case in cases:
entries = normalize_pr_entries(
case.get("comments"),
case.get("reviews"),
{int(k): v for k, v in (case.get("review_comments") or {}).items()})
plans = plan_pr_replies(bot, entries)
all_plans += plans
got = [{"thread": p["thread"], "reason": p["reason"], "keys": p["keys"]}
for p in plans]
want = case["expect"]["threads"]
@@ -1984,6 +2005,20 @@ def selftest_pr_mentions(check) -> None:
print(f" want {json.dumps(want)}")
print(f" got {json.dumps(got)}")
# -- the trigger property itself, over EVERY fixture ----------------------
# Asserted across all cases rather than inside each one: "an explicit mention is the only
# trigger" is the operator's requirement, and a single fixture quietly expecting
# something else is exactly how a continuation rule would creep back in.
check(bool(all_plans), "fixtures: at least one case does produce a reply")
check(all(p["reason"] == "mention" for p in all_plans),
"trigger: every plan is a mention — there is no other reason a run can happen")
check(all(mre.search(t["body"]) for p in all_plans for t in p["triggers"]),
"trigger: every comment answered spells out the handle itself")
check(all(t["author"] != bot for p in all_plans for t in p["triggers"]),
"trigger: no bot-authored comment is ever answered")
check(all(t["key"] in p["keys"] for p in all_plans for t in p["triggers"]),
"trigger: the marker will name every comment the reply answers")
# -- the assembled payload, on a hostile pull request ----------------------
# The point of this one: a PR body and a diff are attacker-controlled text (anyone who
# can push a branch can write both), so neither may reach the model anywhere but inside a