feat: add support for attachments in message tool

This commit is contained in:
pkonowrocki 2026-02-18 09:21:43 +01:00
parent 11008573d7
commit ac551a0caa
5 changed files with 55 additions and 12 deletions

View file

@ -95,11 +95,12 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
// Message tool - available to both agent and subagent
// Subagent uses it to communicate directly with user
messageTool := tools.NewMessageTool()
messageTool.SetSendCallback(func(channel, chatID, content string) error {
messageTool.SetSendCallback(func(channel, chatID, content string, attachments []string) error {
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: content,
Media: attachments,
})
return nil
})

View file

@ -14,6 +14,7 @@ type OutboundMessage struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
Media []string `json:"media,omitempty"`
}
type MessageHandler func(InboundMessage) error

View file

@ -101,12 +101,24 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return fmt.Errorf("channel ID is empty")
}
for _, mediaPath := range msg.Media {
file, err := os.Open(mediaPath)
if err != nil {
return fmt.Errorf("failed to open media file %s: %w", mediaPath, err)
}
_, err = c.session.ChannelFileSend(channelID, mediaPath, file)
file.Close()
if err != nil {
return fmt.Errorf("failed to send media file %s: %w", mediaPath, err)
}
}
runes := []rune(msg.Content)
if len(runes) == 0 {
return nil
}
chunks := splitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks
chunks := splitMessage(msg.Content, 1500)
for _, chunk := range chunks {
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
@ -253,7 +265,19 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content strin
done := make(chan error, 1)
go func() {
_, err := c.session.ChannelMessageSend(channelID, content)
var err error
if strings.HasPrefix(content, "file://") {
filePath := strings.TrimPrefix(content, "file://")
file, fErr := os.Open(filePath)
if fErr != nil {
done <- fErr
return
}
defer file.Close()
_, err = c.session.ChannelFileSend(channelID, filePath, file)
} else {
_, err = c.session.ChannelMessageSend(channelID, content)
}
done <- err
}()

View file

@ -5,7 +5,7 @@ import (
"fmt"
)
type SendCallback func(channel, chatID, content string) error
type SendCallback func(channel, chatID, content string, attachments []string) error
type MessageTool struct {
sendCallback SendCallback
@ -42,6 +42,11 @@ func (t *MessageTool) Parameters() map[string]interface{} {
"type": "string",
"description": "Optional: target chat/user ID",
},
"attachments": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"type": "string"},
"description": "Optional: list of local file paths or URLs to attach",
},
},
"required": []string{"content"},
}
@ -70,6 +75,13 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{})
channel, _ := args["channel"].(string)
chatID, _ := args["chat_id"].(string)
attachmentsRaw, _ := args["attachments"].([]interface{})
var attachments []string
for _, a := range attachmentsRaw {
if s, ok := a.(string); ok {
attachments = append(attachments, s)
}
}
if channel == "" {
channel = t.defaultChannel
@ -86,7 +98,7 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{})
return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
}
if err := t.sendCallback(channel, chatID, content); err != nil {
if err := t.sendCallback(channel, chatID, content, attachments); err != nil {
return &ToolResult{
ForLLM: fmt.Sprintf("sending message: %v", err),
IsError: true,

View file

@ -11,10 +11,12 @@ func TestMessageTool_Execute_Success(t *testing.T) {
tool.SetContext("test-channel", "test-chat-id")
var sentChannel, sentChatID, sentContent string
tool.SetSendCallback(func(channel, chatID, content string) error {
var sentAttachments []string
tool.SetSendCallback(func(channel, chatID, content string, attachments []string) error {
sentChannel = channel
sentChatID = chatID
sentContent = content
sentAttachments = attachments
return nil
})
@ -35,6 +37,9 @@ func TestMessageTool_Execute_Success(t *testing.T) {
if sentContent != "Hello, world!" {
t.Errorf("Expected content 'Hello, world!', got '%s'", sentContent)
}
if len(sentAttachments) != 0 {
t.Errorf("Expected 0 attachments, got %d", len(sentAttachments))
}
// Verify ToolResult meets US-011 criteria:
// - Send success returns SilentResult (Silent=true)
@ -63,7 +68,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
tool.SetContext("default-channel", "default-chat-id")
var sentChannel, sentChatID string
tool.SetSendCallback(func(channel, chatID, content string) error {
tool.SetSendCallback(func(channel, chatID, content string, attachments []string) error {
sentChannel = channel
sentChatID = chatID
return nil
@ -99,7 +104,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
tool.SetContext("test-channel", "test-chat-id")
sendErr := errors.New("network error")
tool.SetSendCallback(func(channel, chatID, content string) error {
tool.SetSendCallback(func(channel, chatID, content string, attachments []string) error {
return sendErr
})
@ -153,7 +158,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
tool := NewMessageTool()
// No SetContext called, so defaultChannel and defaultChatID are empty
tool.SetSendCallback(func(channel, chatID, content string) error {
tool.SetSendCallback(func(channel, chatID, content string, attachments []string) error {
return nil
})