Add reaction tool with typing/placeholder cleanup

Introduces a new `reaction` tool that lets the LLM add an emoji reaction
to a Telegram message instead of sending a text reply. When the reaction
tool (or message tool) handles a turn, CleanupState is now called on the
channel manager so typing indicators and placeholder messages are properly
removed even though no outbound message is published via the bus.
This commit is contained in:
Dmitrii Balabanov 2026-03-09 19:38:35 +02:00
parent c1718da019
commit 81d606766a
16 changed files with 771 additions and 53 deletions

View file

@ -57,6 +57,23 @@
"allow_from": [
"YOUR_USER_ID"
],
"allowed_reaction_emoji": [
"👍",
"👎",
"❤️",
"🔥",
"🥰",
"👏",
"😁",
"🤔",
"🤯",
"😱",
"🤬",
"😢",
"🎉",
"🤩",
"🤮"
],
"reasoning_channel_id": ""
},
"discord": {
@ -432,6 +449,9 @@
"message": {
"enabled": true
},
"reaction": {
"enabled": true
},
"read_file": {
"enabled": true
},

View file

@ -69,6 +69,7 @@ type processOptions struct {
type agentResponse struct {
Content string
ReplyToMessageID string
HandledExternally bool
}
func (r agentResponse) outboundMessage(channel, chatID string) bus.OutboundMessage {
@ -200,6 +201,9 @@ func registerSharedTools(
})
agent.Tools.Register(messageTool)
}
if cfg.Tools.IsToolEnabled("reaction") {
agent.Tools.Register(tools.NewReactionTool([]string(cfg.Channels.Telegram.AllowedReactionEmoji)))
}
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
if cfg.Tools.IsToolEnabled("send_file") {
@ -359,30 +363,13 @@ func (al *AgentLoop) Run(ctx context.Context) error {
response = agentResponse{Content: fmt.Sprintf("Error processing message: %v", err)}
}
if response.HandledExternally {
// A direct tool action (message or reaction) already sent user-facing output.
// Stop typing and delete placeholder since no outbound message will trigger preSend.
if al.channelManager != nil {
al.channelManager.CleanupState(ctx, msg.Channel, msg.ChatID)
}
if response.Content != "" {
// Check if the message tool already sent a response during this round.
// If so, skip publishing to avoid duplicate messages to the user.
// Use default agent's tools to check (message tool is shared).
alreadySent := false
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent != nil {
if tool, ok := defaultAgent.Tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
alreadySent = mt.HasSentInRound()
}
}
}
if !alreadySent {
al.bus.PublishOutbound(ctx, response.outboundMessage(msg.Channel, msg.ChatID))
logger.InfoCF("agent", "Published outbound response",
map[string]any{
"channel": msg.Channel,
"chat_id": msg.ChatID,
"content_len": len(response.Content),
"reply_to_message_id": response.ReplyToMessageID,
})
} else {
logger.DebugCF(
"agent",
"Skipped outbound (message tool already sent)",
@ -394,6 +381,15 @@ func (al *AgentLoop) Run(ctx context.Context) error {
},
)
}
} else if response.Content != "" {
al.bus.PublishOutbound(ctx, response.outboundMessage(msg.Channel, msg.ChatID))
logger.InfoCF("agent", "Published outbound response",
map[string]any{
"channel": msg.Channel,
"chat_id": msg.ChatID,
"content_len": len(response.Content),
"reply_to_message_id": response.ReplyToMessageID,
})
}
}()
}
@ -417,6 +413,7 @@ func (al *AgentLoop) RegisterTool(tool tools.Tool) {
func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
al.channelManager = cm
al.bindAdvancedMessageManagers(cm)
al.bindReactionTools(cm)
}
// bindAdvancedMessageManagers wires up channel callbacks to any tools that
@ -442,6 +439,16 @@ func (al *AgentLoop) bindAdvancedMessageManagers(cm *channels.Manager) {
})
}
func (al *AgentLoop) bindReactionTools(cm *channels.Manager) {
al.registry.ForEachTool("reaction", func(t tools.Tool) {
if rt, ok := t.(*tools.ReactionTool); ok {
rt.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID, emoji string) error {
return cm.SetMessageReaction(ctx, channel, chatID, messageID, emoji)
})
}
})
}
// SetMediaStore injects a MediaStore for media lifecycle management.
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
al.mediaStore = s
@ -459,6 +466,39 @@ func (al *AgentLoop) SetTranscriber(t voice.Transcriber) {
al.transcriber = t
}
func (al *AgentLoop) agentTurnHandledByDirectToolAction(agent *AgentInstance) bool {
if agent == nil {
return false
}
if tool, ok := agent.Tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok && mt.HasSentInRound() {
return true
}
}
if tool, ok := agent.Tools.Get("reaction"); ok {
if rt, ok := tool.(*tools.ReactionTool); ok && rt.HasHandledInRound() {
return true
}
}
return false
}
func (al *AgentLoop) resetRoundActionTools(agent *AgentInstance) {
if agent == nil {
return
}
if tool, ok := agent.Tools.Get("message"); ok {
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
resetter.ResetSentInRound()
}
}
if tool, ok := agent.Tools.Get("reaction"); ok {
if resetter, ok := tool.(interface{ ResetHandledInRound() }); ok {
resetter.ResetHandledInRound()
}
}
}
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// transcribeAudioInMessage resolves audio media refs, transcribes them, and
@ -644,12 +684,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return agentResponse{}, routeErr
}
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
if tool, ok := agent.Tools.Get("message"); ok {
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
resetter.ResetSentInRound()
}
}
al.resetRoundActionTools(agent)
// Resolve session key from route, while preserving explicit agent-scoped keys.
scopeKey := resolveScopeKey(route, msg.SessionKey)
@ -847,16 +882,19 @@ func (al *AgentLoop) runAgentLoop(
// This is controlled by the tool's Silent flag and ForUser content
// 4. Handle empty response
if finalContent == "" {
directActionHandled := al.agentTurnHandledByDirectToolAction(agent)
if finalContent == "" && !directActionHandled {
finalContent = opts.DefaultResponse
}
response := resolveFinalResponse(opts.Channel, opts.ReplyContext, finalContent)
if response.Content == "" {
if response.Content == "" && !directActionHandled {
response.Content = opts.DefaultResponse
}
// 5. Save final assistant message to session
if response.Content != "" {
agent.Sessions.AddMessage(opts.SessionKey, "assistant", response.Content)
}
agent.Sessions.Save(opts.SessionKey)
// 6. Optional: summarization
@ -865,7 +903,7 @@ func (al *AgentLoop) runAgentLoop(
}
// 7. Optional: send response via bus
if opts.SendResponse {
if opts.SendResponse && response.Content != "" {
al.bus.PublishOutbound(ctx, response.outboundMessage(opts.Channel, opts.ChatID))
}
@ -879,6 +917,7 @@ func (al *AgentLoop) runAgentLoop(
"final_length": len(response.Content),
})
response.HandledExternally = directActionHandled
return response, nil
}
@ -1061,7 +1100,7 @@ func (al *AgentLoop) runLLMIteration(
})
// Build tool definitions
providerToolDefs := agent.Tools.ToProviderDefs()
providerToolDefs := agent.Tools.ToProviderDefsWithContext(ctx, opts.Channel, opts.ChatID)
// Log LLM request details
logger.DebugCF("agent", "LLM request",

View file

@ -438,6 +438,42 @@ func (m *taskToolRaceMockProvider) GetDefaultModel() string {
return "tasktool-race-mock-model"
}
type reactionToolMockProvider struct {
calls int
}
func (m *reactionToolMockProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
opts map[string]any,
) (*providers.LLMResponse, error) {
m.calls++
if m.calls == 1 {
return &providers.LLMResponse{
ToolCalls: []providers.ToolCall{
{
ID: "call_reaction",
Name: "reaction",
Arguments: map[string]any{
"emoji": "❤️",
},
},
},
}, nil
}
return &providers.LLMResponse{
Content: "",
ToolCalls: []providers.ToolCall{},
}, nil
}
func (m *reactionToolMockProvider) GetDefaultModel() string {
return "reaction-tool-mock-model"
}
type blockingSequentialTaskTool struct {
inner *tools.TaskTool
createOnce sync.Once
@ -946,6 +982,82 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
}
}
func TestReactionTool_SuppressesDefaultFinalResponse(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = tmpDir
cfg.Agents.Defaults.Model = "test-model"
cfg.Agents.Defaults.MaxTokens = 4096
cfg.Agents.Defaults.MaxToolIterations = 4
msgBus := bus.NewMessageBus()
provider := &reactionToolMockProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
helper := testHelper{al: al}
defaultAgent := al.registry.GetDefaultAgent()
tool, ok := defaultAgent.Tools.Get("reaction")
if !ok {
t.Fatal("expected reaction tool to be registered")
}
rt, ok := tool.(*tools.ReactionTool)
if !ok {
t.Fatalf("reaction tool type = %T", tool)
}
var calls int
rt.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID, emoji string) error {
calls++
if channel != "telegram" || chatID != "chat1" || messageID != "910" || emoji != "❤️" {
t.Fatalf("unexpected callback args channel=%q chatID=%q messageID=%q emoji=%q", channel, chatID, messageID, emoji)
}
return nil
})
response := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "user1",
ChatID: "chat1",
Content: "thanks",
MessageID: "910",
})
if calls != 1 {
t.Fatalf("reaction callback calls = %d, want 1", calls)
}
if response != "" {
t.Fatalf("expected empty final response after reaction tool, got %q", response)
}
}
func TestReactionTool_BecomesAvailableForTelegramAfterChannelManagerBinding(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Channels.Telegram.Enabled = true
al := NewAgentLoop(cfg, bus.NewMessageBus(), nil)
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
before := defaultAgent.Tools.ToProviderDefsWithContext(context.Background(), "telegram", "chat1")
if slices.ContainsFunc(before, func(def providers.ToolDefinition) bool { return def.Function.Name == "reaction" }) {
t.Fatal("reaction tool should not be available before channel manager binding")
}
al.SetChannelManager(&channels.Manager{})
after := defaultAgent.Tools.ToProviderDefsWithContext(context.Background(), "telegram", "chat1")
if !slices.ContainsFunc(after, func(def providers.ToolDefinition) bool { return def.Function.Name == "reaction" }) {
t.Fatal("reaction tool should be available for telegram after channel manager binding")
}
}
func TestTaskTool_DirectModeWithoutChannelManagerReturnsPlan(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {

View file

@ -26,13 +26,20 @@ type MessageDeleter interface {
DeleteMessage(ctx context.Context, chatID string, messageID string) error
}
// ReactionCapable — channels that can add a reaction (e.g. 👀) to an inbound message.
// ReactionCapable — channels that can add a temporary reaction (e.g. 👀) to an
// inbound message as a processing indicator.
// ReactToMessage adds a reaction and returns an undo function to remove it.
// The undo function MUST be idempotent and safe to call multiple times.
type ReactionCapable interface {
ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)
}
// MessageReactor — channels that can set an explicit emoji reaction on a
// specific message as a final user-visible action.
type MessageReactor interface {
SetMessageReaction(ctx context.Context, chatID, messageID, emoji string) error
}
// PlaceholderCapable — channels that can send a placeholder message
// (e.g. "Thinking... 💭") that will later be edited to the actual response.
// The channel MUST also implement MessageEditor for the placeholder to be useful.

View file

@ -115,6 +115,46 @@ func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {
m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()})
}
// CleanupState stops typing, undoes any reaction indicator, and deletes the placeholder
// for the given channel+chatID without sending a response message.
// Called when the agent completes a turn via a direct tool action (e.g. reaction tool)
// that produces no outbound message, so preSend never runs.
func (m *Manager) CleanupState(ctx context.Context, channelName, chatID string) {
key := channelName + ":" + chatID
if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
if entry, ok := v.(typingEntry); ok {
entry.stop()
}
}
if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded {
if entry, ok := v.(reactionEntry); ok {
entry.undo()
}
}
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
ch, ok := m.GetChannel(channelName)
if !ok {
return
}
if deleter, ok := ch.(MessageDeleter); ok {
if err := deleter.DeleteMessage(ctx, chatID, entry.id); err != nil {
logger.WarnCF("manager", "Failed to delete placeholder after tool action",
map[string]any{
"channel": channelName,
"chat_id": chatID,
"placeholder_id": entry.id,
"error": err.Error(),
})
}
}
}
}
}
// preSend handles typing stop, reaction undo, and placeholder editing before sending a message.
// Returns true if the message was edited into a placeholder (skip Send).
func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool {
@ -900,3 +940,17 @@ func (m *Manager) EditMessage(ctx context.Context, channelName, chatID, messageI
}
return editor.EditMessage(ctx, chatID, messageID, content)
}
// SetMessageReaction synchronously adds an explicit emoji reaction to a
// specific message if the channel supports MessageReactor.
func (m *Manager) SetMessageReaction(ctx context.Context, channelName, chatID, messageID, emoji string) error {
ch, ok := m.GetChannel(channelName)
if !ok {
return fmt.Errorf("channel %s not found", channelName)
}
reactor, ok := ch.(MessageReactor)
if !ok {
return fmt.Errorf("channel %s does not support message reactions", channelName)
}
return reactor.SetMessageReaction(ctx, chatID, messageID, emoji)
}

View file

@ -339,6 +339,34 @@ func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, mess
return nil
}
// SetMessageReaction implements channels.MessageReactor.
func (c *TelegramChannel) SetMessageReaction(ctx context.Context, chatID, messageID, emoji string) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
target, err := parseTelegramTarget(chatID)
if err != nil {
return fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed)
}
messageIDs, err := parseTelegramMessageIDs(messageID)
if err != nil {
return fmt.Errorf("invalid message ID %s: %w", messageID, channels.ErrSendFailed)
}
if len(messageIDs) != 1 {
return fmt.Errorf("telegram react: expected a single message ID: %w", channels.ErrSendFailed)
}
if err := c.bot.SetMessageReaction(ctx, (&telego.SetMessageReactionParams{}).
WithChatID(tu.ID(target.ChatID)).
WithMessageID(messageIDs[0]).
WithReaction(tu.ReactionEmoji(emoji))); err != nil {
return fmt.Errorf("telegram react: %w", channels.ErrTemporary)
}
return nil
}
// SendPlaceholder implements channels.PlaceholderCapable.
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
// edited to the actual response via EditMessage (channels.MessageEditor).

View file

@ -79,6 +79,11 @@ func successResponseWithID(t *testing.T, id int) *ta.Response {
return &ta.Response{Ok: true, Result: b}
}
func successBoolResponse(t *testing.T) *ta.Response {
t.Helper()
return &ta.Response{Ok: true, Result: []byte("true")}
}
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
t.Helper()
@ -343,6 +348,29 @@ func TestEditMessage_MultipleChunkIDs(t *testing.T) {
assert.Len(t, caller.calls, 2, "multi-part edit should update every tracked message")
}
func TestSetMessageReaction_SendsConfiguredEmoji(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successBoolResponse(t), nil
},
}
ch := newTestChannel(t, caller)
err := ch.SetMessageReaction(context.Background(), "12345", "99", "❤️")
require.NoError(t, err)
require.Len(t, caller.calls, 1)
body := decodeCallBody(t, caller.calls[0])
assert.Equal(t, float64(99), body["message_id"])
reaction, ok := body["reaction"].([]any)
require.True(t, ok)
require.Len(t, reaction, 1)
first, ok := reaction[0].(map[string]any)
require.True(t, ok)
assert.Equal(t, "emoji", first["type"])
assert.Equal(t, "❤️", first["emoji"])
}
func TestStartTyping_ForumTopic_UsesThreadID(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {

View file

@ -267,6 +267,7 @@ type TelegramConfig struct {
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
AllowedReactionEmoji FlexibleStringSlice `json:"allowed_reaction_emoji" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOWED_REACTION_EMOJI"`
Groups map[string]TelegramGroupConfig `json:"groups,omitempty"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
@ -295,6 +296,30 @@ type FeishuConfig struct {
RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
}
var defaultTelegramReactionEmoji = FlexibleStringSlice{
"👍",
"👎",
"❤️",
"🔥",
"🥰",
"👏",
"😁",
"🤔",
"🤯",
"😱",
"🤬",
"😢",
"🎉",
"🤩",
"🤮",
}
func DefaultTelegramReactionEmoji() FlexibleStringSlice {
emojis := make(FlexibleStringSlice, len(defaultTelegramReactionEmoji))
copy(emojis, defaultTelegramReactionEmoji)
return emojis
}
type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
@ -692,6 +717,7 @@ type ToolsConfig struct {
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
Reaction ToolConfig `json:"reaction" envPrefix:"PICOCLAW_TOOLS_REACTION_"`
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
@ -970,6 +996,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.ListDir.Enabled
case "message":
return t.Message.Enabled
case "reaction":
return t.Reaction.Enabled
case "read_file":
return t.ReadFile.Enabled
case "spawn":

View file

@ -493,6 +493,20 @@ func TestDefaultConfig_DMScope(t *testing.T) {
}
}
func TestDefaultConfig_TelegramReactionEmojiDefaults(t *testing.T) {
cfg := DefaultConfig()
if len(cfg.Channels.Telegram.AllowedReactionEmoji) != 15 {
t.Fatalf("AllowedReactionEmoji len = %d, want 15", len(cfg.Channels.Telegram.AllowedReactionEmoji))
}
if cfg.Channels.Telegram.AllowedReactionEmoji[0] != "👍" {
t.Fatalf("first AllowedReactionEmoji = %q, want %q", cfg.Channels.Telegram.AllowedReactionEmoji[0], "👍")
}
if !cfg.Tools.Reaction.Enabled {
t.Fatal("DefaultConfig().Tools.Reaction.Enabled should be true")
}
}
func TestDefaultConfig_WorkspacePath_Default(t *testing.T) {
// Unset to ensure we test the default
t.Setenv("PICOCLAW_HOME", "")

View file

@ -53,6 +53,7 @@ func DefaultConfig() *Config {
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
AllowedReactionEmoji: DefaultTelegramReactionEmoji(),
Typing: TypingConfig{Enabled: true},
Placeholder: PlaceholderConfig{
Enabled: true,
@ -466,6 +467,9 @@ func DefaultConfig() *Config {
Message: ToolConfig{
Enabled: true,
},
Reaction: ToolConfig{
Enabled: true,
},
ReadFile: ReadFileToolConfig{
Enabled: true,
MaxReadFileSize: 64 * 1024, // 64KB

View file

@ -127,6 +127,13 @@ type SequentialTool interface {
ExecuteSequentially() bool
}
// AvailabilityAwareTool marks tools whose visibility depends on the current
// request context, such as channel-specific tools.
type AvailabilityAwareTool interface {
Tool
Available(ctx context.Context) bool
}
func ToolToSchema(tool Tool) map[string]any {
return map[string]any{
"type": "function",

184
pkg/tools/reaction.go Normal file
View file

@ -0,0 +1,184 @@
package tools
import (
"context"
"fmt"
"strings"
"sync/atomic"
"github.com/sipeed/picoclaw/pkg/config"
)
type ReactionCallback func(ctx context.Context, channel, chatID, messageID, emoji string) error
const (
reactionTargetCurrent = "current"
reactionTargetParent = "parent"
reactionTargetMessage = "message_id"
)
type ReactionTool struct {
allowedEmoji []string
reactCallback ReactionCallback
handledInRound atomic.Bool
}
func NewReactionTool(allowedEmoji []string) *ReactionTool {
emoji := normalizeAllowedEmoji(allowedEmoji)
if len(emoji) == 0 {
emoji = normalizeAllowedEmoji([]string(config.DefaultTelegramReactionEmoji()))
}
return &ReactionTool{allowedEmoji: emoji}
}
func normalizeAllowedEmoji(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func (t *ReactionTool) Name() string {
return "reaction"
}
func (t *ReactionTool) Description() string {
if len(t.allowedEmoji) == 0 {
return "Add an emoji reaction to the current Telegram message instead of sending a text reply. Use this for short acknowledgements like thanks, ok, or got it."
}
return fmt.Sprintf(
"Add an emoji reaction to the current Telegram message instead of sending a text reply. Use this for short acknowledgements like thanks, ok, or got it. You MUST choose one of these configured emojis: %s.",
strings.Join(t.allowedEmoji, " "),
)
}
func (t *ReactionTool) Available(ctx context.Context) bool {
return t.reactCallback != nil && ToolChannel(ctx) == "telegram"
}
func (t *ReactionTool) Parameters() map[string]any {
emojiSchema := map[string]any{
"type": "string",
"description": "Emoji reaction to add to the target Telegram message",
}
if len(t.allowedEmoji) > 0 {
emojiSchema["enum"] = append([]string(nil), t.allowedEmoji...)
}
return map[string]any{
"type": "object",
"properties": map[string]any{
"emoji": emojiSchema,
"target": map[string]any{
"type": "string",
"description": "Which Telegram message to react to. Defaults to current.",
"enum": []string{reactionTargetCurrent, reactionTargetParent, reactionTargetMessage},
},
"message_id": map[string]any{
"type": "string",
"description": "Explicit Telegram message ID when target=message_id",
},
},
"required": []string{"emoji"},
}
}
func (t *ReactionTool) ExecuteSequentially() bool {
return true
}
func (t *ReactionTool) SetReactionCallback(callback ReactionCallback) {
t.reactCallback = callback
}
func (t *ReactionTool) ResetHandledInRound() {
t.handledInRound.Store(false)
}
func (t *ReactionTool) HasHandledInRound() bool {
return t.handledInRound.Load()
}
func (t *ReactionTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
emoji, _ := args["emoji"].(string)
emoji = strings.TrimSpace(emoji)
if emoji == "" {
return ErrorResult("emoji is required")
}
if len(t.allowedEmoji) > 0 {
allowed := false
for _, candidate := range t.allowedEmoji {
if candidate == emoji {
allowed = true
break
}
}
if !allowed {
return ErrorResult(fmt.Sprintf("emoji %q is not allowed; use one of: %s", emoji, strings.Join(t.allowedEmoji, " ")))
}
}
channel := ToolChannel(ctx)
chatID := ToolChatID(ctx)
if channel == "" || chatID == "" {
return ErrorResult("reaction tool requires a current channel/chat context")
}
if channel != "telegram" {
return ErrorResult("reaction tool currently supports Telegram only")
}
messageID, err := resolveReactionTarget(ctx, args)
if err != nil {
return ErrorResult(err.Error()).WithError(err)
}
if t.reactCallback == nil {
return ErrorResult("reaction sending not configured")
}
if err := t.reactCallback(ctx, channel, chatID, messageID, emoji); err != nil {
return ErrorResult(fmt.Sprintf("adding reaction: %v", err)).WithError(err)
}
t.handledInRound.Store(true)
return SilentResult(fmt.Sprintf("Reaction %s added to telegram:%s message %s", emoji, chatID, messageID))
}
func resolveReactionTarget(ctx context.Context, args map[string]any) (string, error) {
target, _ := args["target"].(string)
target = strings.ToLower(strings.TrimSpace(target))
if target == "" {
target = reactionTargetCurrent
}
switch target {
case reactionTargetCurrent:
if id := strings.TrimSpace(ToolCurrentMessageID(ctx)); id != "" {
return id, nil
}
return "", fmt.Errorf("target=current requested but current message id is unavailable")
case reactionTargetParent:
if id := strings.TrimSpace(ToolParentMessageID(ctx)); id != "" {
return id, nil
}
return "", fmt.Errorf("target=parent requested but parent message id is unavailable")
case reactionTargetMessage:
messageID, _ := args["message_id"].(string)
messageID = strings.TrimSpace(messageID)
if messageID == "" {
return "", fmt.Errorf("target=message_id requires message_id")
}
return messageID, nil
default:
return "", fmt.Errorf("unsupported reaction target %q", target)
}
}

121
pkg/tools/reaction_test.go Normal file
View file

@ -0,0 +1,121 @@
package tools
import (
"context"
"testing"
)
func TestReactionTool_Parameters_ExposeAllowedEmojiEnum(t *testing.T) {
tool := NewReactionTool([]string{"❤️", "🔥"})
params := tool.Parameters()
properties, ok := params["properties"].(map[string]any)
if !ok {
t.Fatalf("properties missing or invalid: %#v", params["properties"])
}
emojiProp, ok := properties["emoji"].(map[string]any)
if !ok {
t.Fatalf("emoji property missing or invalid: %#v", properties["emoji"])
}
enumValues, ok := emojiProp["enum"].([]string)
if !ok {
t.Fatalf("emoji enum missing or invalid: %#v", emojiProp["enum"])
}
if len(enumValues) != 2 {
t.Fatalf("emoji enum len = %d, want 2", len(enumValues))
}
if enumValues[0] != "❤️" || enumValues[1] != "🔥" {
t.Fatalf("emoji enum = %#v", enumValues)
}
}
func TestReactionTool_Execute_CurrentMessage(t *testing.T) {
tool := NewReactionTool([]string{"❤️", "🔥"})
var called bool
tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID, emoji string) error {
called = true
if channel != "telegram" {
t.Fatalf("channel=%q", channel)
}
if chatID != "chat-1" {
t.Fatalf("chatID=%q", chatID)
}
if messageID != "910" {
t.Fatalf("messageID=%q", messageID)
}
if emoji != "❤️" {
t.Fatalf("emoji=%q", emoji)
}
return nil
})
ctx := WithToolReplyContext(
WithToolContext(context.Background(), "telegram", "chat-1"),
"910",
"905",
)
result := tool.Execute(ctx, map[string]any{
"emoji": "❤️",
})
if !called {
t.Fatal("expected reaction callback to be called")
}
if result.IsError {
t.Fatalf("unexpected error result: %q", result.ForLLM)
}
if !result.Silent {
t.Fatal("expected silent result")
}
if !tool.HasHandledInRound() {
t.Fatal("expected handledInRound to be true")
}
}
func TestReactionTool_Execute_ParentMessage(t *testing.T) {
tool := NewReactionTool([]string{"❤️"})
var gotMessageID string
tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID, emoji string) error {
gotMessageID = messageID
return nil
})
ctx := WithToolReplyContext(
WithToolContext(context.Background(), "telegram", "chat-1"),
"910",
"905",
)
result := tool.Execute(ctx, map[string]any{
"emoji": "❤️",
"target": "parent",
})
if result.IsError {
t.Fatalf("unexpected error result: %q", result.ForLLM)
}
if gotMessageID != "905" {
t.Fatalf("gotMessageID=%q, want %q", gotMessageID, "905")
}
}
func TestReactionTool_Execute_RejectsEmojiOutsideAllowlist(t *testing.T) {
tool := NewReactionTool([]string{"❤️"})
ctx := WithToolReplyContext(
WithToolContext(context.Background(), "telegram", "chat-1"),
"910",
"905",
)
result := tool.Execute(ctx, map[string]any{
"emoji": "🔥",
})
if !result.IsError {
t.Fatal("expected error result")
}
if tool.HasHandledInRound() {
t.Fatal("handledInRound should remain false on error")
}
}

View file

@ -141,13 +141,22 @@ func (r *ToolRegistry) sortedToolNames() []string {
}
func (r *ToolRegistry) GetDefinitions() []map[string]any {
return r.GetDefinitionsWithContext(context.Background(), "", "")
}
func (r *ToolRegistry) GetDefinitionsWithContext(ctx context.Context, channel, chatID string) []map[string]any {
r.mu.RLock()
defer r.mu.RUnlock()
ctx = availabilityContext(ctx, channel, chatID)
sorted := r.sortedToolNames()
definitions := make([]map[string]any, 0, len(sorted))
for _, name := range sorted {
definitions = append(definitions, ToolToSchema(r.tools[name]))
tool := r.tools[name]
if !toolAvailableInContext(tool, ctx) {
continue
}
definitions = append(definitions, ToolToSchema(tool))
}
return definitions
}
@ -155,13 +164,21 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any {
// ToProviderDefs converts tool definitions to provider-compatible format.
// This is the format expected by LLM provider APIs.
func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
return r.ToProviderDefsWithContext(context.Background(), "", "")
}
func (r *ToolRegistry) ToProviderDefsWithContext(ctx context.Context, channel, chatID string) []providers.ToolDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
ctx = availabilityContext(ctx, channel, chatID)
sorted := r.sortedToolNames()
definitions := make([]providers.ToolDefinition, 0, len(sorted))
for _, name := range sorted {
tool := r.tools[name]
if !toolAvailableInContext(tool, ctx) {
continue
}
schema := ToolToSchema(tool)
// Safely extract nested values with type checks
@ -186,6 +203,21 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
return definitions
}
func availabilityContext(ctx context.Context, channel, chatID string) context.Context {
if ctx == nil {
ctx = context.Background()
}
return WithToolContext(ctx, channel, chatID)
}
func toolAvailableInContext(tool Tool, ctx context.Context) bool {
conditional, ok := tool.(AvailabilityAwareTool)
if !ok {
return true
}
return conditional.Available(ctx)
}
// List returns a list of all registered tool names.
func (r *ToolRegistry) List() []string {
r.mu.RLock()

View file

@ -54,6 +54,17 @@ func (m *mockSequentialRegistryTool) ExecuteSequentially() bool {
return m.sequential
}
type mockAvailabilityTool struct {
mockRegistryTool
allowedChannel string
lastCtx context.Context
}
func (m *mockAvailabilityTool) Available(ctx context.Context) bool {
m.lastCtx = ctx
return ToolChannel(ctx) == m.allowedChannel
}
// --- helpers ---
func newMockTool(name, desc string) *mockRegistryTool {
@ -295,6 +306,35 @@ func TestToolRegistry_ToProviderDefs(t *testing.T) {
}
}
func TestToolRegistry_ToProviderDefsWithContext_FiltersUnavailableTools(t *testing.T) {
r := NewToolRegistry()
r.Register(newMockTool("always", "always visible"))
rt := &mockAvailabilityTool{
mockRegistryTool: *newMockTool("reaction", "telegram only"),
allowedChannel: "telegram",
}
r.Register(rt)
defs := r.ToProviderDefsWithContext(context.Background(), "cli", "direct")
if len(defs) != 1 {
t.Fatalf("defs len = %d, want 1", len(defs))
}
if defs[0].Function.Name != "always" {
t.Fatalf("visible tool = %q, want %q", defs[0].Function.Name, "always")
}
if rt.lastCtx == nil {
t.Fatal("expected availability check to receive context")
}
if got := ToolChannel(rt.lastCtx); got != "cli" {
t.Fatalf("availability context channel = %q, want %q", got, "cli")
}
defs = r.ToProviderDefsWithContext(context.Background(), "telegram", "chat-1")
if len(defs) != 2 {
t.Fatalf("telegram defs len = %d, want 2", len(defs))
}
}
func TestToolRegistry_List(t *testing.T) {
r := NewToolRegistry()
r.Register(newMockTool("x", ""))

View file

@ -55,7 +55,7 @@ func RunToolLoop(
// 1. Build tool definitions
var providerToolDefs []providers.ToolDefinition
if config.Tools != nil {
providerToolDefs = config.Tools.ToProviderDefs()
providerToolDefs = config.Tools.ToProviderDefsWithContext(ctx, channel, chatID)
}
// 2. Set default LLM options