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

View file

@ -69,6 +69,7 @@ type processOptions struct {
type agentResponse struct { type agentResponse struct {
Content string Content string
ReplyToMessageID string ReplyToMessageID string
HandledExternally bool
} }
func (r agentResponse) outboundMessage(channel, chatID string) bus.OutboundMessage { func (r agentResponse) outboundMessage(channel, chatID string) bus.OutboundMessage {
@ -200,6 +201,9 @@ func registerSharedTools(
}) })
agent.Tools.Register(messageTool) 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) // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
if cfg.Tools.IsToolEnabled("send_file") { 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)} 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 != "" { 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( logger.DebugCF(
"agent", "agent",
"Skipped outbound (message tool already sent)", "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) { func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
al.channelManager = cm al.channelManager = cm
al.bindAdvancedMessageManagers(cm) al.bindAdvancedMessageManagers(cm)
al.bindReactionTools(cm)
} }
// bindAdvancedMessageManagers wires up channel callbacks to any tools that // 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. // SetMediaStore injects a MediaStore for media lifecycle management.
func (al *AgentLoop) SetMediaStore(s media.MediaStore) { func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
al.mediaStore = s al.mediaStore = s
@ -459,6 +466,39 @@ func (al *AgentLoop) SetTranscriber(t voice.Transcriber) {
al.transcriber = t 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)(?::[^\]]*)?\]`) var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// transcribeAudioInMessage resolves audio media refs, transcribes them, and // 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 return agentResponse{}, routeErr
} }
// Reset message-tool state for this round so we don't skip publishing due to a previous round. al.resetRoundActionTools(agent)
if tool, ok := agent.Tools.Get("message"); ok {
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
resetter.ResetSentInRound()
}
}
// Resolve session key from route, while preserving explicit agent-scoped keys. // Resolve session key from route, while preserving explicit agent-scoped keys.
scopeKey := resolveScopeKey(route, msg.SessionKey) 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 // This is controlled by the tool's Silent flag and ForUser content
// 4. Handle empty response // 4. Handle empty response
if finalContent == "" { directActionHandled := al.agentTurnHandledByDirectToolAction(agent)
if finalContent == "" && !directActionHandled {
finalContent = opts.DefaultResponse finalContent = opts.DefaultResponse
} }
response := resolveFinalResponse(opts.Channel, opts.ReplyContext, finalContent) response := resolveFinalResponse(opts.Channel, opts.ReplyContext, finalContent)
if response.Content == "" { if response.Content == "" && !directActionHandled {
response.Content = opts.DefaultResponse response.Content = opts.DefaultResponse
} }
// 5. Save final assistant message to session // 5. Save final assistant message to session
if response.Content != "" {
agent.Sessions.AddMessage(opts.SessionKey, "assistant", response.Content) agent.Sessions.AddMessage(opts.SessionKey, "assistant", response.Content)
}
agent.Sessions.Save(opts.SessionKey) agent.Sessions.Save(opts.SessionKey)
// 6. Optional: summarization // 6. Optional: summarization
@ -865,7 +903,7 @@ func (al *AgentLoop) runAgentLoop(
} }
// 7. Optional: send response via bus // 7. Optional: send response via bus
if opts.SendResponse { if opts.SendResponse && response.Content != "" {
al.bus.PublishOutbound(ctx, response.outboundMessage(opts.Channel, opts.ChatID)) al.bus.PublishOutbound(ctx, response.outboundMessage(opts.Channel, opts.ChatID))
} }
@ -879,6 +917,7 @@ func (al *AgentLoop) runAgentLoop(
"final_length": len(response.Content), "final_length": len(response.Content),
}) })
response.HandledExternally = directActionHandled
return response, nil return response, nil
} }
@ -1061,7 +1100,7 @@ func (al *AgentLoop) runLLMIteration(
}) })
// Build tool definitions // Build tool definitions
providerToolDefs := agent.Tools.ToProviderDefs() providerToolDefs := agent.Tools.ToProviderDefsWithContext(ctx, opts.Channel, opts.ChatID)
// Log LLM request details // Log LLM request details
logger.DebugCF("agent", "LLM request", logger.DebugCF("agent", "LLM request",

View file

@ -438,6 +438,42 @@ func (m *taskToolRaceMockProvider) GetDefaultModel() string {
return "tasktool-race-mock-model" 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 { type blockingSequentialTaskTool struct {
inner *tools.TaskTool inner *tools.TaskTool
createOnce sync.Once 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) { func TestTaskTool_DirectModeWithoutChannelManagerReturnsPlan(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*") tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil { if err != nil {

View file

@ -26,13 +26,20 @@ type MessageDeleter interface {
DeleteMessage(ctx context.Context, chatID string, messageID string) error 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. // ReactToMessage adds a reaction and returns an undo function to remove it.
// The undo function MUST be idempotent and safe to call multiple times. // The undo function MUST be idempotent and safe to call multiple times.
type ReactionCapable interface { type ReactionCapable interface {
ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) 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 // PlaceholderCapable — channels that can send a placeholder message
// (e.g. "Thinking... 💭") that will later be edited to the actual response. // (e.g. "Thinking... 💭") that will later be edited to the actual response.
// The channel MUST also implement MessageEditor for the placeholder to be useful. // 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()}) 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. // 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). // 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 { 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) 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 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. // SendPlaceholder implements channels.PlaceholderCapable.
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be // It sends a placeholder message (e.g. "Thinking... 💭") that will later be
// edited to the actual response via EditMessage (channels.MessageEditor). // 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} 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. // newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel { func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
t.Helper() 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") 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) { func TestStartTyping_ForumTopic_UsesThreadID(t *testing.T) {
caller := &stubCaller{ caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { 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"` BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` 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"` Groups map[string]TelegramGroupConfig `json:"groups,omitempty"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,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"` 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 { type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` 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_"` InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` 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_"` ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
@ -970,6 +996,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.ListDir.Enabled return t.ListDir.Enabled
case "message": case "message":
return t.Message.Enabled return t.Message.Enabled
case "reaction":
return t.Reaction.Enabled
case "read_file": case "read_file":
return t.ReadFile.Enabled return t.ReadFile.Enabled
case "spawn": 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) { func TestDefaultConfig_WorkspacePath_Default(t *testing.T) {
// Unset to ensure we test the default // Unset to ensure we test the default
t.Setenv("PICOCLAW_HOME", "") t.Setenv("PICOCLAW_HOME", "")

View file

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

View file

@ -127,6 +127,13 @@ type SequentialTool interface {
ExecuteSequentially() bool 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 { func ToolToSchema(tool Tool) map[string]any {
return map[string]any{ return map[string]any{
"type": "function", "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 { 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() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
ctx = availabilityContext(ctx, channel, chatID)
sorted := r.sortedToolNames() sorted := r.sortedToolNames()
definitions := make([]map[string]any, 0, len(sorted)) definitions := make([]map[string]any, 0, len(sorted))
for _, name := range 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 return definitions
} }
@ -155,13 +164,21 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any {
// ToProviderDefs converts tool definitions to provider-compatible format. // ToProviderDefs converts tool definitions to provider-compatible format.
// This is the format expected by LLM provider APIs. // This is the format expected by LLM provider APIs.
func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { 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() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
ctx = availabilityContext(ctx, channel, chatID)
sorted := r.sortedToolNames() sorted := r.sortedToolNames()
definitions := make([]providers.ToolDefinition, 0, len(sorted)) definitions := make([]providers.ToolDefinition, 0, len(sorted))
for _, name := range sorted { for _, name := range sorted {
tool := r.tools[name] tool := r.tools[name]
if !toolAvailableInContext(tool, ctx) {
continue
}
schema := ToolToSchema(tool) schema := ToolToSchema(tool)
// Safely extract nested values with type checks // Safely extract nested values with type checks
@ -186,6 +203,21 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
return definitions 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. // List returns a list of all registered tool names.
func (r *ToolRegistry) List() []string { func (r *ToolRegistry) List() []string {
r.mu.RLock() r.mu.RLock()

View file

@ -54,6 +54,17 @@ func (m *mockSequentialRegistryTool) ExecuteSequentially() bool {
return m.sequential 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 --- // --- helpers ---
func newMockTool(name, desc string) *mockRegistryTool { 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) { func TestToolRegistry_List(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
r.Register(newMockTool("x", "")) r.Register(newMockTool("x", ""))

View file

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