refactor: replace moderate-diff Go files with upstream, add fork extensions

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 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-13 09:48:58 +09:00
parent 9caa237639
commit 9f93158218
30 changed files with 574 additions and 776 deletions

View file

@ -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")

View file

@ -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
}

View file

@ -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()

View file

@ -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
}()

View file

@ -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().

View file

@ -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 {

View file

@ -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{

View file

@ -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
}

View file

@ -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,
},
}
}

View file

@ -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{

View file

@ -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,

View file

@ -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

View file

@ -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"`

View file

@ -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),
},
})
}
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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. <ns:toolcall>), 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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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))

View file

@ -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))

View file

@ -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.",
)
}

View file

@ -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),

View file

@ -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
}

View file

@ -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/<pid>/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)
}
}

View file

@ -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, "", " ")

View file

@ -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"},

View file

@ -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"`
}

View file

@ -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]))),