From a707e5c1275eabb13c45254a520ddd4a4151e968 Mon Sep 17 00:00:00 2001 From: Milos Marinkovic Date: Sat, 25 Apr 2026 14:26:33 +0200 Subject: [PATCH 1/2] Fix burst-aware mention detection and command handling The previous fix for burst processing used a too-narrow time window (now - chat_debounce_delay_s) when scanning for prior mentions, which always excluded the very messages it was meant to recover. Replaced it with a chain walk bounded by chat_history_depth that breaks on the bot's own reply or another invoker, so a tagged-then-untagged burst no longer silently drops. Known commands are skipped in the chain walk so a follow-up after /command@bot does not inherit the command's @-tag while the command's reply is still racing into the DB. Renamed the helper to __has_unanswered_bot_mention to match what it actually computes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/features/chat/chat_agent.py | 17 +-- src/features/chat/command_processor.py | 16 +++ .../features/chat/telegram/test_chat_agent.py | 107 ++++++++++++++++-- test/features/chat/test_command_processor.py | 45 +++++++- 4 files changed, 170 insertions(+), 15 deletions(-) diff --git a/src/features/chat/chat_agent.py b/src/features/chat/chat_agent.py index efcaa353..ee194116 100644 --- a/src/features/chat/chat_agent.py +++ b/src/features/chat/chat_agent.py @@ -2,7 +2,6 @@ import re import time from dataclasses import dataclass -from datetime import datetime, timedelta from typing import Any, TypeVar from langchain_core.language_models import LanguageModelInput @@ -10,6 +9,7 @@ from langchain_core.runnables import Runnable from di.di import DI +from features.chat.command_processor import is_known_command from features.external_tools.configured_tool import ConfiguredTool from features.external_tools.external_tool import ToolType from features.integrations import prompt_resolvers @@ -231,7 +231,7 @@ def __is_addressable(self) -> bool: is_bot_mentioned = bool(agent_handle) and f"@{agent_handle}" in self.__raw_last_message return self.__di.require_invoker_chat().is_private or is_bot_mentioned - def __is_bot_mentioned_in_burst(self, agent_handle: str | None) -> bool: + def __has_unanswered_bot_mention(self, agent_handle: str | None) -> bool: if not agent_handle: return False mention_token = f"@{agent_handle}" @@ -241,14 +241,17 @@ def __is_bot_mentioned_in_burst(self, agent_handle: str | None) -> bool: return False invoker_id = self.__di.invoker.id chat_id = self.__di.require_invoker_chat().chat_id - recent_messages = self.__di.chat_message_crud.get_latest_chat_messages(chat_id, limit = 5) - cutoff = datetime.now() - timedelta(seconds = config.chat_debounce_delay_s) + recent_messages = self.__di.chat_message_crud.get_latest_chat_messages(chat_id, limit = config.chat_history_depth) + # walk back through consecutive invoker messages; a non-invoker entry (the bot's + # own reply or another user) means the prior interaction was already answered. + # known commands are self-contained — their @-tag is syntax, not conversation — + # so we skip them rather than treating their tag as a pending mention. for message in recent_messages: if message.message_id == self.__last_message_id: continue if message.author_id != invoker_id: - continue - if message.sent_at < cutoff: + return False + if is_known_command(message.text, agent_handle): continue if mention_token in message.text: return True @@ -258,7 +261,7 @@ def should_reply(self) -> bool: chat_type = self.__di.require_invoker_chat_type() agent_user = resolve_agent_user(chat_type) agent_handle = resolve_external_handle(agent_user, chat_type) - is_bot_mentioned = self.__is_bot_mentioned_in_burst(agent_handle) + is_bot_mentioned = self.__has_unanswered_bot_mention(agent_handle) invoker_chat = self.__di.require_invoker_chat() if invoker_chat.reply_chance_percent == 100: should_reply_at_random = True diff --git a/src/features/chat/command_processor.py b/src/features/chat/command_processor.py index cb8f5765..cc9bec66 100644 --- a/src/features/chat/command_processor.py +++ b/src/features/chat/command_processor.py @@ -14,6 +14,22 @@ SUPPORTED_COMMANDS = [COMMAND_START, COMMAND_SETTINGS, COMMAND_HELP, COMMAND_CONNECT] +def is_known_command(raw_input: str | None, agent_handle: str | None) -> bool: + if not raw_input: + return False + parts = raw_input.split() + if not parts or not parts[0].startswith("/"): + return False + full_command_with_tag = parts[0] + if "@" in full_command_with_tag: + core_command, bot_tag = full_command_with_tag.split("@", 1) + if bot_tag != agent_handle: + return False + else: + core_command = full_command_with_tag + return core_command[1:] in SUPPORTED_COMMANDS + + class CommandProcessor: @dataclass diff --git a/test/features/chat/telegram/test_chat_agent.py b/test/features/chat/telegram/test_chat_agent.py index 823cd6a3..69c396e3 100644 --- a/test/features/chat/telegram/test_chat_agent.py +++ b/test/features/chat/telegram/test_chat_agent.py @@ -537,18 +537,111 @@ def test_should_reply_ignores_mention_from_different_invoker(self, mock_config): self.assertFalse(self.agent.should_reply()) @patch("features.chat.chat_agent.config") - def test_should_reply_ignores_mention_outside_burst_window(self, mock_config): + def test_should_reply_ignores_mention_after_bot_response(self, mock_config): self.chat_config.is_private = False self.chat_config.reply_chance_percent = 0 self.agent._ChatAgent__raw_last_message = "follow up" mock_config.chat_debounce_delay_s = 1.0 - stale_tagged = Mock() - stale_tagged.message_id = "msg_001" - stale_tagged.author_id = self.user.id - stale_tagged.sent_at = datetime.now() - timedelta(seconds = 5) - stale_tagged.text = f"Hello @{self.agent_user.telegram_username}" + mock_config.chat_history_depth = 30 + bot_reply = Mock() + bot_reply.message_id = "msg_002" + bot_reply.author_id = UUID(int = 999) # bot/non-invoker breaks the chain + bot_reply.sent_at = datetime.now() + bot_reply.text = "you're welcome" + old_tagged = Mock() + old_tagged.message_id = "msg_001" + old_tagged.author_id = self.user.id + old_tagged.sent_at = datetime.now() + old_tagged.text = f"Hello @{self.agent_user.telegram_username}" current = Mock() current.message_id = "msg_123" - self.mock_di.chat_message_crud.get_latest_chat_messages.return_value = [current, stale_tagged] + self.mock_di.chat_message_crud.get_latest_chat_messages.return_value = [current, bot_reply, old_tagged] self.assertFalse(self.agent.should_reply()) + + @patch("features.chat.chat_agent.config") + def test_should_reply_skips_chain_walk_when_debounce_disabled(self, mock_config): + # debounce=0 turns off burst coordination, so carry-over must not run — otherwise + # an untagged follow-up could double-respond alongside the still-running tagged + # message's instance (no chain-break in DB yet). + self.chat_config.is_private = False + self.chat_config.reply_chance_percent = 0 + self.agent._ChatAgent__raw_last_message = "follow up with no tag" + mock_config.chat_debounce_delay_s = 0.0 + mock_config.chat_history_depth = 30 + recent_tagged = Mock() + recent_tagged.message_id = "msg_001" + recent_tagged.author_id = self.user.id + recent_tagged.sent_at = datetime.now() + recent_tagged.text = f"Hello @{self.agent_user.telegram_username}" + current = Mock() + current.message_id = "msg_123" + self.mock_di.chat_message_crud.get_latest_chat_messages.return_value = [current, recent_tagged] + + self.assertFalse(self.agent.should_reply()) + # the chain walk must not even hit the DB when debounce is disabled + self.mock_di.chat_message_crud.get_latest_chat_messages.assert_not_called() + + @patch("features.chat.chat_agent.config") + def test_should_reply_direct_mention_works_when_debounce_disabled(self, mock_config): + # direct mention in the current message must always trigger a reply, even with + # the chain walk disabled by debounce=0 + self.chat_config.is_private = False + self.chat_config.reply_chance_percent = 0 + self.agent._ChatAgent__raw_last_message = f"hey @{self.agent_user.telegram_username}" + mock_config.chat_debounce_delay_s = 0.0 + mock_config.chat_history_depth = 30 + + self.assertTrue(self.agent.should_reply()) + self.mock_di.chat_message_crud.get_latest_chat_messages.assert_not_called() + + @patch("features.chat.chat_agent.config") + def test_should_reply_skips_command_message_in_burst(self, mock_config): + # Command messages tag the bot as part of syntax, not as a conversational mention. + # The chain walk must skip them so a follow-up does not inherit the command's tag, + # even when the command's bot reply is racing to land in the DB. + self.chat_config.is_private = False + self.chat_config.reply_chance_percent = 0 + self.agent._ChatAgent__raw_last_message = "hey guys what's up" + mock_config.chat_debounce_delay_s = 1.0 + mock_config.chat_history_depth = 30 + command_message = Mock() + command_message.message_id = "msg_001" + command_message.author_id = self.user.id + command_message.sent_at = datetime.now() + command_message.text = f"/help@{self.agent_user.telegram_username}" + current = Mock() + current.message_id = "msg_123" + self.mock_di.chat_message_crud.get_latest_chat_messages.return_value = [current, command_message] + + self.assertFalse(self.agent.should_reply()) + + @patch("features.chat.chat_agent.config") + def test_should_reply_carries_mention_from_seconds_old_burst_message(self, mock_config): + # Regression for prod bug: bot did not reply to msg3 (no tag) when msg2 (TAG) + # arrived within seconds. The should_reply call always happens after a debounce + # sleep, so any prior burst message is older than debounce_delay_s by definition + # — a cutoff of now - debounce_delay_s excludes exactly the messages we want to + # carry the mention from. + self.chat_config.is_private = False + self.chat_config.reply_chance_percent = 0 + self.agent._ChatAgent__raw_last_message = "follow up with no tag" + mock_config.chat_debounce_delay_s = 1.0 + mock_config.chat_history_depth = 30 + tagged_older = Mock() + tagged_older.message_id = "msg_002" + tagged_older.author_id = self.user.id + tagged_older.sent_at = datetime.now() - timedelta(seconds = 5) # older than debounce + tagged_older.text = f"@{self.agent_user.telegram_username} ova poruka treba da triggeruje odgovor" + earlier_untagged = Mock() + earlier_untagged.message_id = "msg_001" + earlier_untagged.author_id = self.user.id + earlier_untagged.sent_at = datetime.now() - timedelta(seconds = 60) + earlier_untagged.text = "Mislim da sam popravio" + current = Mock() + current.message_id = "msg_123" + self.mock_di.chat_message_crud.get_latest_chat_messages.return_value = [ + current, tagged_older, earlier_untagged, + ] + + self.assertTrue(self.agent.should_reply()) diff --git a/test/features/chat/test_command_processor.py b/test/features/chat/test_command_processor.py index 1d380186..51ea7082 100644 --- a/test/features/chat/test_command_processor.py +++ b/test/features/chat/test_command_processor.py @@ -11,7 +11,14 @@ from db.schema.chat_config import ChatConfig from db.schema.user import User, UserSave from di.di import DI -from features.chat.command_processor import COMMAND_CONNECT, COMMAND_HELP, COMMAND_SETTINGS, COMMAND_START, CommandProcessor +from features.chat.command_processor import ( + COMMAND_CONNECT, + COMMAND_HELP, + COMMAND_SETTINGS, + COMMAND_START, + CommandProcessor, + is_known_command, +) from features.connect.profile_connect_service import ProfileConnectService from features.integrations.integrations import resolve_agent_user from features.integrations.platform_bot_sdk import PlatformBotSDK @@ -91,6 +98,42 @@ def test_non_command_input(self): result = self.processor.execute("This is not a command") self.assertEqual(result.status, "ignored") + def test_is_known_command_rejects_empty_or_blank(self): + self.assertFalse(is_known_command("", "the_agent")) + self.assertFalse(is_known_command(None, "the_agent")) + self.assertFalse(is_known_command(" ", "the_agent")) + + def test_is_known_command_rejects_non_slash_input(self): + self.assertFalse(is_known_command("hello", "the_agent")) + self.assertFalse(is_known_command(f"hi /{COMMAND_HELP}", "the_agent")) # slash not first token + + def test_is_known_command_rejects_unknown_command(self): + self.assertFalse(is_known_command("/notacommand", "the_agent")) + self.assertFalse(is_known_command("/notacommand@the_agent", "the_agent")) + + def test_is_known_command_rejects_malformed_slash_tokens(self): + self.assertFalse(is_known_command("/", "the_agent")) + self.assertFalse(is_known_command("/@the_agent", "the_agent")) + self.assertFalse(is_known_command(f"/{COMMAND_HELP}@", "the_agent")) # empty tag part + + def test_is_known_command_accepts_all_supported_commands(self): + for command in (COMMAND_START, COMMAND_SETTINGS, COMMAND_HELP, COMMAND_CONNECT): + self.assertTrue(is_known_command(f"/{command}", "the_agent")) + self.assertTrue(is_known_command(f"/{command}@the_agent", "the_agent")) + + def test_is_known_command_accepts_command_with_trailing_args(self): + self.assertTrue(is_known_command(f"/{COMMAND_CONNECT} ABC-123", "the_agent")) + self.assertTrue(is_known_command(f"/{COMMAND_CONNECT}@the_agent ABC-123", "the_agent")) + + def test_is_known_command_rejects_known_command_with_wrong_tag(self): + self.assertFalse(is_known_command(f"/{COMMAND_HELP}@otherbot", "the_agent")) + + def test_is_known_command_handles_missing_agent_handle(self): + # untagged commands work even when the chat type has no resolvable agent handle + self.assertTrue(is_known_command(f"/{COMMAND_HELP}", None)) + # tagged commands require a real handle to match against; None can never match + self.assertFalse(is_known_command(f"/{COMMAND_HELP}@the_agent", None)) + def test_start_command_no_sponsorship(self): result = self.processor.execute(f"/{COMMAND_START}") self.assertEqual(result.status, "success") From 2d2e68851b4a0b93f7c1b1b4d979117982138c6f Mon Sep 17 00:00:00 2001 From: Milos Marinkovic Date: Sat, 25 Apr 2026 14:27:43 +0200 Subject: [PATCH 2/2] Bump version Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/open-api-docs.yaml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/open-api-docs.yaml b/docs/open-api-docs.yaml index d19a3203..52d5784e 100644 --- a/docs/open-api-docs.yaml +++ b/docs/open-api-docs.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: The Agent's user-facing API description: The user-facing parts of The Agent's API service (excluding system-level endpoints, chat completion, maintenance endpoints, etc.) - version: 5.9.1 + version: 5.9.2 license: name: MIT url: https://opensource.org/licenses/MIT diff --git a/pyproject.toml b/pyproject.toml index aa1a8d86..535ade29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "the-agent" -version = "5.9.1" +version = "5.9.2" [tool.setuptools] package-dir = {"" = "src"}