Skip to content

fix: coalesce queued private chat replies - #10182

Open
94yi wants to merge 1 commit into
AstrBotDevs:masterfrom
94yi:fix/inbound-private-reply-coalescing
Open

94yi wants to merge 1 commit into
AstrBotDevs:masterfrom
94yi:fix/inbound-private-reply-coalescing

Conversation

@94yi

@94yi 94yi commented Sep 21, 2026

Copy link
Copy Markdown

变更\n- 合并同一私聊对象短时间内排队的相近入站消息\n- 避免处理延迟时夹入其他消息并产生多条相近回复\n- 保持与投递安全逻辑相互独立\n\n## 验证\n- Ruff 与相关回归测试通过

Summary by Sourcery

Coalesce eligible queued private messages into one timely response while preventing stale or terminated requests from reaching the LLM.

New Features:

  • Coalesce eligible queued private text messages from the same conversation into a single bounded reply prompt.

Bug Fixes:

  • Prevent stale queued messages, superseded requests, and session termination from triggering redundant or delayed LLM replies.

Enhancements:

  • Preserve separate handling for commands, non-text messages, and messages from different private conversations while retaining receive order in merged prompts.

Tests:

  • Add regression coverage for same-session coalescing, session isolation, stale backlog dropping, command exclusion, and termination before LLM requests.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py" line_range="380-392" />
<code_context>
+                    logger.warning("send_typing failed", exc_info=True)
             if await call_event_hook(event, EventType.OnWaitingLLMRequestEvent):
                 return
+            inbound_merge_registered = (
+                _inbound_reply_merger.register(event)
+                if inbound_merge_candidate
+                else False
+            )

             async with session_lock_manager.acquire_lock(event.unified_msg_origin):
+                if event.is_stopped() or event.get_extra("agent_stop_requested"):
+                    logger.info(
+                        "Session terminated while waiting for lock, skipping LLM request. umo=%s",
+                        event.unified_msg_origin,
+                    )
+                    return
+                if inbound_merge_registered:
+                    while True:
</code_context>
<issue_to_address>
**issue (bug_risk):** A registered message remains in `_InboundMergeState.pending` when the event is stopped while waiting for the session lock. The next message from the same UMO then merges the canceled message into its prompt and produces a reply for stale input.

**Triggers:** When an eligible private message is terminated after registration but before it acquires the session lock.

**Suggested fix:** Remove the event's pending message on every early return after registration, including termination and cancellation paths.
</issue_to_address>

### Comment 2
<location path="astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py" line_range="96" />
<code_context>
+    """Coalesce queued private texts without crossing conversation boundaries."""
+
+    def __init__(self) -> None:
+        self._states: dict[str, _InboundMergeState] = {}
+
+    def register(self, event: AstrMessageEvent) -> bool:
</code_context>
<issue_to_address>
**issue (bug_risk):** `_InboundReplyMerger._states` retains one `_InboundMergeState` for every UMO forever, even after its pending list is cleared. A long-running bot accumulates unbounded per-user state as distinct private contacts send eligible messages.

**Triggers:** When the bot receives eligible private messages from many distinct UMOs over its lifetime.

**Suggested fix:** Delete an empty state after its batch is finalized, while preserving any required sequence or timing state elsewhere with bounded cleanup.
</issue_to_address>

### Comment 3
<location path="astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py" line_range="196-205" />
<code_context>
+    selected: list[str] = []
+    used_chars = 0
+    omitted = 0
+    for message in reversed(messages):
+        text = message.text.strip()
+        projected = used_chars + len(text)
+        if (
+            len(selected) >= _INBOUND_MERGE_MAX_MESSAGES
+            or projected > _INBOUND_MERGE_MAX_CHARS
+        ):
+            omitted += 1
+            continue
+        selected.append(text)
+        used_chars = projected
+    selected.reverse()
</code_context>
<issue_to_address>
**issue (bug_risk):** The reverse selection loop drops the newest message when that message alone exceeds `_INBOUND_MERGE_MAX_CHARS`, then selects older messages instead. The resulting LLM prompt therefore omits the latest user input and answers stale content.

**Triggers:** When the newest queued message is longer than 6000 characters, or when the newest message cannot fit under the character limit.

**Suggested fix:** Always retain the newest message, truncating it or applying the limit after selecting the newest item rather than skipping it.

```suggestion
    for message in reversed(messages):
        text = message.text.strip()
        if not selected and len(text) > _INBOUND_MERGE_MAX_CHARS:
            text = text[:_INBOUND_MERGE_MAX_CHARS]
        projected = used_chars + len(text)
        if (
            len(selected) >= _INBOUND_MERGE_MAX_MESSAGES
            or projected > _INBOUND_MERGE_MAX_CHARS
        ):
            omitted += 1
            continue
        selected.append(text)
```
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 3 findings to address first, and if the merge bookkeeping or eligibility checks are wrong, queued private messages can be superseded or omitted, and a single altered LLM reply may be sent to the user before reverting can undo it. Reverting restores the previous per-event processing behavior, but it cannot retract replies already delivered or recover messages dropped by the new coalescing path.

Blocking findings: astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py:392, astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py:96, astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py:205


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment on lines +380 to +392
inbound_merge_registered = (
_inbound_reply_merger.register(event)
if inbound_merge_candidate
else False
)

async with session_lock_manager.acquire_lock(event.unified_msg_origin):
if event.is_stopped() or event.get_extra("agent_stop_requested"):
logger.info(
"Session terminated while waiting for lock, skipping LLM request. umo=%s",
event.unified_msg_origin,
)
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): A registered message remains in _InboundMergeState.pending when the event is stopped while waiting for the session lock. The next message from the same UMO then merges the canceled message into its prompt and produces a reply for stale input.

Triggers: When an eligible private message is terminated after registration but before it acquires the session lock.

Suggested fix: Remove the event's pending message on every early return after registration, including termination and cancellation paths.

"""Coalesce queued private texts without crossing conversation boundaries."""

def __init__(self) -> None:
self._states: dict[str, _InboundMergeState] = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): _InboundReplyMerger._states retains one _InboundMergeState for every UMO forever, even after its pending list is cleared. A long-running bot accumulates unbounded per-user state as distinct private contacts send eligible messages.

Triggers: When the bot receives eligible private messages from many distinct UMOs over its lifetime.

Suggested fix: Delete an empty state after its batch is finalized, while preserving any required sequence or timing state elsewhere with bounded cleanup.

Comment on lines +196 to +205
for message in reversed(messages):
text = message.text.strip()
projected = used_chars + len(text)
if (
len(selected) >= _INBOUND_MERGE_MAX_MESSAGES
or projected > _INBOUND_MERGE_MAX_CHARS
):
omitted += 1
continue
selected.append(text)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): The reverse selection loop drops the newest message when that message alone exceeds _INBOUND_MERGE_MAX_CHARS, then selects older messages instead. The resulting LLM prompt therefore omits the latest user input and answers stale content.

Triggers: When the newest queued message is longer than 6000 characters, or when the newest message cannot fit under the character limit.

Suggested fix: Always retain the newest message, truncating it or applying the limit after selecting the newest item rather than skipping it.

Suggested change
for message in reversed(messages):
text = message.text.strip()
projected = used_chars + len(text)
if (
len(selected) >= _INBOUND_MERGE_MAX_MESSAGES
or projected > _INBOUND_MERGE_MAX_CHARS
):
omitted += 1
continue
selected.append(text)
for message in reversed(messages):
text = message.text.strip()
if not selected and len(text) > _INBOUND_MERGE_MAX_CHARS:
text = text[:_INBOUND_MERGE_MAX_CHARS]
projected = used_chars + len(text)
if (
len(selected) >= _INBOUND_MERGE_MAX_MESSAGES
or projected > _INBOUND_MERGE_MAX_CHARS
):
omitted += 1
continue
selected.append(text)

@DFGHJ43

DFGHJ43 commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

你好!你做出的修改已经属于新功能而不是修复的范畴了,添加新功能之前最好在issue和其他开发者讨论一下再添加,而且其实已经有社区插件实现了合并临近消息的功能了,你可以看一看。

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