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:
parent
9caa237639
commit
9f93158218
30 changed files with 574 additions and 776 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}()
|
||||
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "", " ")
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]))),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue