From 4c133dc2d956fb82251eafe8235d8f7ce2ba5b55 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:44:31 +0800 Subject: [PATCH] refactor(tools): reorganize tool packages and facades --- pkg/tools/facade_compat_test.go | 15 ++ pkg/tools/{ => fs}/edit.go | 2 +- pkg/tools/{ => fs}/edit_test.go | 2 +- pkg/tools/{ => fs}/filesystem.go | 14 +- pkg/tools/{ => fs}/filesystem_test.go | 39 +---- pkg/tools/{ => fs}/load_image.go | 2 +- pkg/tools/{ => fs}/load_image_test.go | 25 +--- pkg/tools/{ => fs}/send_file.go | 2 +- pkg/tools/{ => fs}/send_file_test.go | 2 +- pkg/tools/fs/shared.go | 37 +++++ pkg/tools/fs_facade.go | 79 +++++++++++ pkg/tools/fs_registry_compat_test.go | 46 ++++++ pkg/tools/{ => hardware}/i2c.go | 2 +- pkg/tools/{ => hardware}/i2c_linux.go | 2 +- pkg/tools/{ => hardware}/i2c_other.go | 2 +- pkg/tools/hardware/shared.go | 13 ++ pkg/tools/{ => hardware}/spi.go | 2 +- pkg/tools/{ => hardware}/spi_linux.go | 2 +- pkg/tools/{ => hardware}/spi_other.go | 2 +- pkg/tools/hardware_facade.go | 16 +++ pkg/tools/identifier_compat.go | 48 +++++++ pkg/tools/integration/helpers.go | 134 ++++++++++++++++++ pkg/tools/{ => integration}/mcp_tool.go | 2 +- pkg/tools/{ => integration}/mcp_tool_test.go | 2 +- pkg/tools/{ => integration}/message.go | 2 +- pkg/tools/{ => integration}/message_test.go | 2 +- pkg/tools/{ => integration}/reaction.go | 2 +- pkg/tools/{ => integration}/reaction_test.go | 2 +- pkg/tools/integration/shared.go | 77 ++++++++++ pkg/tools/{ => integration}/skills_install.go | 2 +- .../{ => integration}/skills_install_test.go | 2 +- pkg/tools/{ => integration}/skills_search.go | 2 +- .../{ => integration}/skills_search_test.go | 2 +- pkg/tools/{ => integration}/tts_send.go | 2 +- pkg/tools/{ => integration}/web.go | 2 +- pkg/tools/{ => integration}/web_test.go | 2 +- pkg/tools/integration_facade.go | 105 ++++++++++++++ pkg/tools/load_image_compat_test.go | 29 ++++ pkg/tools/path_compat.go | 19 +++ pkg/tools/session.go | 8 -- pkg/tools/{ => shared}/base.go | 2 +- pkg/tools/{ => shared}/result.go | 2 +- pkg/tools/{ => shared}/types.go | 10 +- pkg/tools/shared_facade.go | 110 ++++++++++++++ 44 files changed, 778 insertions(+), 98 deletions(-) create mode 100644 pkg/tools/facade_compat_test.go rename pkg/tools/{ => fs}/edit.go (99%) rename pkg/tools/{ => fs}/edit_test.go (99%) rename pkg/tools/{ => fs}/filesystem.go (99%) rename pkg/tools/{ => fs}/filesystem_test.go (96%) rename pkg/tools/{ => fs}/load_image.go (99%) rename pkg/tools/{ => fs}/load_image_test.go (90%) rename pkg/tools/{ => fs}/send_file.go (99%) rename pkg/tools/{ => fs}/send_file_test.go (99%) create mode 100644 pkg/tools/fs/shared.go create mode 100644 pkg/tools/fs_facade.go create mode 100644 pkg/tools/fs_registry_compat_test.go rename pkg/tools/{ => hardware}/i2c.go (99%) rename pkg/tools/{ => hardware}/i2c_linux.go (99%) rename pkg/tools/{ => hardware}/i2c_other.go (95%) create mode 100644 pkg/tools/hardware/shared.go rename pkg/tools/{ => hardware}/spi.go (99%) rename pkg/tools/{ => hardware}/spi_linux.go (99%) rename pkg/tools/{ => hardware}/spi_other.go (94%) create mode 100644 pkg/tools/hardware_facade.go create mode 100644 pkg/tools/identifier_compat.go create mode 100644 pkg/tools/integration/helpers.go rename pkg/tools/{ => integration}/mcp_tool.go (99%) rename pkg/tools/{ => integration}/mcp_tool_test.go (99%) rename pkg/tools/{ => integration}/message.go (99%) rename pkg/tools/{ => integration}/message_test.go (99%) rename pkg/tools/{ => integration}/reaction.go (98%) rename pkg/tools/{ => integration}/reaction_test.go (99%) create mode 100644 pkg/tools/integration/shared.go rename pkg/tools/{ => integration}/skills_install.go (99%) rename pkg/tools/{ => integration}/skills_install_test.go (99%) rename pkg/tools/{ => integration}/skills_search.go (99%) rename pkg/tools/{ => integration}/skills_search_test.go (99%) rename pkg/tools/{ => integration}/tts_send.go (98%) rename pkg/tools/{ => integration}/web.go (99%) rename pkg/tools/{ => integration}/web_test.go (99%) create mode 100644 pkg/tools/integration_facade.go create mode 100644 pkg/tools/load_image_compat_test.go create mode 100644 pkg/tools/path_compat.go rename pkg/tools/{ => shared}/base.go (99%) rename pkg/tools/{ => shared}/result.go (99%) rename pkg/tools/{ => shared}/types.go (91%) create mode 100644 pkg/tools/shared_facade.go diff --git a/pkg/tools/facade_compat_test.go b/pkg/tools/facade_compat_test.go new file mode 100644 index 000000000..672554209 --- /dev/null +++ b/pkg/tools/facade_compat_test.go @@ -0,0 +1,15 @@ +package tools + +import "testing" + +func TestFacadeConstructorsRemainAvailable(t *testing.T) { + if NewI2CTool() == nil { + t.Fatal("NewI2CTool should return a tool") + } + if NewSPITool() == nil { + t.Fatal("NewSPITool should return a tool") + } + if NewMessageTool() == nil { + t.Fatal("NewMessageTool should return a tool") + } +} diff --git a/pkg/tools/edit.go b/pkg/tools/fs/edit.go similarity index 99% rename from pkg/tools/edit.go rename to pkg/tools/fs/edit.go index c527dab54..827ea50c8 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/fs/edit.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/edit_test.go b/pkg/tools/fs/edit_test.go similarity index 99% rename from pkg/tools/edit_test.go rename to pkg/tools/fs/edit_test.go index 83a7e778c..4c25322ef 100644 --- a/pkg/tools/edit_test.go +++ b/pkg/tools/fs/edit_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/filesystem.go b/pkg/tools/fs/filesystem.go similarity index 99% rename from pkg/tools/filesystem.go rename to pkg/tools/fs/filesystem.go index 0f6811f33..262d88d99 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/fs/filesystem.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "bufio" @@ -24,6 +24,18 @@ import ( const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow +func ValidatePathWithAllowPaths( + path, workspace string, + restrict bool, + patterns []*regexp.Regexp, +) (string, error) { + return validatePathWithAllowPaths(path, workspace, restrict, patterns) +} + +func IsAllowedPath(path string, patterns []*regexp.Regexp) bool { + return isAllowedPath(path, patterns) +} + func validatePathWithAllowPaths( path, workspace string, restrict bool, diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/fs/filesystem_test.go similarity index 96% rename from pkg/tools/filesystem_test.go rename to pkg/tools/fs/filesystem_test.go index 0ab37c215..4387332be 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/fs/filesystem_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" @@ -1050,43 +1050,6 @@ func TestReadFileLinesTool_OffsetBeyondEOF(t *testing.T) { } } -func TestReadFileLinesTool_RegistryValidationSupportsMaxLinesAndRejectsLimit(t *testing.T) { - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "registry_lines.txt") - - err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644) - if err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - reg := NewToolRegistry() - reg.Register(NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)) - - result := reg.Execute(context.Background(), "read_file", map[string]any{ - "path": testFile, - "start_line": 1, - "max_lines": 1, - }) - if result.IsError { - t.Fatalf("expected max_lines to pass registry validation, got: %s", result.ForLLM) - } - if !strings.Contains(result.ForLLM, "1|line 1\n") { - t.Fatalf("expected first line via max_lines, got: %s", result.ForLLM) - } - - result = reg.Execute(context.Background(), "read_file", map[string]any{ - "path": testFile, - "start_line": 2, - "limit": 1, - }) - if !result.IsError { - t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM) - } - if !strings.Contains(result.ForLLM, "unexpected property \"limit\"") { - t.Fatalf("expected registry validation error for limit, got: %s", result.ForLLM) - } -} - func TestReadFileLinesTool_RejectsOffset(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "legacy_offset.txt") diff --git a/pkg/tools/load_image.go b/pkg/tools/fs/load_image.go similarity index 99% rename from pkg/tools/load_image.go rename to pkg/tools/fs/load_image.go index 41ea6d054..6f612faea 100644 --- a/pkg/tools/load_image.go +++ b/pkg/tools/fs/load_image.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/load_image_test.go b/pkg/tools/fs/load_image_test.go similarity index 90% rename from pkg/tools/load_image_test.go rename to pkg/tools/fs/load_image_test.go index 91118f93e..72f163d81 100644 --- a/pkg/tools/load_image_test.go +++ b/pkg/tools/fs/load_image_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" @@ -9,7 +9,6 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/providers" ) func TestLoadImage_PathRequired(t *testing.T) { @@ -78,28 +77,6 @@ func TestLoadImage_FileTooLarge(t *testing.T) { } } -func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) { - manager := NewSubagentManager(nil, "gpt-test", "/tmp") - - called := false - manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { - called = true - return msgs - }) - - manager.mu.RLock() - got := manager.mediaResolver - manager.mu.RUnlock() - - if got == nil { - t.Fatal("expected mediaResolver to be set") - } - - if called { - t.Fatal("resolver should not be called during SetMediaResolver") - } -} - func TestLoadImage_SuccessPath(t *testing.T) { dir := t.TempDir() diff --git a/pkg/tools/send_file.go b/pkg/tools/fs/send_file.go similarity index 99% rename from pkg/tools/send_file.go rename to pkg/tools/fs/send_file.go index 44198381e..e4f90bf61 100644 --- a/pkg/tools/send_file.go +++ b/pkg/tools/fs/send_file.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/send_file_test.go b/pkg/tools/fs/send_file_test.go similarity index 99% rename from pkg/tools/send_file_test.go rename to pkg/tools/fs/send_file_test.go index f36baf7d0..771393b75 100644 --- a/pkg/tools/send_file_test.go +++ b/pkg/tools/fs/send_file_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/fs/shared.go b/pkg/tools/fs/shared.go new file mode 100644 index 000000000..6d46e692b --- /dev/null +++ b/pkg/tools/fs/shared.go @@ -0,0 +1,37 @@ +package fstools + +import ( + "context" + + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +type ToolResult = toolshared.ToolResult + +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + return toolshared.WithToolContext(ctx, channel, chatID) +} + +func ToolChannel(ctx context.Context) string { + return toolshared.ToolChannel(ctx) +} + +func ToolChatID(ctx context.Context) string { + return toolshared.ToolChatID(ctx) +} + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func NewToolResult(forLLM string) *ToolResult { + return toolshared.NewToolResult(forLLM) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} + +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return toolshared.MediaResult(forLLM, mediaRefs) +} diff --git a/pkg/tools/fs_facade.go b/pkg/tools/fs_facade.go new file mode 100644 index 000000000..13bb827c3 --- /dev/null +++ b/pkg/tools/fs_facade.go @@ -0,0 +1,79 @@ +package tools + +import ( + "regexp" + + "github.com/sipeed/picoclaw/pkg/media" + fstools "github.com/sipeed/picoclaw/pkg/tools/fs" +) + +type ( + ReadFileTool = fstools.ReadFileTool + ReadFileLinesTool = fstools.ReadFileLinesTool + WriteFileTool = fstools.WriteFileTool + ListDirTool = fstools.ListDirTool + EditFileTool = fstools.EditFileTool + AppendFileTool = fstools.AppendFileTool + LoadImageTool = fstools.LoadImageTool + SendFileTool = fstools.SendFileTool +) + +const MaxReadFileSize = fstools.MaxReadFileSize + +func NewReadFileTool(workspace string, restrict bool, maxReadFileSize int, allowPaths ...[]*regexp.Regexp) *ReadFileTool { + return fstools.NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewReadFileBytesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileTool { + return fstools.NewReadFileBytesTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewReadFileLinesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileLinesTool { + return fstools.NewReadFileLinesTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool { + return fstools.NewWriteFileTool(workspace, restrict, allowPaths...) +} + +func NewListDirTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ListDirTool { + return fstools.NewListDirTool(workspace, restrict, allowPaths...) +} + +func NewEditFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *EditFileTool { + return fstools.NewEditFileTool(workspace, restrict, allowPaths...) +} + +func NewAppendFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *AppendFileTool { + return fstools.NewAppendFileTool(workspace, restrict, allowPaths...) +} + +func NewLoadImageTool( + workspace string, + restrict bool, + maxFileSize int, + store media.MediaStore, + allowPaths ...[]*regexp.Regexp, +) *LoadImageTool { + return fstools.NewLoadImageTool(workspace, restrict, maxFileSize, store, allowPaths...) +} + +func NewSendFileTool( + workspace string, + restrict bool, + maxFileSize int, + store media.MediaStore, + allowPaths ...[]*regexp.Regexp, +) *SendFileTool { + return fstools.NewSendFileTool(workspace, restrict, maxFileSize, store, allowPaths...) +} diff --git a/pkg/tools/fs_registry_compat_test.go b/pkg/tools/fs_registry_compat_test.go new file mode 100644 index 000000000..51e080217 --- /dev/null +++ b/pkg/tools/fs_registry_compat_test.go @@ -0,0 +1,46 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadFileLinesTool_RegistryValidationSupportsMaxLinesAndRejectsLimit(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "registry_lines.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + reg := NewToolRegistry() + reg.Register(NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)) + + result := reg.Execute(context.Background(), "read_file", map[string]any{ + "path": testFile, + "start_line": 1, + "max_lines": 1, + }) + if result.IsError { + t.Fatalf("expected max_lines to pass registry validation, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|line 1\n") { + t.Fatalf("expected first line via max_lines, got: %s", result.ForLLM) + } + + result = reg.Execute(context.Background(), "read_file", map[string]any{ + "path": testFile, + "start_line": 2, + "limit": 1, + }) + if !result.IsError { + t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "unexpected property \"limit\"") { + t.Fatalf("expected registry validation error for limit, got: %s", result.ForLLM) + } +} diff --git a/pkg/tools/i2c.go b/pkg/tools/hardware/i2c.go similarity index 99% rename from pkg/tools/i2c.go rename to pkg/tools/hardware/i2c.go index 779b1d5a7..caa0017ea 100644 --- a/pkg/tools/i2c.go +++ b/pkg/tools/hardware/i2c.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "context" diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/hardware/i2c_linux.go similarity index 99% rename from pkg/tools/i2c_linux.go rename to pkg/tools/hardware/i2c_linux.go index 4eaaf8f09..771d11d90 100644 --- a/pkg/tools/i2c_linux.go +++ b/pkg/tools/hardware/i2c_linux.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "encoding/json" diff --git a/pkg/tools/i2c_other.go b/pkg/tools/hardware/i2c_other.go similarity index 95% rename from pkg/tools/i2c_other.go rename to pkg/tools/hardware/i2c_other.go index 7becf8339..4a0a130e0 100644 --- a/pkg/tools/i2c_other.go +++ b/pkg/tools/hardware/i2c_other.go @@ -1,6 +1,6 @@ //go:build !linux -package tools +package hardwaretools // scan is a stub for non-Linux platforms. func (t *I2CTool) scan(args map[string]any) *ToolResult { diff --git a/pkg/tools/hardware/shared.go b/pkg/tools/hardware/shared.go new file mode 100644 index 000000000..3012f3e6c --- /dev/null +++ b/pkg/tools/hardware/shared.go @@ -0,0 +1,13 @@ +package hardwaretools + +import toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" + +type ToolResult = toolshared.ToolResult + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} diff --git a/pkg/tools/spi.go b/pkg/tools/hardware/spi.go similarity index 99% rename from pkg/tools/spi.go rename to pkg/tools/hardware/spi.go index 0ca17e84f..298d36f08 100644 --- a/pkg/tools/spi.go +++ b/pkg/tools/hardware/spi.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "context" diff --git a/pkg/tools/spi_linux.go b/pkg/tools/hardware/spi_linux.go similarity index 99% rename from pkg/tools/spi_linux.go rename to pkg/tools/hardware/spi_linux.go index 9def73662..8502d6b9e 100644 --- a/pkg/tools/spi_linux.go +++ b/pkg/tools/hardware/spi_linux.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "encoding/json" diff --git a/pkg/tools/spi_other.go b/pkg/tools/hardware/spi_other.go similarity index 94% rename from pkg/tools/spi_other.go rename to pkg/tools/hardware/spi_other.go index 5d078ac3f..89fc99e67 100644 --- a/pkg/tools/spi_other.go +++ b/pkg/tools/hardware/spi_other.go @@ -1,6 +1,6 @@ //go:build !linux -package tools +package hardwaretools // transfer is a stub for non-Linux platforms. func (t *SPITool) transfer(args map[string]any) *ToolResult { diff --git a/pkg/tools/hardware_facade.go b/pkg/tools/hardware_facade.go new file mode 100644 index 000000000..f55d152cf --- /dev/null +++ b/pkg/tools/hardware_facade.go @@ -0,0 +1,16 @@ +package tools + +import hardwaretools "github.com/sipeed/picoclaw/pkg/tools/hardware" + +type ( + I2CTool = hardwaretools.I2CTool + SPITool = hardwaretools.SPITool +) + +func NewI2CTool() *I2CTool { + return hardwaretools.NewI2CTool() +} + +func NewSPITool() *SPITool { + return hardwaretools.NewSPITool() +} diff --git a/pkg/tools/identifier_compat.go b/pkg/tools/identifier_compat.go new file mode 100644 index 000000000..c5a6d9cf3 --- /dev/null +++ b/pkg/tools/identifier_compat.go @@ -0,0 +1,48 @@ +package tools + +import "strings" + +func sanitizeIdentifierComponent(s string) string { + const maxLen = 64 + + s = strings.ToLower(s) + var b strings.Builder + b.Grow(len(s)) + + prevUnderscore := false + for _, r := range s { + isAllowed := (r >= 'a' && r <= 'z') || + (r >= '0' && r <= '9') || + r == '_' || r == '-' + + if !isAllowed { + if !prevUnderscore { + b.WriteRune('_') + prevUnderscore = true + } + continue + } + + if r == '_' { + if prevUnderscore { + continue + } + prevUnderscore = true + } else { + prevUnderscore = false + } + + b.WriteRune(r) + } + + result := strings.Trim(b.String(), "_") + if result == "" { + result = "unnamed" + } + + if len(result) > maxLen { + result = result[:maxLen] + } + + return result +} diff --git a/pkg/tools/integration/helpers.go b/pkg/tools/integration/helpers.go new file mode 100644 index 000000000..b34fbc6cd --- /dev/null +++ b/pkg/tools/integration/helpers.go @@ -0,0 +1,134 @@ +package integrationtools + +import ( + "fmt" + "math" + "mime" + "path/filepath" + "regexp" + "strconv" + "strings" + "unicode" +) + +var ( + inlineMarkdownDataURLRe = regexp.MustCompile(`!\[[^\]]*\]\((data:[^)]+)\)`) + inlineRawDataURLRe = regexp.MustCompile(`data:[^;\s]+;base64,[A-Za-z0-9+/=\r\n]+`) +) + +const ( + largeBase64OmittedMessage = "[Tool returned a large base64-like payload; omitted from model context.]" + inlineMediaOmittedMessage = "[Tool returned inline media content; omitted from model context.]" +) + +func sanitizeToolLLMContent(text string) string { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return text + } + if inlineMarkdownDataURLRe.MatchString(trimmed) || inlineRawDataURLRe.MatchString(trimmed) { + cleaned := inlineMarkdownDataURLRe.ReplaceAllString(trimmed, "") + cleaned = inlineRawDataURLRe.ReplaceAllString(cleaned, "") + cleaned = strings.TrimSpace(cleaned) + if cleaned == "" { + return inlineMediaOmittedMessage + } + return cleaned + "\n" + inlineMediaOmittedMessage + } + if looksLikeLargeBase64Payload(trimmed) { + return largeBase64OmittedMessage + } + return text +} + +func looksLikeLargeBase64Payload(text string) bool { + trimmed := strings.TrimSpace(text) + if len(trimmed) < 1024 { + return false + } + + nonSpace := 0 + base64Like := 0 + spaceCount := 0 + + for _, r := range trimmed { + if unicode.IsSpace(r) { + spaceCount++ + continue + } + nonSpace++ + if (r >= 'A' && r <= 'Z') || + (r >= 'a' && r <= 'z') || + (r >= '0' && r <= '9') || + r == '+' || r == '/' || r == '=' { + base64Like++ + } + } + + if nonSpace == 0 { + return false + } + + ratio := float64(base64Like) / float64(nonSpace) + return ratio >= 0.97 && spaceCount <= len(trimmed)/128 +} + +func extensionForMIMEType(mimeType string) string { + if mimeType == "" { + return ".bin" + } + if exts, err := mime.ExtensionsByType(mimeType); err == nil && len(exts) > 0 { + return exts[0] + } + + switch strings.ToLower(mimeType) { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "audio/wav", "audio/x-wav": + return ".wav" + case "audio/mpeg": + return ".mp3" + case "audio/ogg": + return ".ogg" + case "video/mp4": + return ".mp4" + default: + return filepath.Ext(mimeType) + } +} + +func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) { + raw, exists := args[key] + if !exists { + return defaultVal, nil + } + + switch v := raw.(type) { + case float64: + if v != math.Trunc(v) { + return 0, fmt.Errorf("%s must be an integer, got float %v", key, v) + } + if v > math.MaxInt64 || v < math.MinInt64 { + return 0, fmt.Errorf("%s value %v overflows int64", key, v) + } + return int64(v), nil + case int: + return int64(v), nil + case int64: + return v, nil + case string: + parsed, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid integer format for %s parameter: %w", key, err) + } + return parsed, nil + default: + return 0, fmt.Errorf("unsupported type %T for %s parameter", raw, key) + } +} diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/integration/mcp_tool.go similarity index 99% rename from pkg/tools/mcp_tool.go rename to pkg/tools/integration/mcp_tool.go index 1caf390cf..340bb9e8e 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/integration/mcp_tool.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/integration/mcp_tool_test.go similarity index 99% rename from pkg/tools/mcp_tool_test.go rename to pkg/tools/integration/mcp_tool_test.go index f2b02d6f6..e5c54abb6 100644 --- a/pkg/tools/mcp_tool_test.go +++ b/pkg/tools/integration/mcp_tool_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/message.go b/pkg/tools/integration/message.go similarity index 99% rename from pkg/tools/message.go rename to pkg/tools/integration/message.go index 796e0af3d..98d87bcb3 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/integration/message.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/message_test.go b/pkg/tools/integration/message_test.go similarity index 99% rename from pkg/tools/message_test.go rename to pkg/tools/integration/message_test.go index 649593252..c7b7d2b6e 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/integration/message_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/reaction.go b/pkg/tools/integration/reaction.go similarity index 98% rename from pkg/tools/reaction.go rename to pkg/tools/integration/reaction.go index 3455b07a9..5a8dc87be 100644 --- a/pkg/tools/reaction.go +++ b/pkg/tools/integration/reaction.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/reaction_test.go b/pkg/tools/integration/reaction_test.go similarity index 99% rename from pkg/tools/reaction_test.go rename to pkg/tools/integration/reaction_test.go index 6fc90445a..f579fd914 100644 --- a/pkg/tools/reaction_test.go +++ b/pkg/tools/integration/reaction_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/integration/shared.go b/pkg/tools/integration/shared.go new file mode 100644 index 000000000..cc6aa3f28 --- /dev/null +++ b/pkg/tools/integration/shared.go @@ -0,0 +1,77 @@ +package integrationtools + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/session" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +type ( + Tool = toolshared.Tool + ToolResult = toolshared.ToolResult + AsyncCallback = toolshared.AsyncCallback +) + +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + return toolshared.WithToolContext(ctx, channel, chatID) +} + +func WithToolInboundContext( + ctx context.Context, + channel, chatID, messageID, replyToMessageID string, +) context.Context { + return toolshared.WithToolInboundContext(ctx, channel, chatID, messageID, replyToMessageID) +} + +func WithToolSessionContext( + ctx context.Context, + agentID, sessionKey string, + scope *session.SessionScope, +) context.Context { + return toolshared.WithToolSessionContext(ctx, agentID, sessionKey, scope) +} + +func ToolChannel(ctx context.Context) string { + return toolshared.ToolChannel(ctx) +} + +func ToolChatID(ctx context.Context) string { + return toolshared.ToolChatID(ctx) +} + +func ToolMessageID(ctx context.Context) string { + return toolshared.ToolMessageID(ctx) +} + +func ToolAgentID(ctx context.Context) string { + return toolshared.ToolAgentID(ctx) +} + +func ToolSessionKey(ctx context.Context) string { + return toolshared.ToolSessionKey(ctx) +} + +func ToolSessionScope(ctx context.Context) *session.SessionScope { + return toolshared.ToolSessionScope(ctx) +} + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} + +func NewToolResult(forLLM string) *ToolResult { + return toolshared.NewToolResult(forLLM) +} + +func UserResult(content string) *ToolResult { + return toolshared.UserResult(content) +} + +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return toolshared.MediaResult(forLLM, mediaRefs) +} diff --git a/pkg/tools/skills_install.go b/pkg/tools/integration/skills_install.go similarity index 99% rename from pkg/tools/skills_install.go rename to pkg/tools/integration/skills_install.go index 79d0672b9..1824f2c0a 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/integration/skills_install.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/skills_install_test.go b/pkg/tools/integration/skills_install_test.go similarity index 99% rename from pkg/tools/skills_install_test.go rename to pkg/tools/integration/skills_install_test.go index 125348883..01d2fd2bc 100644 --- a/pkg/tools/skills_install_test.go +++ b/pkg/tools/integration/skills_install_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/skills_search.go b/pkg/tools/integration/skills_search.go similarity index 99% rename from pkg/tools/skills_search.go rename to pkg/tools/integration/skills_search.go index 2b6cffd38..f080aba95 100644 --- a/pkg/tools/skills_search.go +++ b/pkg/tools/integration/skills_search.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/skills_search_test.go b/pkg/tools/integration/skills_search_test.go similarity index 99% rename from pkg/tools/skills_search_test.go rename to pkg/tools/integration/skills_search_test.go index 0e5387cf5..fcce48b49 100644 --- a/pkg/tools/skills_search_test.go +++ b/pkg/tools/integration/skills_search_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/tts_send.go b/pkg/tools/integration/tts_send.go similarity index 98% rename from pkg/tools/tts_send.go rename to pkg/tools/integration/tts_send.go index 3d569e3f7..6c9135624 100644 --- a/pkg/tools/tts_send.go +++ b/pkg/tools/integration/tts_send.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/web.go b/pkg/tools/integration/web.go similarity index 99% rename from pkg/tools/web.go rename to pkg/tools/integration/web.go index 9883d55e5..58db34589 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/integration/web.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "bytes" diff --git a/pkg/tools/web_test.go b/pkg/tools/integration/web_test.go similarity index 99% rename from pkg/tools/web_test.go rename to pkg/tools/integration/web_test.go index cf9d22d05..4ad5a3468 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/integration/web_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "bytes" diff --git a/pkg/tools/integration_facade.go b/pkg/tools/integration_facade.go new file mode 100644 index 000000000..11e604bca --- /dev/null +++ b/pkg/tools/integration_facade.go @@ -0,0 +1,105 @@ +package tools + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/skills" + integrationtools "github.com/sipeed/picoclaw/pkg/tools/integration" +) + +type ( + SendCallbackWithContext = integrationtools.SendCallbackWithContext + ReactionCallback = integrationtools.ReactionCallback + MCPManager = integrationtools.MCPManager + MCPTool = integrationtools.MCPTool + FindSkillsTool = integrationtools.FindSkillsTool + InstallSkillTool = integrationtools.InstallSkillTool + MessageTool = integrationtools.MessageTool + ReactionTool = integrationtools.ReactionTool + SendTTSTool = integrationtools.SendTTSTool + APIKeyPool = integrationtools.APIKeyPool + APIKeyIterator = integrationtools.APIKeyIterator + SearchProvider = integrationtools.SearchProvider + SearchResultItem = integrationtools.SearchResultItem + BraveSearchProvider = integrationtools.BraveSearchProvider + TavilySearchProvider = integrationtools.TavilySearchProvider + SogouSearchProvider = integrationtools.SogouSearchProvider + DuckDuckGoSearchProvider = integrationtools.DuckDuckGoSearchProvider + PerplexitySearchProvider = integrationtools.PerplexitySearchProvider + SearXNGSearchProvider = integrationtools.SearXNGSearchProvider + GLMSearchProvider = integrationtools.GLMSearchProvider + BaiduSearchProvider = integrationtools.BaiduSearchProvider + WebSearchTool = integrationtools.WebSearchTool + WebSearchToolOptions = integrationtools.WebSearchToolOptions + WebFetchTool = integrationtools.WebFetchTool +) + +func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool { + return integrationtools.NewMCPTool(manager, serverName, tool) +} + +func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool { + return integrationtools.NewFindSkillsTool(registryMgr, cache) +} + +func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool { + return integrationtools.NewInstallSkillTool(registryMgr, workspace) +} + +func NewMessageTool() *MessageTool { + return integrationtools.NewMessageTool() +} + +func NewReactionTool() *ReactionTool { + return integrationtools.NewReactionTool() +} + +func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool { + return integrationtools.NewSendTTSTool(provider, store) +} + +func NewAPIKeyPool(keys []string) *APIKeyPool { + return integrationtools.NewAPIKeyPool(keys) +} + +func SetPreferredWebSearchLanguage(lang string) { + integrationtools.SetPreferredWebSearchLanguage(lang) +} + +func GetPreferredWebSearchLanguage() string { + return integrationtools.GetPreferredWebSearchLanguage() +} + +func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { + return integrationtools.NewWebSearchTool(opts) +} + +func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) { + return integrationtools.NewWebFetchTool(maxChars, format, fetchLimitBytes) +} + +func NewWebFetchToolWithProxy( + maxChars int, + proxy string, + format string, + fetchLimitBytes int64, + privateHostWhitelist []string, +) (*WebFetchTool, error) { + return integrationtools.NewWebFetchToolWithProxy(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist) +} + +func NewWebFetchToolWithConfig( + maxChars int, + proxy string, + format string, + fetchLimitBytes int64, + privateHostWhitelist []string, +) (*WebFetchTool, error) { + return integrationtools.NewWebFetchToolWithConfig(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist) +} + +func _keepContext(context.Context) {} diff --git a/pkg/tools/load_image_compat_test.go b/pkg/tools/load_image_compat_test.go new file mode 100644 index 000000000..a29ee2042 --- /dev/null +++ b/pkg/tools/load_image_compat_test.go @@ -0,0 +1,29 @@ +package tools + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) { + manager := NewSubagentManager(nil, "gpt-test", "/tmp") + + called := false + manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { + called = true + return msgs + }) + + manager.mu.RLock() + got := manager.mediaResolver + manager.mu.RUnlock() + + if got == nil { + t.Fatal("expected mediaResolver to be set") + } + + if called { + t.Fatal("resolver should not be called during SetMediaResolver") + } +} diff --git a/pkg/tools/path_compat.go b/pkg/tools/path_compat.go new file mode 100644 index 000000000..9e677cb2b --- /dev/null +++ b/pkg/tools/path_compat.go @@ -0,0 +1,19 @@ +package tools + +import ( + "regexp" + + fstools "github.com/sipeed/picoclaw/pkg/tools/fs" +) + +func validatePathWithAllowPaths( + path, workspace string, + restrict bool, + patterns []*regexp.Regexp, +) (string, error) { + return fstools.ValidatePathWithAllowPaths(path, workspace, restrict, patterns) +} + +func isAllowedPath(path string, patterns []*regexp.Regexp) bool { + return fstools.IsAllowedPath(path, patterns) +} diff --git a/pkg/tools/session.go b/pkg/tools/session.go index 141dd4b5e..8c7584254 100644 --- a/pkg/tools/session.go +++ b/pkg/tools/session.go @@ -242,11 +242,3 @@ func (sm *SessionManager) List() []SessionInfo { func generateSessionID() string { return uuid.New().String()[:8] } - -type SessionInfo struct { - ID string `json:"id"` - Command string `json:"command"` - Status string `json:"status"` - PID int `json:"pid"` - StartedAt int64 `json:"startedAt"` -} diff --git a/pkg/tools/base.go b/pkg/tools/shared/base.go similarity index 99% rename from pkg/tools/base.go rename to pkg/tools/shared/base.go index e1f9aacc0..5498d24ab 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/shared/base.go @@ -1,4 +1,4 @@ -package tools +package toolshared import ( "context" diff --git a/pkg/tools/result.go b/pkg/tools/shared/result.go similarity index 99% rename from pkg/tools/result.go rename to pkg/tools/shared/result.go index c81213125..1719e1d93 100644 --- a/pkg/tools/result.go +++ b/pkg/tools/shared/result.go @@ -1,4 +1,4 @@ -package tools +package toolshared import ( "encoding/json" diff --git a/pkg/tools/types.go b/pkg/tools/shared/types.go similarity index 91% rename from pkg/tools/types.go rename to pkg/tools/shared/types.go index 4d1a18d5a..8a74d30f3 100644 --- a/pkg/tools/types.go +++ b/pkg/tools/shared/types.go @@ -1,4 +1,4 @@ -package tools +package toolshared import "context" @@ -77,3 +77,11 @@ type ExecResponse struct { Error string `json:"error,omitempty"` Sessions []SessionInfo `json:"sessions,omitempty"` } + +type SessionInfo struct { + ID string `json:"id"` + Command string `json:"command"` + Status string `json:"status"` + PID int `json:"pid"` + StartedAt int64 `json:"startedAt"` +} diff --git a/pkg/tools/shared_facade.go b/pkg/tools/shared_facade.go new file mode 100644 index 000000000..28717c435 --- /dev/null +++ b/pkg/tools/shared_facade.go @@ -0,0 +1,110 @@ +package tools + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/session" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +type ( + Message = toolshared.Message + ToolCall = toolshared.ToolCall + FunctionCall = toolshared.FunctionCall + LLMResponse = toolshared.LLMResponse + UsageInfo = toolshared.UsageInfo + LLMProvider = toolshared.LLMProvider + ToolDefinition = toolshared.ToolDefinition + ToolFunctionDefinition = toolshared.ToolFunctionDefinition + ExecRequest = toolshared.ExecRequest + ExecResponse = toolshared.ExecResponse + SessionInfo = toolshared.SessionInfo + Tool = toolshared.Tool + AsyncCallback = toolshared.AsyncCallback + AsyncExecutor = toolshared.AsyncExecutor + ToolResult = toolshared.ToolResult +) + +const ( + handledToolLLMNote = "The requested output has already been delivered to the user in the current chat. Do not call send_file or any other delivery tool again. If you reply, provide only a brief confirmation." + artifactPathsLLMNote = "Use `send_file` with one of these paths to send it to the user, or use file/exec tools to save it inside the workspace if requested." +) + +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + return toolshared.WithToolContext(ctx, channel, chatID) +} + +func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context { + return toolshared.WithToolMessageContext(ctx, messageID, replyToMessageID) +} + +func WithToolInboundContext( + ctx context.Context, + channel, chatID, messageID, replyToMessageID string, +) context.Context { + return toolshared.WithToolInboundContext(ctx, channel, chatID, messageID, replyToMessageID) +} + +func WithToolSessionContext( + ctx context.Context, + agentID, sessionKey string, + scope *session.SessionScope, +) context.Context { + return toolshared.WithToolSessionContext(ctx, agentID, sessionKey, scope) +} + +func ToolChannel(ctx context.Context) string { + return toolshared.ToolChannel(ctx) +} + +func ToolChatID(ctx context.Context) string { + return toolshared.ToolChatID(ctx) +} + +func ToolMessageID(ctx context.Context) string { + return toolshared.ToolMessageID(ctx) +} + +func ToolReplyToMessageID(ctx context.Context) string { + return toolshared.ToolReplyToMessageID(ctx) +} + +func ToolAgentID(ctx context.Context) string { + return toolshared.ToolAgentID(ctx) +} + +func ToolSessionKey(ctx context.Context) string { + return toolshared.ToolSessionKey(ctx) +} + +func ToolSessionScope(ctx context.Context) *session.SessionScope { + return toolshared.ToolSessionScope(ctx) +} + +func ToolToSchema(tool Tool) map[string]any { + return toolshared.ToolToSchema(tool) +} + +func NewToolResult(forLLM string) *ToolResult { + return toolshared.NewToolResult(forLLM) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} + +func AsyncResult(forLLM string) *ToolResult { + return toolshared.AsyncResult(forLLM) +} + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func UserResult(content string) *ToolResult { + return toolshared.UserResult(content) +} + +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return toolshared.MediaResult(forLLM, mediaRefs) +}