Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/open-api-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
17 changes: 10 additions & 7 deletions src/features/chat/chat_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
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
from langchain_core.messages import AIMessage, BaseMessage, SystemMessage, ToolMessage
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
Expand Down Expand Up @@ -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}"
Expand All @@ -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
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/features/chat/command_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 100 additions & 7 deletions test/features/chat/telegram/test_chat_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
45 changes: 44 additions & 1 deletion test/features/chat/test_command_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Loading