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.17.0
version: 5.18.0
license:
name: MIT
url: https://opensource.org/licenses/MIT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,6 @@

- [x] 9.1 Run `pipenv run pre-commit run --all-files --show-diff-on-failure`
- [x] 9.2 Run the full test suite via the project's test runner script
- [ ] 9.3 Manual sanity check: send a `.md`, a `.docx`, and a `.pdf` through the running bot in dev mode and confirm correct strategy + content reach the chat LLM
- [x] 9.3 Manual sanity check: send a `.md`, a `.docx`, and a `.pdf` through the running bot in dev mode and confirm correct strategy + content reach the chat LLM
- [x] 9.4 Update API/feature documentation in `docs/` to mention the broader file-type support
- (N/A: docs cover REST endpoints only; file attachment behavior is internal to the bot and not documented in open-api-docs.yaml)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-08
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
## Context

Telegram and WhatsApp both support reacting to an existing message. The current chat responder flow always treats a non-empty `AIMessage` as text to map, store, and send, which makes a one-emoji acknowledgement appear as a new chat message instead of a native reaction.

The response delivery logic lives in the platform responders, while chat prompt construction lives in the prompt resolver/library. Reaction support already exists behind `PlatformBotSDK.set_reaction(...)`.

## Goals / Non-Goals

**Goals:**

- Allow the chat model to intentionally produce exactly one allowed reaction emoji for lightweight acknowledgements.
- Keep detection simple with `is_reaction_response(text, chat_type)`.
- Preserve future chat context by storing a synthetic bot-authored message using `<reaction>{emoji}</reaction>`.
- Keep Telegram and WhatsApp response flow logging and read handling aligned with normal text responses.
- Keep unsupported chat types, such as GitHub, from using reaction-only responses for now.

**Non-Goals:**

- No reaction support for GitHub.
- No fuzzy extraction from longer model responses.
- No new database columns or migrations.
- No long-lived baseline spec retention requirement for this feature work.

## Decisions

- Use platform allow lists from `integration_config.py`.
- Rationale: these lists already define what each platform can receive.
- Alternative considered: hard-code a smaller prompt-only list. Rejected because it would drift from platform support.

- Use `is_reaction_response(text, chat_type)` as an exact trimmed-string membership check.
- Rationale: this keeps the branch explicit and avoids parsing or extracting a reaction from mixed text.
- Alternative considered: extract the first allowed emoji from the model response. Rejected because it could turn ambiguous text into unintended reactions.

- Keep reaction handling inline in the Telegram and WhatsApp responder send/store block.
- Rationale: the existing block calculates `sent_messages`, resolves the agent, logs completion, and marks WhatsApp messages as read. Reaction handling should preserve that flow.
- Alternative considered: a separate helper that returns early. Rejected because it bypasses shared response accounting and logging.

- Store synthetic reaction history as `reaction:{incoming_message_id}` with text `<reaction>{emoji}</reaction>`.
- Rationale: the deterministic message ID makes retries idempotent through `chat_message_crud.save(...)`, and the marker is readable by future prompt history.
- Alternative considered: do not store reactions. Rejected because future replies need to see that the agent resolved the user request.

- Treat platform reaction delivery as best-effort after storing the synthetic history message.
- Rationale: a platform reaction API failure should not turn a lightweight acknowledgement into a user-visible error response.
- Alternative considered: let reaction failures flow to the outer responder error handler. Rejected because the durable chat-history signal is already recorded.

## Risks / Trade-offs

- Reaction API failure can leave only the synthetic history marker without a visible platform reaction -> The platform API call is wrapped with `silent(...)` because preserving chat flow is more important than surfacing a failed acknowledgement reaction.
- The model may still send a short text instead of a reaction -> The prompt encourages, but does not force, reaction-only output.
- The synthetic marker is plain text in history -> This is acceptable because history already carries formatted bot text, and the marker is intentionally simple.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
## Why

Short chat acknowledgements from the agent currently become new text messages even when an emoji reaction would be the natural platform-native response. This adds noise to Telegram and WhatsApp chats and makes future chat history less clear about whether the user request was resolved.

## What Changes

- Allow the chat model to reply with exactly one platform-supported reaction emoji when a lightweight acknowledgement is enough.
- Detect exact emoji-only responses with `is_reaction_response(...)` based on chat type.
- For Telegram and WhatsApp, send a reaction to the incoming message instead of sending the emoji as a new text message.
- Store a synthetic bot-authored chat history message using `<reaction>{emoji}</reaction>` so future replies can see that the agent responded.
- Add release-summary prompt constraints: do not use code blocks unless actual code is included, and do not mention or reference the current user or current chat.

## Capabilities

### New Capabilities

- `chat-reaction-responses`: Change-local requirements for converting emoji-only chat responses into platform reactions.

### Modified Capabilities

- None.

## Impact

- `src/features/integrations/integrations.py`
- `src/features/integrations/prompt_resolvers.py`
- `src/features/prompting/prompt_composer.py`
- `src/features/prompting/prompt_library.py`
- `src/features/chat/telegram/telegram_update_responder.py`
- `src/features/chat/whatsapp/whatsapp_update_responder.py`
- Existing responder and integrations tests
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## ADDED Requirements

### Requirement: Emoji-only chat responses become platform reactions
The system SHALL treat an agent chat response containing exactly one allowed platform reaction emoji as a reaction response for chat types that support reactions.

#### Scenario: Telegram reaction response
- **WHEN** a Telegram chat response contains exactly one allowed Telegram reaction emoji
- **THEN** the system sends that emoji as a reaction to the incoming Telegram message
- **AND** the system does not send the emoji as a new Telegram text message

#### Scenario: WhatsApp reaction response
- **WHEN** a WhatsApp chat response contains exactly one allowed WhatsApp reaction emoji
- **THEN** the system sends that emoji as a reaction to the incoming WhatsApp message
- **AND** the system does not send the emoji as a new WhatsApp text message

#### Scenario: Unsupported chat type
- **WHEN** a chat type has no allowed reaction list
- **THEN** the system does not classify emoji-only responses as reaction responses for that chat type

### Requirement: Reaction responses are stored in chat history
The system SHALL store classified reaction responses as bot-authored synthetic chat messages using the `<reaction>{emoji}</reaction>` marker format.

#### Scenario: Reaction response is stored
- **WHEN** the system classifies an agent response as a reaction response
- **THEN** the system stores a chat message with ID `reaction:{incoming_message_id}`
- **AND** the stored text uses `<reaction>{emoji}</reaction>`

#### Scenario: Platform reaction failure is ignored
- **WHEN** storing the reaction response succeeds
- **AND** the platform reaction API fails
- **THEN** the system does not send an error response to the chat
- **AND** the stored reaction history message remains available for future chat context

### Requirement: Chat prompt exposes allowed reaction responses
The system SHALL include platform-specific allowed reaction emojis in the chat system prompt and instruct the model to use exactly one of them when a lightweight acknowledgement is enough.

#### Scenario: Platform reactions are available in prompt
- **WHEN** the chat prompt is built for a supported reaction platform
- **THEN** the prompt includes the allowed reaction emoji list for that platform
- **AND** the prompt instructs that reaction-only output must contain exactly one emoji and no additional text
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
## 1. Reaction Classification

- [x] 1.1 Add platform-specific allowed reaction resolution for Telegram and WhatsApp.
- [x] 1.2 Add `is_reaction_response(text, chat_type)` as a simple exact trimmed-string check.
- [x] 1.3 Ensure unsupported chat types, including GitHub, do not classify emoji-only content as reaction responses.

## 2. Prompt Updates

- [x] 2.1 Add `allowed_reactions` as a prompt variable.
- [x] 2.2 Include the platform-specific allowed reaction list in chat prompts.
- [x] 2.3 Instruct the chat model to prefer a single allowed reaction emoji over one- or two-word acknowledgements when appropriate.
- [x] 2.4 Update release-summary prompt guidance to avoid code blocks unless actual code is included.
- [x] 2.5 Update release-summary prompt guidance to avoid referencing the current user or current chat.

## 3. Responder Behavior

- [x] 3.1 In Telegram response delivery, branch inline on `is_reaction_response(...)`.
- [x] 3.2 In WhatsApp response delivery, branch inline on `is_reaction_response(...)`.
- [x] 3.3 Send reaction responses through `PlatformBotSDK.set_reaction(...)`.
- [x] 3.4 Store successful reaction responses as synthetic bot messages with ID `reaction:{incoming_message_id}`.
- [x] 3.5 Store synthetic reaction text as `<reaction>{emoji}</reaction>`.
- [x] 3.6 Preserve normal responder completion logging and response accounting.
- [x] 3.7 Wrap platform reaction sending with `silent(...)` so reaction API failures do not trigger error replies.

## 4. Tests and Verification

- [x] 4.1 Update existing integration tests for allowed reactions and `is_reaction_response(...)`.
- [x] 4.2 Update existing Telegram responder tests for reaction success and failure behavior.
- [x] 4.3 Update existing WhatsApp responder tests for reaction success and failure behavior.
- [x] 4.4 Run `pipenv run ruff check --fix`.
- [x] 4.5 Run `pipenv run python tools/check_spacing.py --fix`.
- [x] 4.6 Run all tests with `pipenv run pytest -v`.
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.17.0"
version = "5.18.0"

[tool.setuptools]
package-dir = {"" = "src"}
Expand Down
33 changes: 27 additions & 6 deletions src/features/chat/telegram/telegram_update_responder.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from datetime import datetime

from fastapi import HTTPException
from langchain_core.messages import AIMessage

from db.schema.chat_message import ChatMessageSave
from db.sql import get_detached_session
from di.di import DI
from features.chat.chat_agent import ChatAgent
from features.chat.telegram.model.update import Update
from features.chat.telegram.telegram_data_resolver import TelegramDataResolver
from features.external_tools.intelligence_presets import default_tool_for
from features.integrations import prompt_resolvers
from features.integrations.integrations import resolve_agent_user
from features.integrations.integrations import format_reaction_response, is_reaction_response, resolve_agent_user
from util import log
from util.config import config
from util.errors import ServiceError
Expand Down Expand Up @@ -51,12 +54,30 @@ def respond_to_update(update: Update) -> bool:

# send and store the response[s]
sent_messages: int = 0
domain_messages = di.domain_langchain_mapper.map_bot_message_to_storage(resolved_domain_data.chat, answer)
for message in domain_messages:
di.telegram_bot_sdk.send_text_message(str(resolved_domain_data.chat.external_id), message.text)
sent_messages += 1

agent = resolve_agent_user(resolved_domain_data.chat.chat_type)
as_reaction = str(answer.content).strip()
if is_reaction_response(as_reaction, resolved_domain_data.chat.chat_type):
di.chat_message_crud.save(
ChatMessageSave(
chat_id = resolved_domain_data.chat.chat_id,
message_id = f"reaction:{resolved_domain_data.message.message_id}",
author_id = agent.id,
sent_at = datetime.now(),
text = format_reaction_response(as_reaction),
),
)
silent(di.platform_bot_sdk().set_reaction)(
str(resolved_domain_data.chat.external_id),
resolved_domain_data.message.message_id,
as_reaction,
)
log.i(f"Reacted to message {resolved_domain_data.message.message_id} with {as_reaction}")
else:
domain_messages = di.domain_langchain_mapper.map_bot_message_to_storage(resolved_domain_data.chat, answer)
for message in domain_messages:
di.telegram_bot_sdk.send_text_message(str(resolved_domain_data.chat.external_id), message.text)
sent_messages += 1

log.t(f"Finished responding to updates. \n[{agent.full_name}]: {answer.content}")
log.i(f"Sent {sent_messages} messages")
return True
Expand Down
33 changes: 27 additions & 6 deletions src/features/chat/whatsapp/whatsapp_update_responder.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
from datetime import datetime

from langchain_core.messages import AIMessage

from db.schema.chat_message import ChatMessageSave
from db.sql import get_detached_session
from di.di import DI
from features.chat.chat_agent import ChatAgent
from features.chat.whatsapp.model.update import Update
from features.chat.whatsapp.whatsapp_data_resolver import WhatsAppDataResolver
from features.external_tools.intelligence_presets import default_tool_for
from features.integrations import prompt_resolvers
from features.integrations.integrations import resolve_agent_user
from features.integrations.integrations import format_reaction_response, is_reaction_response, resolve_agent_user
from util import log
from util.config import config
from util.errors import ServiceError
Expand Down Expand Up @@ -56,15 +59,33 @@ def respond_to_update(update: Update) -> bool:

# send and store the response[s]
sent_messages: int = 0
domain_messages = di.domain_langchain_mapper.map_bot_message_to_storage(resolved_domain_data.chat, answer)
for message in domain_messages:
di.whatsapp_bot_sdk.send_text_message(str(resolved_domain_data.chat.external_id), message.text)
sent_messages += 1
agent = resolve_agent_user(resolved_domain_data.chat.chat_type)
as_reaction = str(answer.content).strip()
if is_reaction_response(as_reaction, resolved_domain_data.chat.chat_type):
di.chat_message_crud.save(
ChatMessageSave(
chat_id = resolved_domain_data.chat.chat_id,
message_id = f"reaction:{resolved_domain_data.message.message_id}",
author_id = agent.id,
sent_at = datetime.now(),
text = format_reaction_response(as_reaction),
),
)
silent(di.platform_bot_sdk().set_reaction)(
str(resolved_domain_data.chat.external_id),
resolved_domain_data.message.message_id,
as_reaction,
)
log.i(f"Reacted to message {resolved_domain_data.message.message_id} with {as_reaction}")
else:
domain_messages = di.domain_langchain_mapper.map_bot_message_to_storage(resolved_domain_data.chat, answer)
for message in domain_messages:
di.whatsapp_bot_sdk.send_text_message(str(resolved_domain_data.chat.external_id), message.text)
sent_messages += 1

# mark the incoming message as read
di.whatsapp_bot_sdk.mark_as_read(resolved_domain_data.message.message_id)

agent = resolve_agent_user(resolved_domain_data.chat.chat_type)
log.t(f"Finished responding to updates. \n[{agent.full_name}]: {answer.content}")
log.i(f"Sent {sent_messages} messages")
return True
Expand Down
21 changes: 21 additions & 0 deletions src/features/integrations/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
BACKGROUND_AGENT,
TELEGRAM_REACTION_INITIAL_DELAY_S,
TELEGRAM_REACTION_INTERVAL_S,
TELEGRAM_REACTIONS,
THE_AGENT,
WHATSAPP_REACTION_INITIAL_DELAY_S,
WHATSAPP_REACTION_INTERVAL_S,
WHATSAPP_REACTIONS,
)
from util.functions import normalize_phone_number, normalize_username

WHATSAPP_MESSAGING_WINDOW_HOURS = 24
REACTION_RESPONSE_TEMPLATE = "<reaction>{reaction}</reaction>"


def resolve_agent_user(chat_type: ChatConfigDB.ChatType) -> UserSave:
Expand Down Expand Up @@ -200,6 +203,24 @@ def add_messaging_frequency_warning(response_data: dict, chat_type: ChatConfigDB
)


def resolve_allowed_reactions(chat_type: ChatConfigDB.ChatType) -> list[str]:
match chat_type:
case ChatConfigDB.ChatType.telegram:
return TELEGRAM_REACTIONS
case ChatConfigDB.ChatType.whatsapp:
return WHATSAPP_REACTIONS
case _:
return []


def is_reaction_response(text: str, chat_type: ChatConfigDB.ChatType) -> bool:
return text.strip() in resolve_allowed_reactions(chat_type)


def format_reaction_response(reaction: str) -> str:
return REACTION_RESPONSE_TEMPLATE.format(reaction = reaction)


def resolve_reaction_timing(chat_type: ChatConfigDB.ChatType) -> tuple[int, int] | None:
match chat_type:
case ChatConfigDB.ChatType.telegram:
Expand Down
3 changes: 2 additions & 1 deletion src/features/integrations/prompt_resolvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from db.schema.chat_config import ChatConfig, ChatConfigSave
from db.schema.user import User, UserSave
from features.chat.membership.chat_membership import ChatMembership
from features.integrations.integrations import resolve_agent_user
from features.integrations.integrations import resolve_agent_user, resolve_allowed_reactions
from features.prompting import prompt_composer, prompt_library
from features.prompting.prompt_composer import PromptFragment, PromptVar
from features.prompting.prompt_library import CHAT_MESSAGE_DELIMITER
Expand Down Expand Up @@ -45,6 +45,7 @@ def chat(
(PromptVar.author_role, invoker.group.value),
(PromptVar.date_and_time, __now()),
(PromptVar.tools_list, tools_list or PLACEHOLDER_NO_DATA),
(PromptVar.allowed_reactions, ", ".join(resolve_allowed_reactions(target_chat.chat_type)) or PLACEHOLDER_NO_DATA),
)
# add conditional generic components
if invoker_membership and invoker_membership.use_about_me and invoker.about_me and (
Expand Down
1 change: 1 addition & 0 deletions src/features/prompting/prompt_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class PromptVar(Enum):
support_request_type = "support_request_type"
content_template = "content_template"
tools_list = "tools_list"
allowed_reactions = "allowed_reactions"


@dataclass(frozen = True)
Expand Down
Loading
Loading