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:
parent
c1718da019
commit
81d606766a
16 changed files with 771 additions and 53 deletions
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -67,8 +67,9 @@ type processOptions struct {
|
|||
}
|
||||
|
||||
type agentResponse struct {
|
||||
Content string
|
||||
ReplyToMessageID string
|
||||
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.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 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 !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 {
|
||||
if response.Content != "" {
|
||||
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
|
||||
agent.Sessions.AddMessage(opts.SessionKey, "assistant", response.Content)
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -262,16 +262,17 @@ type WhatsAppConfig struct {
|
|||
}
|
||||
|
||||
type TelegramConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||
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"`
|
||||
Groups map[string]TelegramGroupConfig `json:"groups,omitempty"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||
Typing TypingConfig `json:"typing,omitempty"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||
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"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
|
||||
}
|
||||
|
||||
type TelegramTopicConfig struct {
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -50,10 +50,11 @@ func DefaultConfig() *Config {
|
|||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Telegram: TelegramConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
Typing: TypingConfig{Enabled: true},
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
AllowedReactionEmoji: DefaultTelegramReactionEmoji(),
|
||||
Typing: TypingConfig{Enabled: true},
|
||||
Placeholder: PlaceholderConfig{
|
||||
Enabled: true,
|
||||
Text: "Thinking... 💭",
|
||||
|
|
@ -466,6 +467,9 @@ func DefaultConfig() *Config {
|
|||
Message: ToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
Reaction: ToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
ReadFile: ReadFileToolConfig{
|
||||
Enabled: true,
|
||||
MaxReadFileSize: 64 * 1024, // 64KB
|
||||
|
|
|
|||
|
|
@ -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
184
pkg/tools/reaction.go
Normal 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
121
pkg/tools/reaction_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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", ""))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue