From 9f93158218e94eaf22a5c0b0bff6f77d5cdae38d Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:48:58 +0900 Subject: [PATCH] refactor: replace moderate-diff Go files with upstream, add fork extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ~30 more Go source files with upstream versions and re-add fork-only functionality via appended code or _ext.go files. Key files aligned: providers/types.go, state/state.go, tools/base.go, tools/registry.go, channels/*, config/defaults.go, providers/*. Conflict metrics: 105 → 34 files, 646 → 315 markers (-51%) Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw-launcher-tui/internal/ui/app.go | 56 +++++++----- cmd/picoclaw/internal/onboard/helpers.go | 72 ++++++++------- pkg/agent/registry.go | 33 ++++--- pkg/channels/discord/discord.go | 65 ++++++-------- pkg/channels/feishu/feishu_64.go | 16 ++-- pkg/channels/matrix/matrix.go | 63 +++++++++---- pkg/channels/pico/pico.go | 20 ----- pkg/channels/slack/slack.go | 20 +++-- pkg/config/defaults.go | 81 +++++++++++++---- .../sources/openclaw/openclaw_config.go | 24 ++--- pkg/providers/antigravity_provider.go | 15 ++-- pkg/providers/claude_cli_provider.go | 35 +------- pkg/providers/codex_cli_provider.go | 35 +------- pkg/providers/codex_provider.go | 32 +++---- pkg/providers/factory.go | 69 ++++++++------- pkg/providers/factory_provider.go | 77 +++++----------- pkg/providers/http_provider.go | 37 +------- pkg/providers/toolcall_utils.go | 71 ++++++++++----- pkg/providers/types.go | 39 ++++---- pkg/state/state.go | 88 +++++++++---------- pkg/tools/edit.go | 49 ++++------- pkg/tools/i2c.go | 30 +++---- pkg/tools/i2c_linux.go | 15 ++-- pkg/tools/message.go | 42 ++++----- pkg/tools/registry.go | 70 +++------------ pkg/tools/shell_process_unix.go | 55 +----------- pkg/tools/skills_install.go | 75 ++++++---------- pkg/tools/skills_search.go | 18 ++-- pkg/tools/spi.go | 33 +++---- pkg/tools/spi_linux.go | 15 ++-- 30 files changed, 574 insertions(+), 776 deletions(-) diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go index 8628afab3..a2ccddf70 100644 --- a/cmd/picoclaw-launcher-tui/internal/ui/app.go +++ b/cmd/picoclaw-launcher-tui/internal/ui/app.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "os" "os/exec" "path/filepath" @@ -67,6 +68,7 @@ func Run() error { root := tview.NewFlex().SetDirection(tview.FlexRow) root.AddItem(bannerView(), 6, 0, false) root.AddItem(state.pages, 0, 1, true) + root.AddItem(footerView(), 1, 0, false) if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil { return err @@ -102,7 +104,7 @@ func (s *appState) pop() { } func (s *appState) mainMenu() tview.Primitive { - menu := NewMenu("Config Menu", nil) + menu := NewMenu("Menu", nil) refreshMainMenu(menu, s) menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { @@ -110,10 +112,7 @@ func (s *appState) mainMenu() tview.Primitive { s.requestExit() return nil } - if event.Rune() == 'q' { - s.requestExit() - return nil - } + return event }) @@ -131,6 +130,32 @@ func (s *appState) refreshMenu(name string, menu *Menu) { } } +func (s *appState) countChannels() (enabled int, total int) { + c := s.config.Channels + entries := []bool{ + c.Telegram.Enabled, + c.Discord.Enabled, + c.QQ.Enabled, + c.MaixCam.Enabled, + c.WhatsApp.Enabled, + c.Feishu.Enabled, + c.DingTalk.Enabled, + c.Slack.Enabled, + c.Matrix.Enabled, + c.LINE.Enabled, + c.OneBot.Enabled, + c.WeCom.Enabled, + c.WeComApp.Enabled, + } + total = len(entries) + for _, v := range entries { + if v { + enabled++ + } + } + return enabled, total +} + func refreshMainMenuIfPresent(s *appState) { if menu, ok := s.menus["main"]; ok { refreshMainMenu(menu, s) @@ -141,6 +166,7 @@ func refreshMainMenu(menu *Menu, s *appState) { selectedModel := s.selectedModelName() modelReady := selectedModel != "" channelReady := s.hasEnabledChannel() + enabledCount, totalChannels := s.countChannels() gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning() gatewayLabel := "Start Gateway" @@ -153,7 +179,7 @@ func refreshMainMenu(menu *Menu, s *appState) { items := []MenuItem{ { Label: rootModelLabel(selectedModel), - Description: rootModelDescription(selectedModel), + Description: rootModelDescription(), Action: func() { s.push("model", s.modelMenu()) }, @@ -167,7 +193,7 @@ func refreshMainMenu(menu *Menu, s *appState) { }, { Label: rootChannelLabel(channelReady), - Description: rootChannelDescription(channelReady), + Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels), Action: func() { s.push("channel", s.channelMenu()) }, @@ -311,16 +337,13 @@ func (s *appState) selectedModelName() string { func rootModelLabel(selected string) string { if selected == "" { - return "Model (no model selected)" + return "Model (None)" } return "Model (" + selected + ")" } -func rootModelDescription(selected string) string { - if selected == "" { - return "no model selected" - } - return "selected" +func rootModelDescription() string { + return "Using SPACE to choose your model" } func rootChannelLabel(valid bool) string { @@ -330,13 +353,6 @@ func rootChannelLabel(valid bool) string { return "Channel" } -func rootChannelDescription(valid bool) string { - if !valid { - return "no channel enabled" - } - return "enabled" -} - func (s *appState) startTalk() { if !s.isActiveModelValid() { s.showMessage("Model required", "Select a valid model before starting talk") diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go index 55bbd7de9..4db8bdc8b 100644 --- a/cmd/picoclaw/internal/onboard/helpers.go +++ b/cmd/picoclaw/internal/onboard/helpers.go @@ -2,6 +2,7 @@ package onboard import ( "fmt" + "io/fs" "os" "path/filepath" @@ -9,30 +10,6 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) -var workspaceTemplates = map[string]string{ - "AGENTS.md": `# Agent Instructions - -You are a helpful AI assistant. Be concise, accurate, and friendly. -`, - "IDENTITY.md": `# Identity - -## Name -PicoClaw 🦞 -`, - "SOUL.md": `# Soul - -I am picoclaw, a lightweight AI assistant powered by AI. -`, - "USER.md": `# User - -Information about user goes here. -`, - "memory/MEMORY.md": `# Long-term Memory - -This file stores important information that should persist across sessions. -`, -} - func onboard() { configPath := internal.GetConfigPath() @@ -77,19 +54,48 @@ func createWorkspaceTemplates(workspace string) { } func copyEmbeddedToTarget(targetDir string) error { + // Ensure target directory exists if err := os.MkdirAll(targetDir, 0o755); err != nil { - return fmt.Errorf("failed to create target directory: %w", err) + return fmt.Errorf("Failed to create target directory: %w", err) } - for relPath, content := range workspaceTemplates { - targetPath := filepath.Join(targetDir, relPath) + // Walk through all files in embed.FS + err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip directories + if d.IsDir() { + return nil + } + + // Read embedded file + data, err := embeddedFiles.ReadFile(path) + if err != nil { + return fmt.Errorf("Failed to read embedded file %s: %w", path, err) + } + + new_path, err := filepath.Rel("workspace", path) + if err != nil { + return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err) + } + + // Build target file path + targetPath := filepath.Join(targetDir, new_path) + + // Ensure target file's directory exists if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", filepath.Dir(targetPath), err) + return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err) } - if err := os.WriteFile(targetPath, []byte(content), 0o644); err != nil { - return fmt.Errorf("failed to write file %s: %w", targetPath, err) - } - } - return nil + // Write file + if err := os.WriteFile(targetPath, data, 0o644); err != nil { + return fmt.Errorf("Failed to write file %s: %w", targetPath, err) + } + + return nil + }) + + return err } diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index 3fb3f49ba..58b7ce440 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -12,11 +12,9 @@ import ( // AgentRegistry manages multiple agent instances and routes messages to them. type AgentRegistry struct { - agents map[string]*AgentInstance - + agents map[string]*AgentInstance resolver *routing.RouteResolver - - mu sync.RWMutex + mu sync.RWMutex } // NewAgentRegistry creates a registry from config, instantiating all agents. @@ -25,16 +23,14 @@ func NewAgentRegistry( provider providers.LLMProvider, ) *AgentRegistry { registry := &AgentRegistry{ - agents: make(map[string]*AgentInstance), - + agents: make(map[string]*AgentInstance), resolver: routing.NewRouteResolver(cfg), } agentConfigs := cfg.Agents.List if len(agentConfigs) == 0 { implicitAgent := &config.AgentConfig{ - ID: "main", - + ID: "main", Default: true, } instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider) @@ -48,13 +44,10 @@ func NewAgentRegistry( registry.agents[id] = instance logger.InfoCF("agent", "Registered agent", map[string]any{ - "agent_id": id, - - "name": ac.Name, - + "agent_id": id, + "name": ac.Name, "workspace": instance.Workspace, - - "model": instance.Model, + "model": instance.Model, }) } } @@ -121,6 +114,18 @@ func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) { } } +// Close releases resources held by all registered agents. +func (r *AgentRegistry) Close() { + r.mu.RLock() + defer r.mu.RUnlock() + for _, agent := range r.agents { + if err := agent.Close(); err != nil { + logger.WarnCF("agent", "Failed to close agent", + map[string]any{"agent_id": agent.ID, "error": err.Error()}) + } + } +} + // GetDefaultAgent returns the default agent instance. func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { r.mu.RLock() diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 4b89e0e07..83a04907c 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -45,6 +45,14 @@ type DiscordChannel struct { } func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { + discordgo.Logger = logger.NewLogger("discord"). + WithLevels(map[int]logger.LogLevel{ + discordgo.LogError: logger.ERROR, + discordgo.LogWarning: logger.WARN, + discordgo.LogInformational: logger.INFO, + discordgo.LogDebug: logger.DEBUG, + }).Log + session, err := discordgo.New("Bot " + cfg.Token) if err != nil { return nil, fmt.Errorf("failed to create discord session: %w", err) @@ -134,7 +142,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return nil } - return c.sendChunk(ctx, channelID, msg.Content) + return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) } // SendMedia implements the channels.MediaSender interface. @@ -232,42 +240,6 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes } } -// SendWithID implements channels.MessageSenderWithID. -// It sends a message and returns the platform message ID. -func (c *DiscordChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) { - if !c.IsRunning() { - return "", channels.ErrNotRunning - } - - if chatID == "" { - return "", fmt.Errorf("channel ID is empty") - } - - sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) - defer cancel() - - type result struct { - id string - err error - } - done := make(chan result, 1) - go func() { - msg, err := c.session.ChannelMessageSend(chatID, content) - if err != nil { - done <- result{"", fmt.Errorf("discord send: %w", channels.ErrTemporary)} - } else { - done <- result{msg.ID, nil} - } - }() - - select { - case r := <-done: - return r.id, r.err - case <-sendCtx.Done(): - return "", sendCtx.Err() - } -} - // EditMessage implements channels.MessageEditor. func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { _, err := c.session.ChannelMessageEdit(chatID, messageID, content) @@ -295,14 +267,29 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return msg.ID, nil } -func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { +func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() done := make(chan error, 1) go func() { - _, err := c.session.ChannelMessageSend(channelID, content) + var err error + + // If we have an ID, we send the message as "Reply" + if replyToID != "" { + _, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + Content: content, + Reference: &discordgo.MessageReference{ + MessageID: replyToID, + ChannelID: channelID, + }, + }) + } else { + // Otherwise, we send a normal message + _, err = c.session.ChannelMessageSend(channelID, content) + } + done <- err }() diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5217dd4e9..5dbbcf0af 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -4,11 +4,10 @@ package feishu import ( "context" - "crypto/rand" "encoding/json" "fmt" "io" - "math/big" + "math/rand" "net/http" "os" "path/filepath" @@ -201,18 +200,13 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { // Get emoji list from config emojiList := c.config.RandomReactionEmoji + var chosenEmoji string if len(emojiList) == 0 { // Default to "Pin" if no config - emojiList = []string{"Pin"} - } - - // Randomly choose one from the list using crypto/rand for better distribution - idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(emojiList)))) - var chosenEmoji string - if err != nil { - chosenEmoji = emojiList[0] + chosenEmoji = "Pin" } else { - chosenEmoji = emojiList[idx.Int64()] + idx := rand.Intn(len(emojiList)) + chosenEmoji = emojiList[idx] } req := larkim.NewCreateMessageReactionReqBuilder(). diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index d51eee8fb..bec5dfdac 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "html" + "io" "mime" "net/url" "os" @@ -13,6 +14,9 @@ import ( "sync" "time" + "github.com/gomarkdown/markdown" + mdhtml "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -268,6 +272,12 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { return nil } +func markdownToHTML(md string) string { + p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) + renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags}) + return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer))) +} + func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning @@ -283,16 +293,22 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return nil } - _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgText, - Body: content, - }) + _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) if err != nil { return fmt.Errorf("matrix send: %w", channels.ErrTemporary) } return nil } +func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { + mc := &event.MessageEventContent{MsgType: event.MsgText, Body: text} + if c.config.MessageFormat != "plain" { + mc.Format = event.FormatHTML + mc.FormattedBody = markdownToHTML(text) + } + return mc +} + // SendMedia implements channels.MediaSender. func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { @@ -482,10 +498,7 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI return fmt.Errorf("matrix message ID is empty") } - editContent := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: content, - } + editContent := c.messageContent(content) editContent.SetEdit(id.EventID(messageID)) _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent) @@ -714,17 +727,23 @@ func (c *MatrixChannel) downloadMedia( reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second) defer cancel() - data, err := c.client.DownloadBytes(reqCtx, parsed) + resp, err := c.client.Download(reqCtx, parsed) if err != nil { return "", err } + defer resp.Body.Close() + + reader := resp.Body + readerClose := func() error { return nil } // Encrypted attachments put URL in msgEvt.File and require client-side decryption. if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" { - err = msgEvt.File.DecryptInPlace(data) - if err != nil { + if err = msgEvt.File.PrepareForDecryption(); err != nil { return "", fmt.Errorf("decrypt matrix media: %w", err) } + decryptReader := msgEvt.File.DecryptStream(resp.Body) + reader = decryptReader + readerClose = decryptReader.Close } label := matrixMediaLabel(msgEvt, mediaKind) @@ -737,14 +756,28 @@ func (c *MatrixChannel) downloadMedia( if err != nil { return "", err } - defer tmp.Close() + tmpPath := tmp.Name() + cleanup := true + defer func() { + _ = tmp.Close() + if cleanup { + _ = os.Remove(tmpPath) + } + }() - if _, err = tmp.Write(data); err != nil { - _ = os.Remove(tmp.Name()) + _, err = io.Copy(tmp, reader) + if err != nil { + return "", err + } + if err = readerClose(); err != nil { + return "", fmt.Errorf("decrypt matrix media: %w", err) + } + if err = tmp.Close(); err != nil { return "", err } - return tmp.Name(), nil + cleanup = false + return tmpPath, nil } func matrixContentType(msgEvt *event.MessageEventContent) string { diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 4394d472a..8d8b62a67 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -150,26 +150,6 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return c.broadcastToSession(msg.ChatID, outMsg) } -// SendWithID implements channels.MessageSenderWithID. -// It sends a message and returns a generated message ID. -func (c *PicoChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) { - if !c.IsRunning() { - return "", channels.ErrNotRunning - } - - msgID := uuid.New().String() - outMsg := newMessage(TypeMessageCreate, map[string]any{ - "content": content, - "message_id": msgID, - }) - - if err := c.broadcastToSession(chatID, outMsg); err != nil { - return "", err - } - - return msgID, nil -} - // EditMessage implements channels.MessageEditor. func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { outMsg := newMessage(TypeMessageUpdate, map[string]any{ diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 412760c98..3ee849621 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -122,7 +122,11 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error slack.MsgOptionText(msg.Content, false), } - if threadTS != "" { + if msg.ReplyToMessageID != "" && threadTS == "" { + // Answer to the message by creating a Thread under it + opts = append(opts, slack.MsgOptionTS(msg.ReplyToMessageID)) + } else if threadTS != "" { + // If we are already in a thread, continue in the thread opts = append(opts, slack.MsgOptionTS(threadTS)) } @@ -183,7 +187,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa title = filename } - _, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{ + _, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{ Channel: channelID, File: localPath, Filename: filename, @@ -303,17 +307,16 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { Timestamp: messageTS, }) - var contentBuf strings.Builder - contentBuf.WriteString(c.stripBotMention(ev.Text)) + content := ev.Text + content = c.stripBotMention(content) // In non-DM channels, apply group trigger filtering if !strings.HasPrefix(channelID, "D") { - respond, cleaned := c.ShouldRespondInGroup(false, contentBuf.String()) + respond, cleaned := c.ShouldRespondInGroup(false, content) if !respond { return } - contentBuf.Reset() - contentBuf.WriteString(cleaned) + content = cleaned } var mediaPaths []string @@ -341,11 +344,10 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { continue } mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name)) - fmt.Fprintf(&contentBuf, "\n[file: %s]", file.Name) + content += fmt.Sprintf("\n[file: %s]", file.Name) } } - content := contentBuf.String() if strings.TrimSpace(content) == "" { return } diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 09eecbbdf..2a3e66043 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -34,7 +34,6 @@ func DefaultConfig() *Config { Temperature: nil, // nil means use provider default MaxToolIterations: 50, SummarizeMessageThreshold: 20, - TaskReminderInterval: 5, SummarizeTokenPercent: 75, }, }, @@ -51,12 +50,10 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, }, Telegram: TelegramConfig{ - Enabled: false, - Token: "", - AllowFrom: FlexibleStringSlice{}, - Typing: TypingConfig{Enabled: true}, - SubagentThreadID: 0, - HeartbeatThreadID: 0, + Enabled: false, + Token: "", + AllowFrom: FlexibleStringSlice{}, + Typing: TypingConfig{Enabled: true}, Placeholder: PlaceholderConfig{ Enabled: true, Text: "Thinking... 💭", @@ -83,10 +80,11 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, }, QQ: QQConfig{ - Enabled: false, - AppID: "", - AppSecret: "", - AllowFrom: FlexibleStringSlice{}, + Enabled: false, + AppID: "", + AppSecret: "", + AllowFrom: FlexibleStringSlice{}, + MaxMessageLength: 2000, }, DingTalk: DingTalkConfig{ Enabled: false, @@ -196,8 +194,8 @@ func DefaultConfig() *Config { // OpenAI - https://platform.openai.com/api-keys { - ModelName: "gpt-5.2", - Model: "openai/gpt-5.2", + ModelName: "gpt-5.4", + Model: "openai/gpt-5.4", APIBase: "https://api.openai.com/v1", APIKey: "", }, @@ -258,8 +256,8 @@ func DefaultConfig() *Config { APIKey: "", }, { - ModelName: "openrouter-gpt-5.2", - Model: "openrouter/openai/gpt-5.2", + ModelName: "openrouter-gpt-5.4", + Model: "openrouter/openai/gpt-5.4", APIBase: "https://openrouter.ai/api/v1", APIKey: "", }, @@ -289,6 +287,12 @@ func DefaultConfig() *Config { }, // Volcengine (火山引擎) - https://console.volcengine.com/ark + { + ModelName: "ark-code-latest", + Model: "volcengine/ark-code-latest", + APIBase: "https://ark.cn-beijing.volces.com/api/v3", + APIKey: "", + }, { ModelName: "doubao-pro", Model: "volcengine/doubao-pro-32k", @@ -313,8 +317,8 @@ func DefaultConfig() *Config { // GitHub Copilot - https://github.com/settings/tokens { - ModelName: "copilot-gpt-5.2", - Model: "github-copilot/gpt-5.2", + ModelName: "copilot-gpt-5.4", + Model: "github-copilot/gpt-5.4", APIBase: "http://localhost:4321", AuthMethod: "oauth", }, @@ -349,6 +353,22 @@ func DefaultConfig() *Config { APIKey: "", }, + // Minimax - https://api.minimaxi.com/ + { + ModelName: "MiniMax-M2.5", + Model: "minimax/MiniMax-M2.5", + APIBase: "https://api.minimaxi.com/v1", + APIKey: "", + }, + + // LongCat - https://longcat.chat/platform + { + ModelName: "LongCat-Flash-Thinking", + Model: "longcat/LongCat-Flash-Thinking", + APIBase: "https://api.longcat.chat/openai", + APIKey: "", + }, + // VLLM (local) - http://localhost:8000 { ModelName: "local-model", @@ -378,6 +398,13 @@ func DefaultConfig() *Config { Brave: BraveConfig{ Enabled: false, APIKey: "", + APIKeys: nil, + MaxResults: 5, + }, + Tavily: TavilyConfig{ + Enabled: false, + APIKey: "", + APIKeys: nil, MaxResults: 5, }, DuckDuckGo: DuckDuckGoConfig{ @@ -387,6 +414,7 @@ func DefaultConfig() *Config { Perplexity: PerplexityConfig{ Enabled: false, APIKey: "", + APIKeys: nil, MaxResults: 5, }, SearXNG: SearXNGConfig{ @@ -439,6 +467,13 @@ func DefaultConfig() *Config { ToolConfig: ToolConfig{ Enabled: false, }, + Discovery: ToolDiscoveryConfig{ + Enabled: false, + TTL: 5, + MaxSearchResults: 5, + UseBM25: true, + UseRegex: false, + }, Servers: map[string]MCPServerConfig{}, }, AppendFile: ToolConfig{ @@ -463,7 +498,8 @@ func DefaultConfig() *Config { Enabled: true, }, ReadFile: ReadFileToolConfig{ - Enabled: true, + Enabled: true, + MaxReadFileSize: 64 * 1024, // 64KB }, Spawn: ToolConfig{ Enabled: true, @@ -489,5 +525,14 @@ func DefaultConfig() *Config { Enabled: false, MonitorUSB: true, }, + Voice: VoiceConfig{ + EchoTranscription: false, + }, + BuildInfo: BuildInfo{ + Version: Version, + GitCommit: GitCommit, + BuildTime: BuildTime, + GoVersion: GoVersion, + }, } } diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go index 92fcf0f62..e95c2f3ec 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config.go +++ b/pkg/migrate/sources/openclaw/openclaw_config.go @@ -733,16 +733,18 @@ type WebToolsConfig struct { } type BraveConfig struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - MaxResults int `json:"max_results"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + APIKeys []string `json:"api_keys"` + MaxResults int `json:"max_results"` } type TavilyConfig struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url"` - MaxResults int `json:"max_results"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + APIKeys []string `json:"api_keys"` + BaseURL string `json:"base_url"` + MaxResults int `json:"max_results"` } type DuckDuckGoConfig struct { @@ -751,9 +753,10 @@ type DuckDuckGoConfig struct { } type PerplexityConfig struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - MaxResults int `json:"max_results"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + APIKeys []string `json:"api_keys"` + MaxResults int `json:"max_results"` } type CronConfig struct { @@ -1082,6 +1085,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig { Brave: config.BraveConfig{ Enabled: c.Web.Brave.Enabled, APIKey: c.Web.Brave.APIKey, + APIKeys: c.Web.Brave.APIKeys, MaxResults: c.Web.Brave.MaxResults, }, Tavily: config.TavilyConfig{ diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go index 5e17e0853..8a1890212 100644 --- a/pkg/providers/antigravity_provider.go +++ b/pkg/providers/antigravity_provider.go @@ -297,7 +297,7 @@ func (p *AntigravityProvider) buildRequest( if t.Type != "function" { continue } - params := sanitizeSchemaForGemini(t.Function.ParametersMap()) + params := sanitizeSchemaForGemini(t.Function.Parameters) funcDecls = append(funcDecls, antigravityFuncDecl{ Name: t.Function.Name, Description: t.Function.Description, @@ -340,13 +340,17 @@ func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) { thoughtSignature = tc.Function.ThoughtSignature } - if len(args) == 0 && tc.Function != nil && len(tc.Function.Arguments) > 0 { - args = cloneToolArgs(tc.Function.Arguments) - } if args == nil { args = map[string]any{} } + if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" { + var parsed map[string]any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil { + args = parsed + } + } + return name, args, thoughtSignature } @@ -432,13 +436,14 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error contentParts = append(contentParts, part.Text) } if part.FunctionCall != nil { + argumentsJSON, _ := json.Marshal(part.FunctionCall.Args) toolCalls = append(toolCalls, ToolCall{ ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()), Name: part.FunctionCall.Name, Arguments: part.FunctionCall.Args, Function: &FunctionCall{ Name: part.FunctionCall.Name, - Arguments: cloneToolArgs(part.FunctionCall.Args), + Arguments: string(argumentsJSON), ThoughtSignature: extractPartThoughtSignature( part.ThoughtSignature, part.ThoughtSignatureSnake, diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index dcfec73b9..6c4f6a767 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -100,45 +100,12 @@ func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDe } if len(tools) > 0 { - parts = append(parts, p.buildToolsPrompt(tools)) + parts = append(parts, buildCLIToolsPrompt(tools)) } return strings.Join(parts, "\n\n") } -// buildToolsPrompt creates the tool definitions section for the system prompt. -func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string { - var sb strings.Builder - - sb.WriteString("## Available Tools\n\n") - sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") - sb.WriteString("```json\n") - sb.WriteString( - `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`, - ) - sb.WriteString("\n```\n\n") - sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") - sb.WriteString("### Tool Definitions:\n\n") - - for _, tool := range tools { - if tool.Type != "function" { - continue - } - sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) - if tool.Function.Description != "" { - sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) - } - if len(tool.Function.Parameters) > 0 { - sb.WriteString("Parameters:\n```json\n") - sb.Write(tool.Function.Parameters) - sb.WriteString("\n```\n") - } - sb.WriteString("\n") - } - - return sb.String() -} - // parseClaudeCliResponse parses the JSON output from the claude CLI. func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) { var resp claudeCliJSONResponse diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index c9b7e9fa2..13f53ad9e 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -115,7 +115,7 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio } if len(tools) > 0 { - sb.WriteString(p.buildToolsPrompt(tools)) + sb.WriteString(buildCLIToolsPrompt(tools)) sb.WriteString("\n\n") } @@ -128,39 +128,6 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio return sb.String() } -// buildToolsPrompt creates a tool definitions section for the prompt. -func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string { - var sb strings.Builder - - sb.WriteString("## Available Tools\n\n") - sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") - sb.WriteString("```json\n") - sb.WriteString( - `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`, - ) - sb.WriteString("\n```\n\n") - sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") - sb.WriteString("### Tool Definitions:\n\n") - - for _, tool := range tools { - if tool.Type != "function" { - continue - } - sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) - if tool.Function.Description != "" { - sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) - } - if len(tool.Function.Parameters) > 0 { - sb.WriteString("Parameters:\n```json\n") - sb.Write(tool.Function.Parameters) - sb.WriteString("\n```\n") - } - sb.WriteString("\n") - } - - return sb.String() -} - // codexEvent represents a single JSONL event from `codex exec --json`. type codexEvent struct { Type string `json:"type"` diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index c8df94b8d..cf5c2d876 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -16,7 +16,7 @@ import ( ) const ( - codexDefaultModel = "gpt-5.2" + codexDefaultModel = "gpt-5.3-codex" codexDefaultInstructions = "You are Codex, a coding assistant." ) @@ -317,19 +317,19 @@ func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) return "", "", false } - args := tc.Arguments - if len(args) == 0 && tc.Function != nil { - args = tc.Function.Arguments - } - if len(args) == 0 { - return name, "{}", true + if len(tc.Arguments) > 0 { + argsJSON, err := json.Marshal(tc.Arguments) + if err != nil { + return "", "", false + } + return name, string(argsJSON), true } - argsJSON, err := json.Marshal(args) - if err != nil { - return "", "", false + if tc.Function != nil && tc.Function.Arguments != "" { + return name, tc.Function.Arguments, true } - return name, string(argsJSON), true + + return name, "{}", true } func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam { @@ -345,13 +345,9 @@ func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []resp if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") { continue } - params := t.Function.ParametersMap() - if params == nil { - params = map[string]any{} - } ft := responses.FunctionToolParam{ Name: t.Function.Name, - Parameters: params, + Parameters: t.Function.Parameters, Strict: openai.Opt(false), } if t.Function.Description != "" { @@ -386,10 +382,6 @@ func parseCodexResponse(resp *responses.Response) *LLMResponse { ID: item.CallID, Name: item.Name, Arguments: args, - Function: &FunctionCall{ - Name: item.Name, - Arguments: cloneToolArgs(args), - }, }) } } diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index aaceb891a..d2afe2943 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -36,13 +36,14 @@ type providerSelection struct { } func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { - return resolveProviderSelectionByName(cfg, strings.ToLower(cfg.Agents.Defaults.Provider)) -} - -func resolveProviderSelectionByName(cfg *config.Config, providerName string) (providerSelection, error) { model := cfg.Agents.Defaults.GetModelName() + providerName := strings.ToLower(cfg.Agents.Defaults.Provider) lowerModel := strings.ToLower(model) + if providerName == "" && model == "" { + return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty") + } + sel := providerSelection{ providerType: providerTypeHTTPCompat, model: model, @@ -211,6 +212,24 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr sel.apiBase = "https://api.mistral.ai/v1" } } + case "minimax": + if cfg.Providers.Minimax.APIKey != "" { + sel.apiKey = cfg.Providers.Minimax.APIKey + sel.apiBase = cfg.Providers.Minimax.APIBase + sel.proxy = cfg.Providers.Minimax.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.minimaxi.com/v1" + } + } + case "longcat": + if cfg.Providers.LongCat.APIKey != "" { + sel.apiKey = cfg.Providers.LongCat.APIKey + sel.apiBase = cfg.Providers.LongCat.APIBase + sel.proxy = cfg.Providers.LongCat.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.longcat.chat/openai" + } + } case "github_copilot", "copilot": sel.providerType = providerTypeGitHubCopilot if cfg.Providers.GitHubCopilot.APIBase != "" { @@ -328,6 +347,13 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr if sel.apiBase == "" { sel.apiBase = "https://api.mistral.ai/v1" } + case (strings.Contains(lowerModel, "minimax") || strings.HasPrefix(model, "minimax/")) && cfg.Providers.Minimax.APIKey != "": + sel.apiKey = cfg.Providers.Minimax.APIKey + sel.apiBase = cfg.Providers.Minimax.APIBase + sel.proxy = cfg.Providers.Minimax.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.minimaxi.com/v1" + } case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "": sel.apiKey = cfg.Providers.Avian.APIKey sel.apiBase = cfg.Providers.Avian.APIBase @@ -335,6 +361,13 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr if sel.apiBase == "" { sel.apiBase = "https://api.avian.io/v1" } + case (strings.Contains(lowerModel, "longcat") || strings.HasPrefix(model, "longcat/")) && cfg.Providers.LongCat.APIKey != "": + sel.apiKey = cfg.Providers.LongCat.APIKey + sel.apiBase = cfg.Providers.LongCat.APIBase + sel.proxy = cfg.Providers.LongCat.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.longcat.chat/openai" + } case cfg.Providers.VLLM.APIBase != "": sel.apiKey = cfg.Providers.VLLM.APIKey sel.apiBase = cfg.Providers.VLLM.APIBase @@ -365,31 +398,3 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr return sel, nil } - -// CreateProviderByName creates a provider for the given explicit provider name. -// Used by the fallback chain to resolve cross-provider candidates. -func CreateProviderByName(cfg *config.Config, providerName string) (LLMProvider, error) { - sel, err := resolveProviderSelectionByName(cfg, strings.ToLower(providerName)) - if err != nil { - return nil, err - } - - switch sel.providerType { - case providerTypeClaudeAuth: - return createClaudeAuthProvider() - case providerTypeCodexAuth: - return createCodexAuthProvider() - case providerTypeCodexCLIToken: - c := NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource()) - c.enableWebSearch = sel.enableWebSearch - return c, nil - case providerTypeClaudeCLI: - return NewClaudeCliProvider(sel.workspace), nil - case providerTypeCodexCLI: - return NewCodexCliProvider(sel.workspace), nil - case providerTypeGitHubCopilot: - return NewGitHubCopilotProvider(sel.apiBase, sel.connectMode, sel.model) - default: - return NewHTTPProvider(sel.apiKey, sel.apiBase, sel.proxy), nil - } -} diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index da87ba94f..9749e7a15 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -8,10 +8,8 @@ package providers import ( "fmt" "strings" - "time" "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/providers/openai_compat" ) // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. @@ -86,33 +84,18 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } - return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, - openai_compat.WithMaxTokensField(cfg.MaxTokensField), - openai_compat.WithStream(boolDefault(cfg.Stream, false)), - openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second), - openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)), - ), modelID, nil - - case "minimax": - // MiniMax uses a non-standard endpoint path and defaults to SSE streaming. - if cfg.APIKey == "" && cfg.APIBase == "" { - return nil, "", fmt.Errorf("api_key or api_base is required for minimax protocol") - } - apiBase := cfg.APIBase - if apiBase == "" { - apiBase = getDefaultAPIBase(protocol) - } - return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, - openai_compat.WithEndpointPath("/text/chatcompletion_v2"), - openai_compat.WithMaxTokensField(cfg.MaxTokensField), - openai_compat.WithStream(boolDefault(cfg.Stream, true)), - openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second), - openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)), + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey, + apiBase, + cfg.Proxy, + cfg.MaxTokensField, + cfg.RequestTimeout, ), modelID, nil case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian": + "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian", + "minimax", "longcat": // All other OpenAI-compatible HTTP providers if cfg.APIKey == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) @@ -121,11 +104,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } - return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, - openai_compat.WithMaxTokensField(cfg.MaxTokensField), - openai_compat.WithStream(boolDefault(cfg.Stream, false)), - openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second), - openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)), + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey, + apiBase, + cfg.Proxy, + cfg.MaxTokensField, + cfg.RequestTimeout, ), modelID, nil case "anthropic": @@ -145,10 +129,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if cfg.APIKey == "" { return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) } - return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, - openai_compat.WithMaxTokensField(cfg.MaxTokensField), - openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second), - openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)), + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey, + apiBase, + cfg.Proxy, + cfg.MaxTokensField, + cfg.RequestTimeout, ), modelID, nil case "antigravity": @@ -223,30 +209,15 @@ func getDefaultAPIBase(protocol string) string { return "https://dashscope.aliyuncs.com/compatible-mode/v1" case "vllm": return "http://localhost:8000/v1" - case "minimax": - return "https://api.minimax.io/v1" case "mistral": return "https://api.mistral.ai/v1" case "avian": return "https://api.avian.io/v1" + case "minimax": + return "https://api.minimaxi.com/v1" + case "longcat": + return "https://api.longcat.chat/openai" default: return "" } } - -// rpmToMinInterval converts a requests-per-minute limit to a minimum interval -// between consecutive requests. Returns 0 (no throttle) when rpm <= 0. -func rpmToMinInterval(rpm int) time.Duration { - if rpm <= 0 { - return 0 - } - return time.Minute / time.Duration(rpm) -} - -// boolDefault dereferences a *bool, returning def when nil. -func boolDefault(p *bool, def bool) bool { - if p != nil { - return *p - } - return def -} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 49f45938b..5c328f418 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -42,12 +42,6 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( } } -func NewHTTPProviderWithOptions(apiKey, apiBase, proxy string, opts ...openai_compat.Option) *HTTPProvider { - return &HTTPProvider{ - delegate: openai_compat.NewProvider(apiKey, apiBase, proxy, opts...), - } -} - func (p *HTTPProvider) Chat( ctx context.Context, messages []Message, @@ -55,38 +49,9 @@ func (p *HTTPProvider) Chat( model string, options map[string]any, ) (*LLMResponse, error) { - resp, err := p.delegate.Chat(ctx, messages, tools, model, options) - if err != nil { - return nil, err - } - // If provider returned no structured tool_calls but Content has XML - // tool call blocks (e.g. ), parse them as a fallback. - if len(resp.ToolCalls) == 0 { - if xmlCalls := extractXMLToolCalls(resp.Content); len(xmlCalls) > 0 { - resp.ToolCalls = xmlCalls - } - } - // Strip XML tool call artifacts from Content regardless. - resp.Content = stripXMLToolCalls(resp.Content) - return resp, nil + return p.delegate.Chat(ctx, messages, tools, model, options) } func (p *HTTPProvider) GetDefaultModel() string { return "" } - -// CanStream returns true when the underlying provider uses SSE streaming. -func (p *HTTPProvider) CanStream() bool { - return p.delegate.CanStream() -} - -// ChatStream opens an SSE stream and returns a channel of StreamEvent. -func (p *HTTPProvider) ChatStream( - ctx context.Context, - messages []Message, - tools []ToolDefinition, - model string, - options map[string]any, -) (<-chan StreamEvent, error) { - return p.delegate.ChatStream(ctx, messages, tools, model, options) -} diff --git a/pkg/providers/toolcall_utils.go b/pkg/providers/toolcall_utils.go index 8085f815b..a33e1eb5c 100644 --- a/pkg/providers/toolcall_utils.go +++ b/pkg/providers/toolcall_utils.go @@ -5,32 +5,74 @@ package providers +import ( + "encoding/json" + "fmt" + "strings" +) + +// buildCLIToolsPrompt creates the tool definitions section for a CLI provider system prompt. +func buildCLIToolsPrompt(tools []ToolDefinition) string { + var sb strings.Builder + + sb.WriteString("## Available Tools\n\n") + sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") + sb.WriteString("```json\n") + sb.WriteString( + `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`, + ) + sb.WriteString("\n```\n\n") + sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") + sb.WriteString("### Tool Definitions:\n\n") + + for _, tool := range tools { + if tool.Type != "function" { + continue + } + sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) + if tool.Function.Description != "" { + sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) + } + if len(tool.Function.Parameters) > 0 { + paramsJSON, _ := json.Marshal(tool.Function.Parameters) + sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON))) + } + sb.WriteString("\n") + } + + return sb.String() +} + // NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated. // It handles cases where Name/Arguments might be in different locations (top-level vs Function) // and ensures both are populated consistently. func NormalizeToolCall(tc ToolCall) ToolCall { normalized := tc - // Ensure Name is populated from Function if not set. + // Ensure Name is populated from Function if not set if normalized.Name == "" && normalized.Function != nil { normalized.Name = normalized.Function.Name } - // Ensure Arguments is not nil. + // Ensure Arguments is not nil if normalized.Arguments == nil { normalized.Arguments = map[string]any{} } - // Populate top-level arguments from Function arguments when needed. - if len(normalized.Arguments) == 0 && normalized.Function != nil && len(normalized.Function.Arguments) > 0 { - normalized.Arguments = cloneToolArgs(normalized.Function.Arguments) + // Parse Arguments from Function.Arguments if not already set + if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" { + var parsed map[string]any + if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil { + normalized.Arguments = parsed + } } - // Ensure Function is populated with consistent values. + // Ensure Function is populated with consistent values + argsJSON, _ := json.Marshal(normalized.Arguments) if normalized.Function == nil { normalized.Function = &FunctionCall{ Name: normalized.Name, - Arguments: cloneToolArgs(normalized.Arguments), + Arguments: string(argsJSON), } } else { if normalized.Function.Name == "" { @@ -39,21 +81,10 @@ func NormalizeToolCall(tc ToolCall) ToolCall { if normalized.Name == "" { normalized.Name = normalized.Function.Name } - if len(normalized.Function.Arguments) == 0 { - normalized.Function.Arguments = cloneToolArgs(normalized.Arguments) + if normalized.Function.Arguments == "" { + normalized.Function.Arguments = string(argsJSON) } } return normalized } - -func cloneToolArgs(src map[string]any) map[string]any { - if len(src) == 0 { - return map[string]any{} - } - dst := make(map[string]any, len(src)) - for k, v := range src { - dst[k] = v - } - return dst -} diff --git a/pkg/providers/types.go b/pkg/providers/types.go index aad2dccc2..1b7af8514 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -2,9 +2,10 @@ package providers import ( "context" - "encoding/json" "fmt" + "encoding/json" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -18,8 +19,6 @@ type ( ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ExtraContent = protocoltypes.ExtraContent GoogleExtra = protocoltypes.GoogleExtra - StreamEvent = protocoltypes.StreamEvent - StreamToolCallDelta = protocoltypes.StreamToolCallDelta ContentBlock = protocoltypes.ContentBlock CacheControl = protocoltypes.CacheControl ) @@ -84,6 +83,12 @@ func (e *FailoverError) IsRetriable() bool { return e.Reason != FailoverFormat } +// ModelConfig holds primary model and fallback list. +type ModelConfig struct { + Primary string + Fallbacks []string +} + // StreamingProvider extends LLMProvider with SSE channel-based streaming. // Use a type assertion to check if a provider supports streaming: // @@ -91,21 +96,23 @@ func (e *FailoverError) IsRetriable() bool { type StreamingProvider interface { LLMProvider CanStream() bool - ChatStream( - ctx context.Context, - messages []Message, - tools []ToolDefinition, - model string, - options map[string]any, - ) (<-chan StreamEvent, error) + ChatStream(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any) (<-chan StreamEvent, error) } -// ModelConfig holds primary model and fallback list. -type ModelConfig struct { - Primary string - Fallbacks []string +// FallbackCandidate represents a model that can be tried if the primary model fails. +type FallbackCandidate struct { + ModelName string + Model string + Protocol string + Provider LLMProvider + Options map[string]any } -func MustMarshalParameters(params map[string]any) json.RawMessage { - return protocoltypes.MustMarshalParameters(params) +// UnmarshalArguments is a helper to parse FunctionCall.Arguments from json.RawMessage. +func UnmarshalArguments(raw json.RawMessage) (map[string]any, error) { + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + return m, nil } diff --git a/pkg/state/state.go b/pkg/state/state.go index 60a204ab9..34589dfae 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -99,36 +99,6 @@ func (sm *Manager) SetLastChannel(channel string) error { return nil } -// SetLastHeartbeatTarget atomically updates the last heartbeat target and saves the state. -func (sm *Manager) SetLastHeartbeatTarget(target string) error { - sm.mu.Lock() - defer sm.mu.Unlock() - - sm.state.LastHeartbeatTarget = target - sm.state.Timestamp = time.Now() - - if err := sm.saveAtomic(); err != nil { - return fmt.Errorf("failed to save state atomically: %w", err) - } - - return nil -} - -// SetHeartbeatTarget atomically updates the explicit heartbeat target and saves the state. -func (sm *Manager) SetHeartbeatTarget(target string) error { - sm.mu.Lock() - defer sm.mu.Unlock() - - sm.state.HeartbeatTarget = target - sm.state.Timestamp = time.Now() - - if err := sm.saveAtomic(); err != nil { - return fmt.Errorf("failed to save state atomically: %w", err) - } - - return nil -} - // SetLastChatID atomically updates the last chat ID and saves the state. func (sm *Manager) SetLastChatID(chatID string) error { sm.mu.Lock() @@ -153,20 +123,6 @@ func (sm *Manager) GetLastChannel() string { return sm.state.LastChannel } -// GetLastHeartbeatTarget returns the last heartbeat target from the state. -func (sm *Manager) GetLastHeartbeatTarget() string { - sm.mu.RLock() - defer sm.mu.RUnlock() - return sm.state.LastHeartbeatTarget -} - -// GetHeartbeatTarget returns the explicit heartbeat target from the state. -func (sm *Manager) GetHeartbeatTarget() string { - sm.mu.RLock() - defer sm.mu.RUnlock() - return sm.state.HeartbeatTarget -} - // GetLastChatID returns the last chat ID from the state. func (sm *Manager) GetLastChatID() string { sm.mu.RLock() @@ -217,3 +173,47 @@ func (sm *Manager) load() error { return nil } + +// SetLastHeartbeatTarget atomically updates the last heartbeat target and saves the state. +func (sm *Manager) SetLastHeartbeatTarget(target string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + sm.state.LastHeartbeatTarget = target + sm.state.Timestamp = time.Now() + + if err := sm.saveAtomic(); err != nil { + return fmt.Errorf("failed to save state atomically: %w", err) + } + + return nil +} + +// SetHeartbeatTarget atomically updates the explicit heartbeat target and saves the state. +func (sm *Manager) SetHeartbeatTarget(target string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + sm.state.HeartbeatTarget = target + sm.state.Timestamp = time.Now() + + if err := sm.saveAtomic(); err != nil { + return fmt.Errorf("failed to save state atomically: %w", err) + } + + return nil +} + +// GetLastHeartbeatTarget returns the last heartbeat target from the state. +func (sm *Manager) GetLastHeartbeatTarget() string { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.state.LastHeartbeatTarget +} + +// GetHeartbeatTarget returns the explicit heartbeat target from the state. +func (sm *Manager) GetHeartbeatTarget() string { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.state.HeartbeatTarget +} diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go index 20d561b52..d5bebf4a2 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/edit.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io/fs" + "regexp" "strings" ) @@ -15,17 +16,12 @@ type EditFileTool struct { } // NewEditFileTool creates a new EditFileTool with optional directory restriction. - -func NewEditFileTool(workspace string, restrict bool) *EditFileTool { - var fs fileSystem - - if restrict { - fs = &sandboxFs{workspace: workspace} - } else { - fs = &hostFs{} +func NewEditFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *EditFileTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] } - - return &EditFileTool{fs: fs} + return &EditFileTool{fs: buildFs(workspace, restrict, patterns)} } func (t *EditFileTool) Name() string { @@ -41,18 +37,15 @@ func (t *EditFileTool) Parameters() map[string]any { "type": "object", "properties": map[string]any{ "path": map[string]any{ - "type": "string", - + "type": "string", "description": "The file path to edit", }, "old_text": map[string]any{ - "type": "string", - + "type": "string", "description": "The exact text to find and replace", }, "new_text": map[string]any{ - "type": "string", - + "type": "string", "description": "The text to replace with", }, }, @@ -76,7 +69,7 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult("new_text is required") } - if err := editFile(resolveFS(ctx, t.fs, path), path, oldText, newText); err != nil { + if err := editFile(t.fs, path, oldText, newText); err != nil { return ErrorResult(err.Error()) } return SilentResult(fmt.Sprintf("File edited: %s", path)) @@ -86,16 +79,12 @@ type AppendFileTool struct { fs fileSystem } -func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool { - var fs fileSystem - - if restrict { - fs = &sandboxFs{workspace: workspace} - } else { - fs = &hostFs{} +func NewAppendFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *AppendFileTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] } - - return &AppendFileTool{fs: fs} + return &AppendFileTool{fs: buildFs(workspace, restrict, patterns)} } func (t *AppendFileTool) Name() string { @@ -111,13 +100,11 @@ func (t *AppendFileTool) Parameters() map[string]any { "type": "object", "properties": map[string]any{ "path": map[string]any{ - "type": "string", - + "type": "string", "description": "The file path to append to", }, "content": map[string]any{ - "type": "string", - + "type": "string", "description": "The content to append", }, }, @@ -136,7 +123,7 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool return ErrorResult("content is required") } - if err := appendFile(resolveFS(ctx, t.fs, path), path, content); err != nil { + if err := appendFile(t.fs, path, content); err != nil { return ErrorResult(err.Error()) } return SilentResult(fmt.Sprintf("Appended to %s", path)) diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go index 436285247..779b1d5a7 100644 --- a/pkg/tools/i2c.go +++ b/pkg/tools/i2c.go @@ -29,42 +29,33 @@ func (t *I2CTool) Parameters() map[string]any { "type": "object", "properties": map[string]any{ "action": map[string]any{ - "type": "string", - - "enum": []string{"detect", "scan", "read", "write"}, - + "type": "string", + "enum": []string{"detect", "scan", "read", "write"}, "description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)", }, "bus": map[string]any{ - "type": "string", - + "type": "string", "description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.", }, "address": map[string]any{ - "type": "integer", - + "type": "integer", "description": "7-bit I2C device address (0x03-0x77). Required for read/write.", }, "register": map[string]any{ - "type": "integer", - + "type": "integer", "description": "Register address to read from or write to. If set, sends register byte before read/write.", }, "data": map[string]any{ - "type": "array", - - "items": map[string]any{"type": "integer"}, - + "type": "array", + "items": map[string]any{"type": "integer"}, "description": "Bytes to write (0-255 each). Required for write action.", }, "length": map[string]any{ - "type": "integer", - + "type": "integer", "description": "Number of bytes to read (1-256). Default: 1. Used with read action.", }, "confirm": map[string]any{ - "type": "boolean", - + "type": "boolean", "description": "Must be true for write operations. Safety guard to prevent accidental writes.", }, }, @@ -111,8 +102,7 @@ func (t *I2CTool) detect() *ToolResult { type busInfo struct { Path string `json:"path"` - - Bus string `json:"bus"` + Bus string `json:"bus"` } buses := make([]busInfo, 0, len(matches)) diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go index 483c5a15f..4eaaf8f09 100644 --- a/pkg/tools/i2c_linux.go +++ b/pkg/tools/i2c_linux.go @@ -97,12 +97,10 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { hasQuick := funcs&i2cFuncSmbusQuick != 0 hasReadByte := funcs&i2cFuncSmbusReadByte != 0 + if !hasQuick && !hasReadByte { return ErrorResult( - fmt.Sprintf( - "I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", - devPath, - ), + fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath), ) } @@ -125,6 +123,7 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { } continue } + if smbusProbe(fd, addr, hasQuick) { found = append(found, deviceEntry{ Address: fmt.Sprintf("0x%02x", addr), @@ -144,7 +143,7 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result))) } -// readDevice reads bytes from an I2C device, optionally at a specific register. +// readDevice reads bytes from an I2C device, optionally at a specific register func (t *I2CTool) readDevice(args map[string]any) *ToolResult { bus, errResult := parseI2CBus(args) if errResult != nil { @@ -214,14 +213,12 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult { return SilentResult(string(result)) } -// writeDevice writes bytes to an I2C device, optionally at a specific register. +// writeDevice writes bytes to an I2C device, optionally at a specific register func (t *I2CTool) writeDevice(args map[string]any) *ToolResult { confirm, _ := args["confirm"].(bool) if !confirm { return ErrorResult( - "write operations require confirm: true." + - " Please confirm with the user before writing to I2C devices," + - " as incorrect writes can misconfigure hardware.", + "write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.", ) } diff --git a/pkg/tools/message.go b/pkg/tools/message.go index b86bed0e0..438ceeddd 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -3,18 +3,14 @@ package tools import ( "context" "fmt" + "sync/atomic" ) type SendCallback func(channel, chatID, content string) error type MessageTool struct { sendCallback SendCallback - - defaultChannel string - - defaultChatID string - - sentInRound bool // Tracks whether a message was sent in the current processing round + sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round } func NewMessageTool() *MessageTool { @@ -34,18 +30,15 @@ func (t *MessageTool) Parameters() map[string]any { "type": "object", "properties": map[string]any{ "content": map[string]any{ - "type": "string", - + "type": "string", "description": "The message content to send", }, "channel": map[string]any{ - "type": "string", - + "type": "string", "description": "Optional: target channel (telegram, whatsapp, etc.)", }, "chat_id": map[string]any{ - "type": "string", - + "type": "string", "description": "Optional: target chat/user ID", }, }, @@ -53,17 +46,15 @@ func (t *MessageTool) Parameters() map[string]any { } } -func (t *MessageTool) SetContext(channel, chatID string) { - t.defaultChannel = channel - - t.defaultChatID = chatID - - t.sentInRound = false // Reset send tracking for new processing round +// ResetSentInRound resets the per-round send tracker. +// Called by the agent loop at the start of each inbound message processing round. +func (t *MessageTool) ResetSentInRound() { + t.sentInRound.Store(false) } // HasSentInRound returns true if the message tool sent a message during the current round. func (t *MessageTool) HasSentInRound() bool { - return t.sentInRound + return t.sentInRound.Load() } func (t *MessageTool) SetSendCallback(callback SendCallback) { @@ -80,10 +71,10 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes chatID, _ := args["chat_id"].(string) if channel == "" { - channel = t.defaultChannel + channel = ToolChannel(ctx) } if chatID == "" { - chatID = t.defaultChatID + chatID = ToolChatID(ctx) } if channel == "" || chatID == "" { @@ -96,16 +87,13 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes if err := t.sendCallback(channel, chatID, content); err != nil { return &ToolResult{ - ForLLM: fmt.Sprintf("sending message: %v", err), - + ForLLM: fmt.Sprintf("sending message: %v", err), IsError: true, - - Err: err, + Err: err, } } - t.sentInRound = true - + t.sentInRound.Store(true) // Silent: user already received the message directly return &ToolResult{ ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index 5df7b94e1..0635f47d7 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -2,10 +2,8 @@ package tools import ( "context" - "encoding/json" "fmt" "sort" - "strings" "sync" "sync/atomic" "time" @@ -14,20 +12,6 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) -// NormalizeToolName keeps only lowercase ASCII letters. -// "read_file" → "readfile", "ReadFile" → "readfile", "read-file" → "readfile". -func NormalizeToolName(s string) string { - var b strings.Builder - for _, r := range s { - if r >= 'A' && r <= 'Z' { - b.WriteRune(r + 32) - } else if r >= 'a' && r <= 'z' { - b.WriteRune(r) - } - } - return b.String() -} - type ToolEntry struct { Tool Tool IsCore bool @@ -155,28 +139,15 @@ func (r *ToolRegistry) SnapshotHiddenTools() HiddenToolSnapshot { func (r *ToolRegistry) Get(name string) (Tool, bool) { r.mu.RLock() defer r.mu.RUnlock() - - // Exact match first - if entry, ok := r.tools[name]; ok { - // Hidden tools with expired TTL are not callable. - if !entry.IsCore && entry.TTL <= 0 { - return nil, false - } - return entry.Tool, true + entry, ok := r.tools[name] + if !ok { + return nil, false } - - // Fork extension: fuzzy fallback — normalize and compare - // (handles "readfile" → "read_file" etc.) - norm := NormalizeToolName(name) - for _, entry := range r.tools { - if entry.IsCore || entry.TTL > 0 { - if NormalizeToolName(entry.Tool.Name()) == norm { - return entry.Tool, true - } - } + // Hidden tools with expired TTL are not callable. + if !entry.IsCore && entry.TTL <= 0 { + return nil, false } - - return nil, false + return entry.Tool, true } func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult { @@ -213,11 +184,6 @@ func (r *ToolRegistry) ExecuteWithContext( // Always inject — tools validate what they require. ctx = WithToolContext(ctx, channel, chatID) - // Legacy ContextualTool support (fork-only, prefer ctx-based injection above) - if contextualTool, ok := tool.(ContextualTool); ok && channel != "" && chatID != "" { - contextualTool.SetContext(channel, chatID) - } - // If tool implements AsyncExecutor and callback is provided, use ExecuteAsync. // The callback is a call parameter, not mutable state on the tool instance. var result *ToolResult @@ -228,14 +194,6 @@ func (r *ToolRegistry) ExecuteWithContext( "tool": name, }) result = asyncExec.ExecuteAsync(ctx, args, asyncCallback) - } else if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil { - // Legacy AsyncTool support (fork-only, prefer AsyncExecutor above) - asyncTool.SetCallback(asyncCallback) - logger.DebugCF("tool", "Async callback injected (legacy)", - map[string]any{ - "tool": name, - }) - result = tool.Execute(ctx, args) } else { result = tool.Execute(ctx, args) } @@ -293,7 +251,7 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any { continue } - definitions = append(definitions, ToolToSchema(entry.Tool)) + definitions = append(definitions, ToolToSchema(r.tools[name].Tool)) } return definitions } @@ -325,19 +283,12 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { desc, _ := fn["description"].(string) params, _ := fn["parameters"].(map[string]any) - paramsRaw := json.RawMessage(`{}`) - if len(params) > 0 { - if payload, err := json.Marshal(params); err == nil { - paramsRaw = payload - } - } - definitions = append(definitions, providers.ToolDefinition{ Type: "function", Function: providers.ToolFunctionDefinition{ Name: name, Description: desc, - Parameters: paramsRaw, + Parameters: params, }, }) } @@ -374,8 +325,7 @@ func (r *ToolRegistry) GetSummaries() []string { continue } - hint := buildParamHint(entry.Tool.Parameters()) - summaries = append(summaries, fmt.Sprintf("- `%s`%s - %s", entry.Tool.Name(), hint, entry.Tool.Description())) + summaries = append(summaries, fmt.Sprintf("- `%s` - %s", entry.Tool.Name(), entry.Tool.Description())) } return summaries } diff --git a/pkg/tools/shell_process_unix.go b/pkg/tools/shell_process_unix.go index d9dc5b92e..7b29a81bf 100644 --- a/pkg/tools/shell_process_unix.go +++ b/pkg/tools/shell_process_unix.go @@ -3,10 +3,7 @@ package tools import ( - "os" "os/exec" - "strconv" - "strings" "syscall" ) @@ -21,6 +18,7 @@ func terminateProcessTree(cmd *exec.Cmd) error { if cmd == nil || cmd.Process == nil { return nil } + pid := cmd.Process.Pid if pid <= 0 { return nil @@ -28,58 +26,7 @@ func terminateProcessTree(cmd *exec.Cmd) error { // Kill the entire process group spawned by the shell command. _ = syscall.Kill(-pid, syscall.SIGKILL) - - // Some shells/background jobs may still leave descendants around - // briefly; aggressively walk /proc and kill child processes too. - killDescendants(pid) - // Fallback kill on the shell process itself. _ = cmd.Process.Kill() return nil } - -func killDescendants(ppid int) { - if ppid <= 0 { - return - } - entries, err := os.ReadDir("/proc") - if err != nil { - return - } - - for _, e := range entries { - if !e.IsDir() { - continue - } - childPID, err := strconv.Atoi(e.Name()) - if err != nil || childPID <= 0 || childPID == ppid { - continue - } - - statPath := "/proc/" + e.Name() + "/stat" - data, err := os.ReadFile(statPath) - if err != nil { - continue - } - - // /proc//stat: pid (comm) state ppid ... - raw := string(data) - end := strings.LastIndex(raw, ")") - if end == -1 || end+2 >= len(raw) { - continue - } - fields := strings.Fields(raw[end+2:]) - if len(fields) < 2 { - continue - } - parent, err := strconv.Atoi(fields[1]) - if err != nil || parent != ppid { - continue - } - - // Recurse first, then kill child process/group. - killDescendants(childPID) - _ = syscall.Kill(-childPID, syscall.SIGKILL) - _ = syscall.Kill(childPID, syscall.SIGKILL) - } -} diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index f4bd62777..71bfe730b 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -20,10 +20,8 @@ import ( // so all registries configured in config are available for installation. type InstallSkillTool struct { registryMgr *skills.RegistryManager - - workspace string - - mu sync.Mutex + workspace string + mu sync.Mutex } // NewInstallSkillTool creates a new InstallSkillTool. @@ -32,10 +30,8 @@ type InstallSkillTool struct { func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool { return &InstallSkillTool{ registryMgr: registryMgr, - - workspace: workspace, - - mu: sync.Mutex{}, + workspace: workspace, + mu: sync.Mutex{}, } } @@ -52,23 +48,19 @@ func (t *InstallSkillTool) Parameters() map[string]any { "type": "object", "properties": map[string]any{ "slug": map[string]any{ - "type": "string", - + "type": "string", "description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')", }, "version": map[string]any{ - "type": "string", - + "type": "string", "description": "Specific version to install (optional, defaults to latest)", }, "registry": map[string]any{ - "type": "string", - + "type": "string", "description": "Registry to install from (required, e.g., 'clawhub')", }, "force": map[string]any{ - "type": "boolean", - + "type": "boolean", "description": "Force reinstall if skill already exists (default false)", }, }, @@ -131,11 +123,9 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To if rmErr != nil { logger.ErrorCF("tool", "Failed to remove partial install", map[string]any{ - "tool": "install_skill", - + "tool": "install_skill", "target_dir": targetDir, - - "error": rmErr.Error(), + "error": rmErr.Error(), }) } return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err)) @@ -147,11 +137,9 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To if rmErr != nil { logger.ErrorCF("tool", "Failed to remove partial install", map[string]any{ - "tool": "install_skill", - + "tool": "install_skill", "target_dir": targetDir, - - "error": rmErr.Error(), + "error": rmErr.Error(), }) } return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug)) @@ -161,17 +149,12 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil { logger.ErrorCF("tool", "Failed to write origin metadata", map[string]any{ - "tool": "install_skill", - - "error": err.Error(), - - "target": targetDir, - + "tool": "install_skill", + "error": err.Error(), + "target": targetDir, "registry": registry.Name(), - - "slug": slug, - - "version": result.Version, + "slug": slug, + "version": result.Version, }) _ = err } @@ -194,28 +177,20 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To // originMeta tracks which registry a skill was installed from. type originMeta struct { - Version int `json:"version"` - - Registry string `json:"registry"` - - Slug string `json:"slug"` - + Version int `json:"version"` + Registry string `json:"registry"` + Slug string `json:"slug"` InstalledVersion string `json:"installed_version"` - - InstalledAt int64 `json:"installed_at"` + InstalledAt int64 `json:"installed_at"` } func writeOriginMeta(targetDir, registryName, slug, version string) error { meta := originMeta{ - Version: 1, - - Registry: registryName, - - Slug: slug, - + Version: 1, + Registry: registryName, + Slug: slug, InstalledVersion: version, - - InstalledAt: time.Now().UnixMilli(), + InstalledAt: time.Now().UnixMilli(), } data, err := json.MarshalIndent(meta, "", " ") diff --git a/pkg/tools/skills_search.go b/pkg/tools/skills_search.go index 66fafd6ee..2b6cffd38 100644 --- a/pkg/tools/skills_search.go +++ b/pkg/tools/skills_search.go @@ -11,8 +11,7 @@ import ( // FindSkillsTool allows the LLM agent to search for installable skills from registries. type FindSkillsTool struct { registryMgr *skills.RegistryManager - - cache *skills.SearchCache + cache *skills.SearchCache } // NewFindSkillsTool creates a new FindSkillsTool. @@ -21,8 +20,7 @@ type FindSkillsTool struct { func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool { return &FindSkillsTool{ registryMgr: registryMgr, - - cache: cache, + cache: cache, } } @@ -39,18 +37,14 @@ func (t *FindSkillsTool) Parameters() map[string]any { "type": "object", "properties": map[string]any{ "query": map[string]any{ - "type": "string", - + "type": "string", "description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')", }, "limit": map[string]any{ - "type": "integer", - + "type": "integer", "description": "Maximum number of results to return (1-20, default 5)", - - "minimum": 1.0, - - "maximum": 20.0, + "minimum": 1.0, + "maximum": 20.0, }, }, "required": []string{"query"}, diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go index 4618ea424..0ca17e84f 100644 --- a/pkg/tools/spi.go +++ b/pkg/tools/spi.go @@ -29,47 +29,37 @@ func (t *SPITool) Parameters() map[string]any { "type": "object", "properties": map[string]any{ "action": map[string]any{ - "type": "string", - - "enum": []string{"list", "transfer", "read"}, - + "type": "string", + "enum": []string{"list", "transfer", "read"}, "description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)", }, "device": map[string]any{ - "type": "string", - + "type": "string", "description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.", }, "speed": map[string]any{ - "type": "integer", - + "type": "integer", "description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).", }, "mode": map[string]any{ - "type": "integer", - + "type": "integer", "description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.", }, "bits": map[string]any{ - "type": "integer", - + "type": "integer", "description": "Bits per word. Default: 8.", }, "data": map[string]any{ - "type": "array", - - "items": map[string]any{"type": "integer"}, - + "type": "array", + "items": map[string]any{"type": "integer"}, "description": "Bytes to send (0-255 each). Required for transfer action.", }, "length": map[string]any{ - "type": "integer", - + "type": "integer", "description": "Number of bytes to read (1-4096). Required for read action.", }, "confirm": map[string]any{ - "type": "boolean", - + "type": "boolean", "description": "Must be true for transfer operations. Safety guard to prevent accidental writes.", }, }, @@ -113,8 +103,7 @@ func (t *SPITool) list() *ToolResult { } type devInfo struct { - Path string `json:"path"` - + Path string `json:"path"` Device string `json:"device"` } diff --git a/pkg/tools/spi_linux.go b/pkg/tools/spi_linux.go index 9a3ae8448..9def73662 100644 --- a/pkg/tools/spi_linux.go +++ b/pkg/tools/spi_linux.go @@ -34,10 +34,8 @@ type spiTransfer struct { pad uint8 } -// configureSPI opens an SPI device and sets mode, bits per word, and speed. -func configureSPI( - devPath string, mode uint8, bits uint8, speed uint32, -) (int, *ToolResult) { +// configureSPI opens an SPI device and sets mode, bits per word, and speed +func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) { fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) if err != nil { return -1, ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err)) @@ -67,13 +65,12 @@ func configureSPI( return fd, nil } -// transfer performs a full-duplex SPI transfer. +// transfer performs a full-duplex SPI transfer func (t *SPITool) transfer(args map[string]any) *ToolResult { confirm, _ := args["confirm"].(bool) if !confirm { return ErrorResult( - "transfer operations require confirm: true." + - " Please confirm with the user before sending data to SPI devices.", + "transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.", ) } @@ -111,6 +108,7 @@ func (t *SPITool) transfer(args map[string]any) *ToolResult { defer syscall.Close(fd) rxBuf := make([]byte, len(txBuf)) + xfer := spiTransfer{ txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))), @@ -143,7 +141,7 @@ func (t *SPITool) transfer(args map[string]any) *ToolResult { return SilentResult(string(result)) } -// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed). +// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed) func (t *SPITool) readDevice(args map[string]any) *ToolResult { dev, speed, mode, bits, errMsg := parseSPIArgs(args) if errMsg != "" { @@ -167,6 +165,7 @@ func (t *SPITool) readDevice(args map[string]any) *ToolResult { txBuf := make([]byte, length) // zeros rxBuf := make([]byte, length) + xfer := spiTransfer{ txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))),