feat(feishu): implement SendMedia and add send_file tool
Add outbound media support for the Feishu channel so the agent can send images and files to users via the MediaStore pipeline. Feishu channel: - SendMedia dispatches media parts as image or file uploads - sendImage uploads via Image.Create then sends image message - sendFile uploads via File.Create then sends file message - feishuFileType maps extensions to Feishu file_type values send_file tool: - New tool lets the LLM send a local file to the current chat - Validates path, registers file in MediaStore, returns media ref - Agent loop wires tool registration, MediaStore propagation, and context updates Tested on Radxa Cubie A7A (arm64) with Feishu websocket channel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
23da4503c1
commit
07ff0d57f6
3 changed files with 294 additions and 0 deletions
|
|
@ -170,6 +170,14 @@ func registerSharedTools(
|
|||
agent.Tools.Register(messageTool)
|
||||
}
|
||||
|
||||
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
|
||||
sendFileTool := tools.NewSendFileTool(
|
||||
agent.Workspace,
|
||||
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||
nil,
|
||||
)
|
||||
agent.Tools.Register(sendFileTool)
|
||||
|
||||
// Skill discovery and installation tools
|
||||
skills_enabled := cfg.Tools.IsToolEnabled("skills")
|
||||
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
||||
|
|
@ -371,6 +379,19 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
|||
// SetMediaStore injects a MediaStore for media lifecycle management.
|
||||
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
||||
al.mediaStore = s
|
||||
|
||||
// Propagate store to send_file tools in all agents.
|
||||
for _, id := range al.registry.ListAgentIDs() {
|
||||
agent, ok := al.registry.GetAgent(id)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tool, ok := agent.Tools.Get("send_file"); ok {
|
||||
if sf, ok := tool.(*tools.SendFileTool); ok {
|
||||
sf.SetMediaStore(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetTranscriber injects a voice transcriber for agent-level audio transcription.
|
||||
|
|
@ -1169,6 +1190,31 @@ func (al *AgentLoop) runLLMIteration(
|
|||
return finalContent, iteration, nil
|
||||
}
|
||||
|
||||
// updateToolContexts updates the context for tools that need channel/chatID info.
|
||||
func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
|
||||
// Use ContextualTool interface instead of type assertions
|
||||
if tool, ok := agent.Tools.Get("message"); ok {
|
||||
if mt, ok := tool.(tools.ContextualTool); ok {
|
||||
mt.SetContext(channel, chatID)
|
||||
}
|
||||
}
|
||||
if tool, ok := agent.Tools.Get("spawn"); ok {
|
||||
if st, ok := tool.(tools.ContextualTool); ok {
|
||||
st.SetContext(channel, chatID)
|
||||
}
|
||||
}
|
||||
if tool, ok := agent.Tools.Get("subagent"); ok {
|
||||
if st, ok := tool.(tools.ContextualTool); ok {
|
||||
st.SetContext(channel, chatID)
|
||||
}
|
||||
}
|
||||
if tool, ok := agent.Tools.Get("send_file"); ok {
|
||||
if sf, ok := tool.(tools.ContextualTool); ok {
|
||||
sf.SetContext(channel, chatID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
||||
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
|
||||
newHistory := agent.Sessions.GetHistory(sessionKey)
|
||||
|
|
|
|||
125
pkg/tools/send_file.go
Normal file
125
pkg/tools/send_file.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
)
|
||||
|
||||
// SendFileTool allows the LLM to send a local file (image, document, etc.)
|
||||
// to the user on the current chat channel via the MediaStore pipeline.
|
||||
type SendFileTool struct {
|
||||
workspace string
|
||||
restrict bool
|
||||
mediaStore media.MediaStore
|
||||
|
||||
defaultChannel string
|
||||
defaultChatID string
|
||||
}
|
||||
|
||||
func NewSendFileTool(workspace string, restrict bool, store media.MediaStore) *SendFileTool {
|
||||
return &SendFileTool{
|
||||
workspace: workspace,
|
||||
restrict: restrict,
|
||||
mediaStore: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SendFileTool) Name() string { return "send_file" }
|
||||
func (t *SendFileTool) Description() string {
|
||||
return "Send a local file (image, document, etc.) to the user on the current chat channel."
|
||||
}
|
||||
|
||||
func (t *SendFileTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Path to the local file. Relative paths are resolved from workspace.",
|
||||
},
|
||||
"filename": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional display filename. Defaults to the basename of path.",
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SendFileTool) SetContext(channel, chatID string) {
|
||||
t.defaultChannel = channel
|
||||
t.defaultChatID = chatID
|
||||
}
|
||||
|
||||
func (t *SendFileTool) SetMediaStore(store media.MediaStore) {
|
||||
t.mediaStore = store
|
||||
}
|
||||
|
||||
func (t *SendFileTool) Execute(_ context.Context, args map[string]any) *ToolResult {
|
||||
path, _ := args["path"].(string)
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return ErrorResult("path is required")
|
||||
}
|
||||
|
||||
if t.defaultChannel == "" || t.defaultChatID == "" {
|
||||
return ErrorResult("no target channel/chat available")
|
||||
}
|
||||
|
||||
if t.mediaStore == nil {
|
||||
return ErrorResult("media store not configured")
|
||||
}
|
||||
|
||||
resolved, err := validatePath(path, t.workspace, t.restrict)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("invalid path: %v", err))
|
||||
}
|
||||
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("file not found: %v", err))
|
||||
}
|
||||
if info.IsDir() {
|
||||
return ErrorResult("path is a directory, expected a file")
|
||||
}
|
||||
|
||||
filename, _ := args["filename"].(string)
|
||||
if filename == "" {
|
||||
filename = filepath.Base(resolved)
|
||||
}
|
||||
|
||||
mediaType := detectMediaType(resolved)
|
||||
scope := fmt.Sprintf("tool:send_file:%s:%s", t.defaultChannel, t.defaultChatID)
|
||||
|
||||
ref, err := t.mediaStore.Store(resolved, media.MediaMeta{
|
||||
Filename: filename,
|
||||
ContentType: mediaType,
|
||||
Source: "tool:send_file",
|
||||
}, scope)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to register media: %v", err))
|
||||
}
|
||||
|
||||
return MediaResult(fmt.Sprintf("File %q sent to user", filename), []string{ref})
|
||||
}
|
||||
|
||||
func detectMediaType(path string) string {
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
123
pkg/tools/send_file_test.go
Normal file
123
pkg/tools/send_file_test.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
)
|
||||
|
||||
func TestSendFileTool_MissingPath(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
tool := NewSendFileTool("/tmp", false, store)
|
||||
tool.SetContext("feishu", "chat123")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error for missing path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendFileTool_NoContext(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
tool := NewSendFileTool("/tmp", false, store)
|
||||
// no SetContext call
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{"path": "/tmp/test.txt"})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error when no channel context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendFileTool_NoMediaStore(t *testing.T) {
|
||||
tool := NewSendFileTool("/tmp", false, nil)
|
||||
tool.SetContext("feishu", "chat123")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{"path": "/tmp/test.txt"})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error when no media store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendFileTool_Directory(t *testing.T) {
|
||||
store := media.NewFileMediaStore()
|
||||
tool := NewSendFileTool("/tmp", false, store)
|
||||
tool.SetContext("feishu", "chat123")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{"path": "/tmp"})
|
||||
if !result.IsError {
|
||||
t.Fatal("expected error for directory path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendFileTool_Success(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testFile := filepath.Join(dir, "photo.png")
|
||||
if err := os.WriteFile(testFile, []byte("fake png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
tool := NewSendFileTool(dir, false, store)
|
||||
tool.SetContext("feishu", "chat123")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{"path": testFile})
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %s", result.ForLLM)
|
||||
}
|
||||
if len(result.Media) != 1 {
|
||||
t.Fatalf("expected 1 media ref, got %d", len(result.Media))
|
||||
}
|
||||
if result.Media[0][:8] != "media://" {
|
||||
t.Errorf("expected media:// ref, got %q", result.Media[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendFileTool_CustomFilename(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testFile := filepath.Join(dir, "img.jpg")
|
||||
if err := os.WriteFile(testFile, []byte("fake jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
tool := NewSendFileTool(dir, false, store)
|
||||
tool.SetContext("telegram", "chat456")
|
||||
|
||||
result := tool.Execute(context.Background(), map[string]any{
|
||||
"path": testFile,
|
||||
"filename": "my-photo.jpg",
|
||||
})
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %s", result.ForLLM)
|
||||
}
|
||||
if len(result.Media) != 1 {
|
||||
t.Fatalf("expected 1 media ref, got %d", len(result.Media))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMediaType(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"photo.jpg", "image/jpeg"},
|
||||
{"photo.jpeg", "image/jpeg"},
|
||||
{"photo.png", "image/png"},
|
||||
{"anim.gif", "image/gif"},
|
||||
{"photo.webp", "image/webp"},
|
||||
{"doc.pdf", "application/pdf"},
|
||||
{"data.bin", "application/octet-stream"},
|
||||
{"noext", "application/octet-stream"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := detectMediaType(tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("detectMediaType(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue