From 07ff0d57f6834b883426fed2fbddfb0f5613565e Mon Sep 17 00:00:00 2001 From: shikihane Date: Mon, 2 Mar 2026 11:19:00 +0800 Subject: [PATCH] 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 --- pkg/agent/loop.go | 46 +++++++++++++ pkg/tools/send_file.go | 125 ++++++++++++++++++++++++++++++++++++ pkg/tools/send_file_test.go | 123 +++++++++++++++++++++++++++++++++++ 3 files changed, 294 insertions(+) create mode 100644 pkg/tools/send_file.go create mode 100644 pkg/tools/send_file_test.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 685b346e6..f0f207d3c 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -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) diff --git a/pkg/tools/send_file.go b/pkg/tools/send_file.go new file mode 100644 index 000000000..00dc5b21d --- /dev/null +++ b/pkg/tools/send_file.go @@ -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" + } +} diff --git a/pkg/tools/send_file_test.go b/pkg/tools/send_file_test.go new file mode 100644 index 000000000..83825c1d9 --- /dev/null +++ b/pkg/tools/send_file_test.go @@ -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) + } + }) + } +}