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
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -67,6 +68,7 @@ func Run() error {
|
||||||
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||||
root.AddItem(bannerView(), 6, 0, false)
|
root.AddItem(bannerView(), 6, 0, false)
|
||||||
root.AddItem(state.pages, 0, 1, true)
|
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 {
|
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -102,7 +104,7 @@ func (s *appState) pop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *appState) mainMenu() tview.Primitive {
|
func (s *appState) mainMenu() tview.Primitive {
|
||||||
menu := NewMenu("Config Menu", nil)
|
menu := NewMenu("Menu", nil)
|
||||||
refreshMainMenu(menu, s)
|
refreshMainMenu(menu, s)
|
||||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
switch event.Key() {
|
switch event.Key() {
|
||||||
|
|
@ -110,10 +112,7 @@ func (s *appState) mainMenu() tview.Primitive {
|
||||||
s.requestExit()
|
s.requestExit()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if event.Rune() == 'q' {
|
|
||||||
s.requestExit()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
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) {
|
func refreshMainMenuIfPresent(s *appState) {
|
||||||
if menu, ok := s.menus["main"]; ok {
|
if menu, ok := s.menus["main"]; ok {
|
||||||
refreshMainMenu(menu, s)
|
refreshMainMenu(menu, s)
|
||||||
|
|
@ -141,6 +166,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
||||||
selectedModel := s.selectedModelName()
|
selectedModel := s.selectedModelName()
|
||||||
modelReady := selectedModel != ""
|
modelReady := selectedModel != ""
|
||||||
channelReady := s.hasEnabledChannel()
|
channelReady := s.hasEnabledChannel()
|
||||||
|
enabledCount, totalChannels := s.countChannels()
|
||||||
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
||||||
|
|
||||||
gatewayLabel := "Start Gateway"
|
gatewayLabel := "Start Gateway"
|
||||||
|
|
@ -153,7 +179,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
||||||
items := []MenuItem{
|
items := []MenuItem{
|
||||||
{
|
{
|
||||||
Label: rootModelLabel(selectedModel),
|
Label: rootModelLabel(selectedModel),
|
||||||
Description: rootModelDescription(selectedModel),
|
Description: rootModelDescription(),
|
||||||
Action: func() {
|
Action: func() {
|
||||||
s.push("model", s.modelMenu())
|
s.push("model", s.modelMenu())
|
||||||
},
|
},
|
||||||
|
|
@ -167,7 +193,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Label: rootChannelLabel(channelReady),
|
Label: rootChannelLabel(channelReady),
|
||||||
Description: rootChannelDescription(channelReady),
|
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
|
||||||
Action: func() {
|
Action: func() {
|
||||||
s.push("channel", s.channelMenu())
|
s.push("channel", s.channelMenu())
|
||||||
},
|
},
|
||||||
|
|
@ -311,16 +337,13 @@ func (s *appState) selectedModelName() string {
|
||||||
|
|
||||||
func rootModelLabel(selected string) string {
|
func rootModelLabel(selected string) string {
|
||||||
if selected == "" {
|
if selected == "" {
|
||||||
return "Model (no model selected)"
|
return "Model (None)"
|
||||||
}
|
}
|
||||||
return "Model (" + selected + ")"
|
return "Model (" + selected + ")"
|
||||||
}
|
}
|
||||||
|
|
||||||
func rootModelDescription(selected string) string {
|
func rootModelDescription() string {
|
||||||
if selected == "" {
|
return "Using SPACE to choose your model"
|
||||||
return "no model selected"
|
|
||||||
}
|
|
||||||
return "selected"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func rootChannelLabel(valid bool) string {
|
func rootChannelLabel(valid bool) string {
|
||||||
|
|
@ -330,13 +353,6 @@ func rootChannelLabel(valid bool) string {
|
||||||
return "Channel"
|
return "Channel"
|
||||||
}
|
}
|
||||||
|
|
||||||
func rootChannelDescription(valid bool) string {
|
|
||||||
if !valid {
|
|
||||||
return "no channel enabled"
|
|
||||||
}
|
|
||||||
return "enabled"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) startTalk() {
|
func (s *appState) startTalk() {
|
||||||
if !s.isActiveModelValid() {
|
if !s.isActiveModelValid() {
|
||||||
s.showMessage("Model required", "Select a valid model before starting talk")
|
s.showMessage("Model required", "Select a valid model before starting talk")
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package onboard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
|
@ -9,30 +10,6 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"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() {
|
func onboard() {
|
||||||
configPath := internal.GetConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
|
|
@ -77,19 +54,48 @@ func createWorkspaceTemplates(workspace string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyEmbeddedToTarget(targetDir string) error {
|
func copyEmbeddedToTarget(targetDir string) error {
|
||||||
|
// Ensure target directory exists
|
||||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
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 {
|
// Walk through all files in embed.FS
|
||||||
targetPath := filepath.Join(targetDir, relPath)
|
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 {
|
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.
|
// AgentRegistry manages multiple agent instances and routes messages to them.
|
||||||
type AgentRegistry struct {
|
type AgentRegistry struct {
|
||||||
agents map[string]*AgentInstance
|
agents map[string]*AgentInstance
|
||||||
|
|
||||||
resolver *routing.RouteResolver
|
resolver *routing.RouteResolver
|
||||||
|
mu sync.RWMutex
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentRegistry creates a registry from config, instantiating all agents.
|
// NewAgentRegistry creates a registry from config, instantiating all agents.
|
||||||
|
|
@ -25,16 +23,14 @@ func NewAgentRegistry(
|
||||||
provider providers.LLMProvider,
|
provider providers.LLMProvider,
|
||||||
) *AgentRegistry {
|
) *AgentRegistry {
|
||||||
registry := &AgentRegistry{
|
registry := &AgentRegistry{
|
||||||
agents: make(map[string]*AgentInstance),
|
agents: make(map[string]*AgentInstance),
|
||||||
|
|
||||||
resolver: routing.NewRouteResolver(cfg),
|
resolver: routing.NewRouteResolver(cfg),
|
||||||
}
|
}
|
||||||
|
|
||||||
agentConfigs := cfg.Agents.List
|
agentConfigs := cfg.Agents.List
|
||||||
if len(agentConfigs) == 0 {
|
if len(agentConfigs) == 0 {
|
||||||
implicitAgent := &config.AgentConfig{
|
implicitAgent := &config.AgentConfig{
|
||||||
ID: "main",
|
ID: "main",
|
||||||
|
|
||||||
Default: true,
|
Default: true,
|
||||||
}
|
}
|
||||||
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
|
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
@ -48,13 +44,10 @@ func NewAgentRegistry(
|
||||||
registry.agents[id] = instance
|
registry.agents[id] = instance
|
||||||
logger.InfoCF("agent", "Registered agent",
|
logger.InfoCF("agent", "Registered agent",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": id,
|
"agent_id": id,
|
||||||
|
"name": ac.Name,
|
||||||
"name": ac.Name,
|
|
||||||
|
|
||||||
"workspace": instance.Workspace,
|
"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.
|
// GetDefaultAgent returns the default agent instance.
|
||||||
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,14 @@ type DiscordChannel struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
|
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)
|
session, err := discordgo.New("Bot " + cfg.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
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 nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.sendChunk(ctx, channelID, msg.Content)
|
return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMedia implements the channels.MediaSender interface.
|
// 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.
|
// EditMessage implements channels.MessageEditor.
|
||||||
func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||||
_, err := c.session.ChannelMessageEdit(chatID, messageID, content)
|
_, 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
|
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
|
// Use the passed ctx for timeout control
|
||||||
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() {
|
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
|
done <- err
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,10 @@ package feishu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/big"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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) {
|
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
||||||
// Get emoji list from config
|
// Get emoji list from config
|
||||||
emojiList := c.config.RandomReactionEmoji
|
emojiList := c.config.RandomReactionEmoji
|
||||||
|
var chosenEmoji string
|
||||||
if len(emojiList) == 0 {
|
if len(emojiList) == 0 {
|
||||||
// Default to "Pin" if no config
|
// Default to "Pin" if no config
|
||||||
emojiList = []string{"Pin"}
|
chosenEmoji = "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]
|
|
||||||
} else {
|
} else {
|
||||||
chosenEmoji = emojiList[idx.Int64()]
|
idx := rand.Intn(len(emojiList))
|
||||||
|
chosenEmoji = emojiList[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
req := larkim.NewCreateMessageReactionReqBuilder().
|
req := larkim.NewCreateMessageReactionReqBuilder().
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -13,6 +14,9 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gomarkdown/markdown"
|
||||||
|
mdhtml "github.com/gomarkdown/markdown/html"
|
||||||
|
"github.com/gomarkdown/markdown/parser"
|
||||||
"maunium.net/go/mautrix"
|
"maunium.net/go/mautrix"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
@ -268,6 +272,12 @@ func (c *MatrixChannel) Stop(ctx context.Context) error {
|
||||||
return nil
|
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 {
|
func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
|
|
@ -283,16 +293,22 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
|
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content))
|
||||||
MsgType: event.MsgText,
|
|
||||||
Body: content,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("matrix send: %w", channels.ErrTemporary)
|
return fmt.Errorf("matrix send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
return nil
|
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.
|
// SendMedia implements channels.MediaSender.
|
||||||
func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
if !c.IsRunning() {
|
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")
|
return fmt.Errorf("matrix message ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
editContent := &event.MessageEventContent{
|
editContent := c.messageContent(content)
|
||||||
MsgType: event.MsgText,
|
|
||||||
Body: content,
|
|
||||||
}
|
|
||||||
editContent.SetEdit(id.EventID(messageID))
|
editContent.SetEdit(id.EventID(messageID))
|
||||||
|
|
||||||
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent)
|
_, 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)
|
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
data, err := c.client.DownloadBytes(reqCtx, parsed)
|
resp, err := c.client.Download(reqCtx, parsed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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.
|
// Encrypted attachments put URL in msgEvt.File and require client-side decryption.
|
||||||
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
|
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
|
||||||
err = msgEvt.File.DecryptInPlace(data)
|
if err = msgEvt.File.PrepareForDecryption(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
||||||
}
|
}
|
||||||
|
decryptReader := msgEvt.File.DecryptStream(resp.Body)
|
||||||
|
reader = decryptReader
|
||||||
|
readerClose = decryptReader.Close
|
||||||
}
|
}
|
||||||
|
|
||||||
label := matrixMediaLabel(msgEvt, mediaKind)
|
label := matrixMediaLabel(msgEvt, mediaKind)
|
||||||
|
|
@ -737,14 +756,28 @@ func (c *MatrixChannel) downloadMedia(
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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 {
|
_, err = io.Copy(tmp, reader)
|
||||||
_ = os.Remove(tmp.Name())
|
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 "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tmp.Name(), nil
|
cleanup = false
|
||||||
|
return tmpPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func matrixContentType(msgEvt *event.MessageEventContent) string {
|
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)
|
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.
|
// EditMessage implements channels.MessageEditor.
|
||||||
func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||||
outMsg := newMessage(TypeMessageUpdate, map[string]any{
|
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),
|
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))
|
opts = append(opts, slack.MsgOptionTS(threadTS))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,7 +187,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
||||||
title = filename
|
title = filename
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{
|
_, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{
|
||||||
Channel: channelID,
|
Channel: channelID,
|
||||||
File: localPath,
|
File: localPath,
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
|
|
@ -303,17 +307,16 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
Timestamp: messageTS,
|
Timestamp: messageTS,
|
||||||
})
|
})
|
||||||
|
|
||||||
var contentBuf strings.Builder
|
content := ev.Text
|
||||||
contentBuf.WriteString(c.stripBotMention(ev.Text))
|
content = c.stripBotMention(content)
|
||||||
|
|
||||||
// In non-DM channels, apply group trigger filtering
|
// In non-DM channels, apply group trigger filtering
|
||||||
if !strings.HasPrefix(channelID, "D") {
|
if !strings.HasPrefix(channelID, "D") {
|
||||||
respond, cleaned := c.ShouldRespondInGroup(false, contentBuf.String())
|
respond, cleaned := c.ShouldRespondInGroup(false, content)
|
||||||
if !respond {
|
if !respond {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
contentBuf.Reset()
|
content = cleaned
|
||||||
contentBuf.WriteString(cleaned)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var mediaPaths []string
|
var mediaPaths []string
|
||||||
|
|
@ -341,11 +344,10 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))
|
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) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ func DefaultConfig() *Config {
|
||||||
Temperature: nil, // nil means use provider default
|
Temperature: nil, // nil means use provider default
|
||||||
MaxToolIterations: 50,
|
MaxToolIterations: 50,
|
||||||
SummarizeMessageThreshold: 20,
|
SummarizeMessageThreshold: 20,
|
||||||
TaskReminderInterval: 5,
|
|
||||||
SummarizeTokenPercent: 75,
|
SummarizeTokenPercent: 75,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -51,12 +50,10 @@ func DefaultConfig() *Config {
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
Telegram: TelegramConfig{
|
Telegram: TelegramConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Token: "",
|
Token: "",
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
Typing: TypingConfig{Enabled: true},
|
Typing: TypingConfig{Enabled: true},
|
||||||
SubagentThreadID: 0,
|
|
||||||
HeartbeatThreadID: 0,
|
|
||||||
Placeholder: PlaceholderConfig{
|
Placeholder: PlaceholderConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Text: "Thinking... 💭",
|
Text: "Thinking... 💭",
|
||||||
|
|
@ -83,10 +80,11 @@ func DefaultConfig() *Config {
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
QQ: QQConfig{
|
QQ: QQConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
AppID: "",
|
AppID: "",
|
||||||
AppSecret: "",
|
AppSecret: "",
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
MaxMessageLength: 2000,
|
||||||
},
|
},
|
||||||
DingTalk: DingTalkConfig{
|
DingTalk: DingTalkConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
|
@ -196,8 +194,8 @@ func DefaultConfig() *Config {
|
||||||
|
|
||||||
// OpenAI - https://platform.openai.com/api-keys
|
// OpenAI - https://platform.openai.com/api-keys
|
||||||
{
|
{
|
||||||
ModelName: "gpt-5.2",
|
ModelName: "gpt-5.4",
|
||||||
Model: "openai/gpt-5.2",
|
Model: "openai/gpt-5.4",
|
||||||
APIBase: "https://api.openai.com/v1",
|
APIBase: "https://api.openai.com/v1",
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
|
|
@ -258,8 +256,8 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "openrouter-gpt-5.2",
|
ModelName: "openrouter-gpt-5.4",
|
||||||
Model: "openrouter/openai/gpt-5.2",
|
Model: "openrouter/openai/gpt-5.4",
|
||||||
APIBase: "https://openrouter.ai/api/v1",
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
|
|
@ -289,6 +287,12 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
// 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",
|
ModelName: "doubao-pro",
|
||||||
Model: "volcengine/doubao-pro-32k",
|
Model: "volcengine/doubao-pro-32k",
|
||||||
|
|
@ -313,8 +317,8 @@ func DefaultConfig() *Config {
|
||||||
|
|
||||||
// GitHub Copilot - https://github.com/settings/tokens
|
// GitHub Copilot - https://github.com/settings/tokens
|
||||||
{
|
{
|
||||||
ModelName: "copilot-gpt-5.2",
|
ModelName: "copilot-gpt-5.4",
|
||||||
Model: "github-copilot/gpt-5.2",
|
Model: "github-copilot/gpt-5.4",
|
||||||
APIBase: "http://localhost:4321",
|
APIBase: "http://localhost:4321",
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
},
|
},
|
||||||
|
|
@ -349,6 +353,22 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
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
|
// VLLM (local) - http://localhost:8000
|
||||||
{
|
{
|
||||||
ModelName: "local-model",
|
ModelName: "local-model",
|
||||||
|
|
@ -378,6 +398,13 @@ func DefaultConfig() *Config {
|
||||||
Brave: BraveConfig{
|
Brave: BraveConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
|
APIKeys: nil,
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
|
Tavily: TavilyConfig{
|
||||||
|
Enabled: false,
|
||||||
|
APIKey: "",
|
||||||
|
APIKeys: nil,
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
DuckDuckGo: DuckDuckGoConfig{
|
DuckDuckGo: DuckDuckGoConfig{
|
||||||
|
|
@ -387,6 +414,7 @@ func DefaultConfig() *Config {
|
||||||
Perplexity: PerplexityConfig{
|
Perplexity: PerplexityConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
|
APIKeys: nil,
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
SearXNG: SearXNGConfig{
|
SearXNG: SearXNGConfig{
|
||||||
|
|
@ -439,6 +467,13 @@ func DefaultConfig() *Config {
|
||||||
ToolConfig: ToolConfig{
|
ToolConfig: ToolConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
},
|
},
|
||||||
|
Discovery: ToolDiscoveryConfig{
|
||||||
|
Enabled: false,
|
||||||
|
TTL: 5,
|
||||||
|
MaxSearchResults: 5,
|
||||||
|
UseBM25: true,
|
||||||
|
UseRegex: false,
|
||||||
|
},
|
||||||
Servers: map[string]MCPServerConfig{},
|
Servers: map[string]MCPServerConfig{},
|
||||||
},
|
},
|
||||||
AppendFile: ToolConfig{
|
AppendFile: ToolConfig{
|
||||||
|
|
@ -463,7 +498,8 @@ func DefaultConfig() *Config {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
ReadFile: ReadFileToolConfig{
|
ReadFile: ReadFileToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
MaxReadFileSize: 64 * 1024, // 64KB
|
||||||
},
|
},
|
||||||
Spawn: ToolConfig{
|
Spawn: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
@ -489,5 +525,14 @@ func DefaultConfig() *Config {
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
MonitorUSB: true,
|
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 {
|
type BraveConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
MaxResults int `json:"max_results"`
|
APIKeys []string `json:"api_keys"`
|
||||||
|
MaxResults int `json:"max_results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TavilyConfig struct {
|
type TavilyConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
BaseURL string `json:"base_url"`
|
APIKeys []string `json:"api_keys"`
|
||||||
MaxResults int `json:"max_results"`
|
BaseURL string `json:"base_url"`
|
||||||
|
MaxResults int `json:"max_results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DuckDuckGoConfig struct {
|
type DuckDuckGoConfig struct {
|
||||||
|
|
@ -751,9 +753,10 @@ type DuckDuckGoConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PerplexityConfig struct {
|
type PerplexityConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
MaxResults int `json:"max_results"`
|
APIKeys []string `json:"api_keys"`
|
||||||
|
MaxResults int `json:"max_results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronConfig struct {
|
type CronConfig struct {
|
||||||
|
|
@ -1082,6 +1085,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig {
|
||||||
Brave: config.BraveConfig{
|
Brave: config.BraveConfig{
|
||||||
Enabled: c.Web.Brave.Enabled,
|
Enabled: c.Web.Brave.Enabled,
|
||||||
APIKey: c.Web.Brave.APIKey,
|
APIKey: c.Web.Brave.APIKey,
|
||||||
|
APIKeys: c.Web.Brave.APIKeys,
|
||||||
MaxResults: c.Web.Brave.MaxResults,
|
MaxResults: c.Web.Brave.MaxResults,
|
||||||
},
|
},
|
||||||
Tavily: config.TavilyConfig{
|
Tavily: config.TavilyConfig{
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,7 @@ func (p *AntigravityProvider) buildRequest(
|
||||||
if t.Type != "function" {
|
if t.Type != "function" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
params := sanitizeSchemaForGemini(t.Function.ParametersMap())
|
params := sanitizeSchemaForGemini(t.Function.Parameters)
|
||||||
funcDecls = append(funcDecls, antigravityFuncDecl{
|
funcDecls = append(funcDecls, antigravityFuncDecl{
|
||||||
Name: t.Function.Name,
|
Name: t.Function.Name,
|
||||||
Description: t.Function.Description,
|
Description: t.Function.Description,
|
||||||
|
|
@ -340,13 +340,17 @@ func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) {
|
||||||
thoughtSignature = tc.Function.ThoughtSignature
|
thoughtSignature = tc.Function.ThoughtSignature
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(args) == 0 && tc.Function != nil && len(tc.Function.Arguments) > 0 {
|
|
||||||
args = cloneToolArgs(tc.Function.Arguments)
|
|
||||||
}
|
|
||||||
if args == nil {
|
if args == nil {
|
||||||
args = map[string]any{}
|
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
|
return name, args, thoughtSignature
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -432,13 +436,14 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
|
||||||
contentParts = append(contentParts, part.Text)
|
contentParts = append(contentParts, part.Text)
|
||||||
}
|
}
|
||||||
if part.FunctionCall != nil {
|
if part.FunctionCall != nil {
|
||||||
|
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
|
||||||
toolCalls = append(toolCalls, ToolCall{
|
toolCalls = append(toolCalls, ToolCall{
|
||||||
ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
|
ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
|
||||||
Name: part.FunctionCall.Name,
|
Name: part.FunctionCall.Name,
|
||||||
Arguments: part.FunctionCall.Args,
|
Arguments: part.FunctionCall.Args,
|
||||||
Function: &FunctionCall{
|
Function: &FunctionCall{
|
||||||
Name: part.FunctionCall.Name,
|
Name: part.FunctionCall.Name,
|
||||||
Arguments: cloneToolArgs(part.FunctionCall.Args),
|
Arguments: string(argumentsJSON),
|
||||||
ThoughtSignature: extractPartThoughtSignature(
|
ThoughtSignature: extractPartThoughtSignature(
|
||||||
part.ThoughtSignature,
|
part.ThoughtSignature,
|
||||||
part.ThoughtSignatureSnake,
|
part.ThoughtSignatureSnake,
|
||||||
|
|
|
||||||
|
|
@ -100,45 +100,12 @@ func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDe
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tools) > 0 {
|
if len(tools) > 0 {
|
||||||
parts = append(parts, p.buildToolsPrompt(tools))
|
parts = append(parts, buildCLIToolsPrompt(tools))
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(parts, "\n\n")
|
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.
|
// parseClaudeCliResponse parses the JSON output from the claude CLI.
|
||||||
func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) {
|
func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) {
|
||||||
var resp claudeCliJSONResponse
|
var resp claudeCliJSONResponse
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tools) > 0 {
|
if len(tools) > 0 {
|
||||||
sb.WriteString(p.buildToolsPrompt(tools))
|
sb.WriteString(buildCLIToolsPrompt(tools))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,39 +128,6 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio
|
||||||
return sb.String()
|
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`.
|
// codexEvent represents a single JSONL event from `codex exec --json`.
|
||||||
type codexEvent struct {
|
type codexEvent struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
codexDefaultModel = "gpt-5.2"
|
codexDefaultModel = "gpt-5.3-codex"
|
||||||
codexDefaultInstructions = "You are Codex, a coding assistant."
|
codexDefaultInstructions = "You are Codex, a coding assistant."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -317,19 +317,19 @@ func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool)
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
args := tc.Arguments
|
if len(tc.Arguments) > 0 {
|
||||||
if len(args) == 0 && tc.Function != nil {
|
argsJSON, err := json.Marshal(tc.Arguments)
|
||||||
args = tc.Function.Arguments
|
if err != nil {
|
||||||
}
|
return "", "", false
|
||||||
if len(args) == 0 {
|
}
|
||||||
return name, "{}", true
|
return name, string(argsJSON), true
|
||||||
}
|
}
|
||||||
|
|
||||||
argsJSON, err := json.Marshal(args)
|
if tc.Function != nil && tc.Function.Arguments != "" {
|
||||||
if err != nil {
|
return name, tc.Function.Arguments, true
|
||||||
return "", "", false
|
|
||||||
}
|
}
|
||||||
return name, string(argsJSON), true
|
|
||||||
|
return name, "{}", true
|
||||||
}
|
}
|
||||||
|
|
||||||
func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam {
|
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") {
|
if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
params := t.Function.ParametersMap()
|
|
||||||
if params == nil {
|
|
||||||
params = map[string]any{}
|
|
||||||
}
|
|
||||||
ft := responses.FunctionToolParam{
|
ft := responses.FunctionToolParam{
|
||||||
Name: t.Function.Name,
|
Name: t.Function.Name,
|
||||||
Parameters: params,
|
Parameters: t.Function.Parameters,
|
||||||
Strict: openai.Opt(false),
|
Strict: openai.Opt(false),
|
||||||
}
|
}
|
||||||
if t.Function.Description != "" {
|
if t.Function.Description != "" {
|
||||||
|
|
@ -386,10 +382,6 @@ func parseCodexResponse(resp *responses.Response) *LLMResponse {
|
||||||
ID: item.CallID,
|
ID: item.CallID,
|
||||||
Name: item.Name,
|
Name: item.Name,
|
||||||
Arguments: args,
|
Arguments: args,
|
||||||
Function: &FunctionCall{
|
|
||||||
Name: item.Name,
|
|
||||||
Arguments: cloneToolArgs(args),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,13 +36,14 @@ type providerSelection struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
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()
|
model := cfg.Agents.Defaults.GetModelName()
|
||||||
|
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
|
||||||
lowerModel := strings.ToLower(model)
|
lowerModel := strings.ToLower(model)
|
||||||
|
|
||||||
|
if providerName == "" && model == "" {
|
||||||
|
return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty")
|
||||||
|
}
|
||||||
|
|
||||||
sel := providerSelection{
|
sel := providerSelection{
|
||||||
providerType: providerTypeHTTPCompat,
|
providerType: providerTypeHTTPCompat,
|
||||||
model: model,
|
model: model,
|
||||||
|
|
@ -211,6 +212,24 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
|
||||||
sel.apiBase = "https://api.mistral.ai/v1"
|
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":
|
case "github_copilot", "copilot":
|
||||||
sel.providerType = providerTypeGitHubCopilot
|
sel.providerType = providerTypeGitHubCopilot
|
||||||
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
||||||
|
|
@ -328,6 +347,13 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
|
||||||
if sel.apiBase == "" {
|
if sel.apiBase == "" {
|
||||||
sel.apiBase = "https://api.mistral.ai/v1"
|
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 != "":
|
case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "":
|
||||||
sel.apiKey = cfg.Providers.Avian.APIKey
|
sel.apiKey = cfg.Providers.Avian.APIKey
|
||||||
sel.apiBase = cfg.Providers.Avian.APIBase
|
sel.apiBase = cfg.Providers.Avian.APIBase
|
||||||
|
|
@ -335,6 +361,13 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
|
||||||
if sel.apiBase == "" {
|
if sel.apiBase == "" {
|
||||||
sel.apiBase = "https://api.avian.io/v1"
|
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 != "":
|
case cfg.Providers.VLLM.APIBase != "":
|
||||||
sel.apiKey = cfg.Providers.VLLM.APIKey
|
sel.apiKey = cfg.Providers.VLLM.APIKey
|
||||||
sel.apiBase = cfg.Providers.VLLM.APIBase
|
sel.apiBase = cfg.Providers.VLLM.APIBase
|
||||||
|
|
@ -365,31 +398,3 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr
|
||||||
|
|
||||||
return sel, nil
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"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.
|
// 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 == "" {
|
if apiBase == "" {
|
||||||
apiBase = getDefaultAPIBase(protocol)
|
apiBase = getDefaultAPIBase(protocol)
|
||||||
}
|
}
|
||||||
return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy,
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
openai_compat.WithMaxTokensField(cfg.MaxTokensField),
|
cfg.APIKey,
|
||||||
openai_compat.WithStream(boolDefault(cfg.Stream, false)),
|
apiBase,
|
||||||
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second),
|
cfg.Proxy,
|
||||||
openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)),
|
cfg.MaxTokensField,
|
||||||
), modelID, nil
|
cfg.RequestTimeout,
|
||||||
|
|
||||||
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)),
|
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"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
|
// All other OpenAI-compatible HTTP providers
|
||||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
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 == "" {
|
if apiBase == "" {
|
||||||
apiBase = getDefaultAPIBase(protocol)
|
apiBase = getDefaultAPIBase(protocol)
|
||||||
}
|
}
|
||||||
return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy,
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
openai_compat.WithMaxTokensField(cfg.MaxTokensField),
|
cfg.APIKey,
|
||||||
openai_compat.WithStream(boolDefault(cfg.Stream, false)),
|
apiBase,
|
||||||
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second),
|
cfg.Proxy,
|
||||||
openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)),
|
cfg.MaxTokensField,
|
||||||
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
|
|
@ -145,10 +129,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey == "" {
|
||||||
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
||||||
}
|
}
|
||||||
return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy,
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
openai_compat.WithMaxTokensField(cfg.MaxTokensField),
|
cfg.APIKey,
|
||||||
openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second),
|
apiBase,
|
||||||
openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)),
|
cfg.Proxy,
|
||||||
|
cfg.MaxTokensField,
|
||||||
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
case "antigravity":
|
case "antigravity":
|
||||||
|
|
@ -223,30 +209,15 @@ func getDefaultAPIBase(protocol string) string {
|
||||||
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
case "vllm":
|
case "vllm":
|
||||||
return "http://localhost:8000/v1"
|
return "http://localhost:8000/v1"
|
||||||
case "minimax":
|
|
||||||
return "https://api.minimax.io/v1"
|
|
||||||
case "mistral":
|
case "mistral":
|
||||||
return "https://api.mistral.ai/v1"
|
return "https://api.mistral.ai/v1"
|
||||||
case "avian":
|
case "avian":
|
||||||
return "https://api.avian.io/v1"
|
return "https://api.avian.io/v1"
|
||||||
|
case "minimax":
|
||||||
|
return "https://api.minimaxi.com/v1"
|
||||||
|
case "longcat":
|
||||||
|
return "https://api.longcat.chat/openai"
|
||||||
default:
|
default:
|
||||||
return ""
|
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(
|
func (p *HTTPProvider) Chat(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
messages []Message,
|
messages []Message,
|
||||||
|
|
@ -55,38 +49,9 @@ func (p *HTTPProvider) Chat(
|
||||||
model string,
|
model string,
|
||||||
options map[string]any,
|
options map[string]any,
|
||||||
) (*LLMResponse, error) {
|
) (*LLMResponse, error) {
|
||||||
resp, err := p.delegate.Chat(ctx, messages, tools, model, options)
|
return 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPProvider) GetDefaultModel() string {
|
func (p *HTTPProvider) GetDefaultModel() string {
|
||||||
return ""
|
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
|
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.
|
// 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)
|
// It handles cases where Name/Arguments might be in different locations (top-level vs Function)
|
||||||
// and ensures both are populated consistently.
|
// and ensures both are populated consistently.
|
||||||
func NormalizeToolCall(tc ToolCall) ToolCall {
|
func NormalizeToolCall(tc ToolCall) ToolCall {
|
||||||
normalized := tc
|
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 {
|
if normalized.Name == "" && normalized.Function != nil {
|
||||||
normalized.Name = normalized.Function.Name
|
normalized.Name = normalized.Function.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Arguments is not nil.
|
// Ensure Arguments is not nil
|
||||||
if normalized.Arguments == nil {
|
if normalized.Arguments == nil {
|
||||||
normalized.Arguments = map[string]any{}
|
normalized.Arguments = map[string]any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate top-level arguments from Function arguments when needed.
|
// Parse Arguments from Function.Arguments if not already set
|
||||||
if len(normalized.Arguments) == 0 && normalized.Function != nil && len(normalized.Function.Arguments) > 0 {
|
if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" {
|
||||||
normalized.Arguments = cloneToolArgs(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 {
|
if normalized.Function == nil {
|
||||||
normalized.Function = &FunctionCall{
|
normalized.Function = &FunctionCall{
|
||||||
Name: normalized.Name,
|
Name: normalized.Name,
|
||||||
Arguments: cloneToolArgs(normalized.Arguments),
|
Arguments: string(argsJSON),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if normalized.Function.Name == "" {
|
if normalized.Function.Name == "" {
|
||||||
|
|
@ -39,21 +81,10 @@ func NormalizeToolCall(tc ToolCall) ToolCall {
|
||||||
if normalized.Name == "" {
|
if normalized.Name == "" {
|
||||||
normalized.Name = normalized.Function.Name
|
normalized.Name = normalized.Function.Name
|
||||||
}
|
}
|
||||||
if len(normalized.Function.Arguments) == 0 {
|
if normalized.Function.Arguments == "" {
|
||||||
normalized.Function.Arguments = cloneToolArgs(normalized.Arguments)
|
normalized.Function.Arguments = string(argsJSON)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -18,8 +19,6 @@ type (
|
||||||
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||||
ExtraContent = protocoltypes.ExtraContent
|
ExtraContent = protocoltypes.ExtraContent
|
||||||
GoogleExtra = protocoltypes.GoogleExtra
|
GoogleExtra = protocoltypes.GoogleExtra
|
||||||
StreamEvent = protocoltypes.StreamEvent
|
|
||||||
StreamToolCallDelta = protocoltypes.StreamToolCallDelta
|
|
||||||
ContentBlock = protocoltypes.ContentBlock
|
ContentBlock = protocoltypes.ContentBlock
|
||||||
CacheControl = protocoltypes.CacheControl
|
CacheControl = protocoltypes.CacheControl
|
||||||
)
|
)
|
||||||
|
|
@ -84,6 +83,12 @@ func (e *FailoverError) IsRetriable() bool {
|
||||||
return e.Reason != FailoverFormat
|
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.
|
// StreamingProvider extends LLMProvider with SSE channel-based streaming.
|
||||||
// Use a type assertion to check if a provider supports streaming:
|
// Use a type assertion to check if a provider supports streaming:
|
||||||
//
|
//
|
||||||
|
|
@ -91,21 +96,23 @@ func (e *FailoverError) IsRetriable() bool {
|
||||||
type StreamingProvider interface {
|
type StreamingProvider interface {
|
||||||
LLMProvider
|
LLMProvider
|
||||||
CanStream() bool
|
CanStream() bool
|
||||||
ChatStream(
|
ChatStream(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any) (<-chan StreamEvent, error)
|
||||||
ctx context.Context,
|
|
||||||
messages []Message,
|
|
||||||
tools []ToolDefinition,
|
|
||||||
model string,
|
|
||||||
options map[string]any,
|
|
||||||
) (<-chan StreamEvent, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelConfig holds primary model and fallback list.
|
// FallbackCandidate represents a model that can be tried if the primary model fails.
|
||||||
type ModelConfig struct {
|
type FallbackCandidate struct {
|
||||||
Primary string
|
ModelName string
|
||||||
Fallbacks []string
|
Model string
|
||||||
|
Protocol string
|
||||||
|
Provider LLMProvider
|
||||||
|
Options map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
func MustMarshalParameters(params map[string]any) json.RawMessage {
|
// UnmarshalArguments is a helper to parse FunctionCall.Arguments from json.RawMessage.
|
||||||
return protocoltypes.MustMarshalParameters(params)
|
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
|
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.
|
// SetLastChatID atomically updates the last chat ID and saves the state.
|
||||||
func (sm *Manager) SetLastChatID(chatID string) error {
|
func (sm *Manager) SetLastChatID(chatID string) error {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
|
|
@ -153,20 +123,6 @@ func (sm *Manager) GetLastChannel() string {
|
||||||
return sm.state.LastChannel
|
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.
|
// GetLastChatID returns the last chat ID from the state.
|
||||||
func (sm *Manager) GetLastChatID() string {
|
func (sm *Manager) GetLastChatID() string {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
|
|
@ -217,3 +173,47 @@ func (sm *Manager) load() error {
|
||||||
|
|
||||||
return nil
|
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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -15,17 +16,12 @@ type EditFileTool struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEditFileTool creates a new EditFileTool with optional directory restriction.
|
// NewEditFileTool creates a new EditFileTool with optional directory restriction.
|
||||||
|
func NewEditFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *EditFileTool {
|
||||||
func NewEditFileTool(workspace string, restrict bool) *EditFileTool {
|
var patterns []*regexp.Regexp
|
||||||
var fs fileSystem
|
if len(allowPaths) > 0 {
|
||||||
|
patterns = allowPaths[0]
|
||||||
if restrict {
|
|
||||||
fs = &sandboxFs{workspace: workspace}
|
|
||||||
} else {
|
|
||||||
fs = &hostFs{}
|
|
||||||
}
|
}
|
||||||
|
return &EditFileTool{fs: buildFs(workspace, restrict, patterns)}
|
||||||
return &EditFileTool{fs: fs}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *EditFileTool) Name() string {
|
func (t *EditFileTool) Name() string {
|
||||||
|
|
@ -41,18 +37,15 @@ func (t *EditFileTool) Parameters() map[string]any {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"path": map[string]any{
|
"path": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
||||||
"description": "The file path to edit",
|
"description": "The file path to edit",
|
||||||
},
|
},
|
||||||
"old_text": map[string]any{
|
"old_text": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
||||||
"description": "The exact text to find and replace",
|
"description": "The exact text to find and replace",
|
||||||
},
|
},
|
||||||
"new_text": map[string]any{
|
"new_text": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
||||||
"description": "The text to replace with",
|
"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")
|
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 ErrorResult(err.Error())
|
||||||
}
|
}
|
||||||
return SilentResult(fmt.Sprintf("File edited: %s", path))
|
return SilentResult(fmt.Sprintf("File edited: %s", path))
|
||||||
|
|
@ -86,16 +79,12 @@ type AppendFileTool struct {
|
||||||
fs fileSystem
|
fs fileSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool {
|
func NewAppendFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *AppendFileTool {
|
||||||
var fs fileSystem
|
var patterns []*regexp.Regexp
|
||||||
|
if len(allowPaths) > 0 {
|
||||||
if restrict {
|
patterns = allowPaths[0]
|
||||||
fs = &sandboxFs{workspace: workspace}
|
|
||||||
} else {
|
|
||||||
fs = &hostFs{}
|
|
||||||
}
|
}
|
||||||
|
return &AppendFileTool{fs: buildFs(workspace, restrict, patterns)}
|
||||||
return &AppendFileTool{fs: fs}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *AppendFileTool) Name() string {
|
func (t *AppendFileTool) Name() string {
|
||||||
|
|
@ -111,13 +100,11 @@ func (t *AppendFileTool) Parameters() map[string]any {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"path": map[string]any{
|
"path": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
||||||
"description": "The file path to append to",
|
"description": "The file path to append to",
|
||||||
},
|
},
|
||||||
"content": map[string]any{
|
"content": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
||||||
"description": "The content to append",
|
"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")
|
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 ErrorResult(err.Error())
|
||||||
}
|
}
|
||||||
return SilentResult(fmt.Sprintf("Appended to %s", path))
|
return SilentResult(fmt.Sprintf("Appended to %s", path))
|
||||||
|
|
|
||||||
|
|
@ -29,42 +29,33 @@ func (t *I2CTool) Parameters() map[string]any {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"action": map[string]any{
|
"action": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
"enum": []string{"detect", "scan", "read", "write"},
|
||||||
"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)",
|
"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{
|
"bus": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
||||||
"description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.",
|
"description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.",
|
||||||
},
|
},
|
||||||
"address": map[string]any{
|
"address": map[string]any{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
|
|
||||||
"description": "7-bit I2C device address (0x03-0x77). Required for read/write.",
|
"description": "7-bit I2C device address (0x03-0x77). Required for read/write.",
|
||||||
},
|
},
|
||||||
"register": map[string]any{
|
"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.",
|
"description": "Register address to read from or write to. If set, sends register byte before read/write.",
|
||||||
},
|
},
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
"items": map[string]any{"type": "integer"},
|
||||||
"items": map[string]any{"type": "integer"},
|
|
||||||
|
|
||||||
"description": "Bytes to write (0-255 each). Required for write action.",
|
"description": "Bytes to write (0-255 each). Required for write action.",
|
||||||
},
|
},
|
||||||
"length": map[string]any{
|
"length": map[string]any{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
|
|
||||||
"description": "Number of bytes to read (1-256). Default: 1. Used with read action.",
|
"description": "Number of bytes to read (1-256). Default: 1. Used with read action.",
|
||||||
},
|
},
|
||||||
"confirm": map[string]any{
|
"confirm": map[string]any{
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
|
|
||||||
"description": "Must be true for write operations. Safety guard to prevent accidental writes.",
|
"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 {
|
type busInfo struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
Bus string `json:"bus"`
|
||||||
Bus string `json:"bus"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buses := make([]busInfo, 0, len(matches))
|
buses := make([]busInfo, 0, len(matches))
|
||||||
|
|
|
||||||
|
|
@ -97,12 +97,10 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
|
||||||
|
|
||||||
hasQuick := funcs&i2cFuncSmbusQuick != 0
|
hasQuick := funcs&i2cFuncSmbusQuick != 0
|
||||||
hasReadByte := funcs&i2cFuncSmbusReadByte != 0
|
hasReadByte := funcs&i2cFuncSmbusReadByte != 0
|
||||||
|
|
||||||
if !hasQuick && !hasReadByte {
|
if !hasQuick && !hasReadByte {
|
||||||
return ErrorResult(
|
return ErrorResult(
|
||||||
fmt.Sprintf(
|
fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath),
|
||||||
"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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if smbusProbe(fd, addr, hasQuick) {
|
if smbusProbe(fd, addr, hasQuick) {
|
||||||
found = append(found, deviceEntry{
|
found = append(found, deviceEntry{
|
||||||
Address: fmt.Sprintf("0x%02x", addr),
|
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)))
|
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 {
|
func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
|
||||||
bus, errResult := parseI2CBus(args)
|
bus, errResult := parseI2CBus(args)
|
||||||
if errResult != nil {
|
if errResult != nil {
|
||||||
|
|
@ -214,14 +213,12 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
|
||||||
return SilentResult(string(result))
|
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 {
|
func (t *I2CTool) writeDevice(args map[string]any) *ToolResult {
|
||||||
confirm, _ := args["confirm"].(bool)
|
confirm, _ := args["confirm"].(bool)
|
||||||
if !confirm {
|
if !confirm {
|
||||||
return ErrorResult(
|
return ErrorResult(
|
||||||
"write operations require confirm: true." +
|
"write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.",
|
||||||
" Please confirm with the user before writing to I2C devices," +
|
|
||||||
" as incorrect writes can misconfigure hardware.",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,14 @@ package tools
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SendCallback func(channel, chatID, content string) error
|
type SendCallback func(channel, chatID, content string) error
|
||||||
|
|
||||||
type MessageTool struct {
|
type MessageTool struct {
|
||||||
sendCallback SendCallback
|
sendCallback SendCallback
|
||||||
|
sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round
|
||||||
defaultChannel string
|
|
||||||
|
|
||||||
defaultChatID string
|
|
||||||
|
|
||||||
sentInRound bool // Tracks whether a message was sent in the current processing round
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMessageTool() *MessageTool {
|
func NewMessageTool() *MessageTool {
|
||||||
|
|
@ -34,18 +30,15 @@ func (t *MessageTool) Parameters() map[string]any {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"content": map[string]any{
|
"content": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
||||||
"description": "The message content to send",
|
"description": "The message content to send",
|
||||||
},
|
},
|
||||||
"channel": map[string]any{
|
"channel": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
||||||
"description": "Optional: target channel (telegram, whatsapp, etc.)",
|
"description": "Optional: target channel (telegram, whatsapp, etc.)",
|
||||||
},
|
},
|
||||||
"chat_id": map[string]any{
|
"chat_id": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
||||||
"description": "Optional: target chat/user ID",
|
"description": "Optional: target chat/user ID",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -53,17 +46,15 @@ func (t *MessageTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *MessageTool) SetContext(channel, chatID string) {
|
// ResetSentInRound resets the per-round send tracker.
|
||||||
t.defaultChannel = channel
|
// Called by the agent loop at the start of each inbound message processing round.
|
||||||
|
func (t *MessageTool) ResetSentInRound() {
|
||||||
t.defaultChatID = chatID
|
t.sentInRound.Store(false)
|
||||||
|
|
||||||
t.sentInRound = false // Reset send tracking for new processing round
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasSentInRound returns true if the message tool sent a message during the current round.
|
// HasSentInRound returns true if the message tool sent a message during the current round.
|
||||||
func (t *MessageTool) HasSentInRound() bool {
|
func (t *MessageTool) HasSentInRound() bool {
|
||||||
return t.sentInRound
|
return t.sentInRound.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *MessageTool) SetSendCallback(callback SendCallback) {
|
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)
|
chatID, _ := args["chat_id"].(string)
|
||||||
|
|
||||||
if channel == "" {
|
if channel == "" {
|
||||||
channel = t.defaultChannel
|
channel = ToolChannel(ctx)
|
||||||
}
|
}
|
||||||
if chatID == "" {
|
if chatID == "" {
|
||||||
chatID = t.defaultChatID
|
chatID = ToolChatID(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
if channel == "" || chatID == "" {
|
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 {
|
if err := t.sendCallback(channel, chatID, content); err != nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ForLLM: fmt.Sprintf("sending message: %v", err),
|
ForLLM: fmt.Sprintf("sending message: %v", err),
|
||||||
|
|
||||||
IsError: true,
|
IsError: true,
|
||||||
|
Err: err,
|
||||||
Err: err,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
t.sentInRound = true
|
t.sentInRound.Store(true)
|
||||||
|
|
||||||
// Silent: user already received the message directly
|
// Silent: user already received the message directly
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
|
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@ package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -14,20 +12,6 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"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 {
|
type ToolEntry struct {
|
||||||
Tool Tool
|
Tool Tool
|
||||||
IsCore bool
|
IsCore bool
|
||||||
|
|
@ -155,28 +139,15 @@ func (r *ToolRegistry) SnapshotHiddenTools() HiddenToolSnapshot {
|
||||||
func (r *ToolRegistry) Get(name string) (Tool, bool) {
|
func (r *ToolRegistry) Get(name string) (Tool, bool) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
entry, ok := r.tools[name]
|
||||||
// Exact match first
|
if !ok {
|
||||||
if entry, ok := r.tools[name]; ok {
|
return nil, false
|
||||||
// Hidden tools with expired TTL are not callable.
|
|
||||||
if !entry.IsCore && entry.TTL <= 0 {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return entry.Tool, true
|
|
||||||
}
|
}
|
||||||
|
// Hidden tools with expired TTL are not callable.
|
||||||
// Fork extension: fuzzy fallback — normalize and compare
|
if !entry.IsCore && entry.TTL <= 0 {
|
||||||
// (handles "readfile" → "read_file" etc.)
|
return nil, false
|
||||||
norm := NormalizeToolName(name)
|
|
||||||
for _, entry := range r.tools {
|
|
||||||
if entry.IsCore || entry.TTL > 0 {
|
|
||||||
if NormalizeToolName(entry.Tool.Name()) == norm {
|
|
||||||
return entry.Tool, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return entry.Tool, true
|
||||||
return nil, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult {
|
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.
|
// Always inject — tools validate what they require.
|
||||||
ctx = WithToolContext(ctx, channel, chatID)
|
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.
|
// If tool implements AsyncExecutor and callback is provided, use ExecuteAsync.
|
||||||
// The callback is a call parameter, not mutable state on the tool instance.
|
// The callback is a call parameter, not mutable state on the tool instance.
|
||||||
var result *ToolResult
|
var result *ToolResult
|
||||||
|
|
@ -228,14 +194,6 @@ func (r *ToolRegistry) ExecuteWithContext(
|
||||||
"tool": name,
|
"tool": name,
|
||||||
})
|
})
|
||||||
result = asyncExec.ExecuteAsync(ctx, args, asyncCallback)
|
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 {
|
} else {
|
||||||
result = tool.Execute(ctx, args)
|
result = tool.Execute(ctx, args)
|
||||||
}
|
}
|
||||||
|
|
@ -293,7 +251,7 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
definitions = append(definitions, ToolToSchema(entry.Tool))
|
definitions = append(definitions, ToolToSchema(r.tools[name].Tool))
|
||||||
}
|
}
|
||||||
return definitions
|
return definitions
|
||||||
}
|
}
|
||||||
|
|
@ -325,19 +283,12 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
|
||||||
desc, _ := fn["description"].(string)
|
desc, _ := fn["description"].(string)
|
||||||
params, _ := fn["parameters"].(map[string]any)
|
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{
|
definitions = append(definitions, providers.ToolDefinition{
|
||||||
Type: "function",
|
Type: "function",
|
||||||
Function: providers.ToolFunctionDefinition{
|
Function: providers.ToolFunctionDefinition{
|
||||||
Name: name,
|
Name: name,
|
||||||
Description: desc,
|
Description: desc,
|
||||||
Parameters: paramsRaw,
|
Parameters: params,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -374,8 +325,7 @@ func (r *ToolRegistry) GetSummaries() []string {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
hint := buildParamHint(entry.Tool.Parameters())
|
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", entry.Tool.Name(), entry.Tool.Description()))
|
||||||
summaries = append(summaries, fmt.Sprintf("- `%s`%s - %s", entry.Tool.Name(), hint, entry.Tool.Description()))
|
|
||||||
}
|
}
|
||||||
return summaries
|
return summaries
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,7 @@
|
||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -21,6 +18,7 @@ func terminateProcessTree(cmd *exec.Cmd) error {
|
||||||
if cmd == nil || cmd.Process == nil {
|
if cmd == nil || cmd.Process == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
pid := cmd.Process.Pid
|
pid := cmd.Process.Pid
|
||||||
if pid <= 0 {
|
if pid <= 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -28,58 +26,7 @@ func terminateProcessTree(cmd *exec.Cmd) error {
|
||||||
|
|
||||||
// Kill the entire process group spawned by the shell command.
|
// Kill the entire process group spawned by the shell command.
|
||||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
_ = 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.
|
// Fallback kill on the shell process itself.
|
||||||
_ = cmd.Process.Kill()
|
_ = cmd.Process.Kill()
|
||||||
return nil
|
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.
|
// so all registries configured in config are available for installation.
|
||||||
type InstallSkillTool struct {
|
type InstallSkillTool struct {
|
||||||
registryMgr *skills.RegistryManager
|
registryMgr *skills.RegistryManager
|
||||||
|
workspace string
|
||||||
workspace string
|
mu sync.Mutex
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInstallSkillTool creates a new InstallSkillTool.
|
// NewInstallSkillTool creates a new InstallSkillTool.
|
||||||
|
|
@ -32,10 +30,8 @@ type InstallSkillTool struct {
|
||||||
func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool {
|
func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool {
|
||||||
return &InstallSkillTool{
|
return &InstallSkillTool{
|
||||||
registryMgr: registryMgr,
|
registryMgr: registryMgr,
|
||||||
|
workspace: workspace,
|
||||||
workspace: workspace,
|
mu: sync.Mutex{},
|
||||||
|
|
||||||
mu: sync.Mutex{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,23 +48,19 @@ func (t *InstallSkillTool) Parameters() map[string]any {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"slug": 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')",
|
"description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')",
|
||||||
},
|
},
|
||||||
"version": map[string]any{
|
"version": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
||||||
"description": "Specific version to install (optional, defaults to latest)",
|
"description": "Specific version to install (optional, defaults to latest)",
|
||||||
},
|
},
|
||||||
"registry": map[string]any{
|
"registry": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
||||||
"description": "Registry to install from (required, e.g., 'clawhub')",
|
"description": "Registry to install from (required, e.g., 'clawhub')",
|
||||||
},
|
},
|
||||||
"force": map[string]any{
|
"force": map[string]any{
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
|
|
||||||
"description": "Force reinstall if skill already exists (default false)",
|
"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 {
|
if rmErr != nil {
|
||||||
logger.ErrorCF("tool", "Failed to remove partial install",
|
logger.ErrorCF("tool", "Failed to remove partial install",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tool": "install_skill",
|
"tool": "install_skill",
|
||||||
|
|
||||||
"target_dir": targetDir,
|
"target_dir": targetDir,
|
||||||
|
"error": rmErr.Error(),
|
||||||
"error": rmErr.Error(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err))
|
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 {
|
if rmErr != nil {
|
||||||
logger.ErrorCF("tool", "Failed to remove partial install",
|
logger.ErrorCF("tool", "Failed to remove partial install",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tool": "install_skill",
|
"tool": "install_skill",
|
||||||
|
|
||||||
"target_dir": targetDir,
|
"target_dir": targetDir,
|
||||||
|
"error": rmErr.Error(),
|
||||||
"error": rmErr.Error(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug))
|
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 {
|
if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil {
|
||||||
logger.ErrorCF("tool", "Failed to write origin metadata",
|
logger.ErrorCF("tool", "Failed to write origin metadata",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tool": "install_skill",
|
"tool": "install_skill",
|
||||||
|
"error": err.Error(),
|
||||||
"error": err.Error(),
|
"target": targetDir,
|
||||||
|
|
||||||
"target": targetDir,
|
|
||||||
|
|
||||||
"registry": registry.Name(),
|
"registry": registry.Name(),
|
||||||
|
"slug": slug,
|
||||||
"slug": slug,
|
"version": result.Version,
|
||||||
|
|
||||||
"version": result.Version,
|
|
||||||
})
|
})
|
||||||
_ = err
|
_ = 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.
|
// originMeta tracks which registry a skill was installed from.
|
||||||
type originMeta struct {
|
type originMeta struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
|
Registry string `json:"registry"`
|
||||||
Registry string `json:"registry"`
|
Slug string `json:"slug"`
|
||||||
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
|
|
||||||
InstalledVersion string `json:"installed_version"`
|
InstalledVersion string `json:"installed_version"`
|
||||||
|
InstalledAt int64 `json:"installed_at"`
|
||||||
InstalledAt int64 `json:"installed_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeOriginMeta(targetDir, registryName, slug, version string) error {
|
func writeOriginMeta(targetDir, registryName, slug, version string) error {
|
||||||
meta := originMeta{
|
meta := originMeta{
|
||||||
Version: 1,
|
Version: 1,
|
||||||
|
Registry: registryName,
|
||||||
Registry: registryName,
|
Slug: slug,
|
||||||
|
|
||||||
Slug: slug,
|
|
||||||
|
|
||||||
InstalledVersion: version,
|
InstalledVersion: version,
|
||||||
|
InstalledAt: time.Now().UnixMilli(),
|
||||||
InstalledAt: time.Now().UnixMilli(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.MarshalIndent(meta, "", " ")
|
data, err := json.MarshalIndent(meta, "", " ")
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@ import (
|
||||||
// FindSkillsTool allows the LLM agent to search for installable skills from registries.
|
// FindSkillsTool allows the LLM agent to search for installable skills from registries.
|
||||||
type FindSkillsTool struct {
|
type FindSkillsTool struct {
|
||||||
registryMgr *skills.RegistryManager
|
registryMgr *skills.RegistryManager
|
||||||
|
cache *skills.SearchCache
|
||||||
cache *skills.SearchCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFindSkillsTool creates a new FindSkillsTool.
|
// NewFindSkillsTool creates a new FindSkillsTool.
|
||||||
|
|
@ -21,8 +20,7 @@ type FindSkillsTool struct {
|
||||||
func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool {
|
func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool {
|
||||||
return &FindSkillsTool{
|
return &FindSkillsTool{
|
||||||
registryMgr: registryMgr,
|
registryMgr: registryMgr,
|
||||||
|
cache: cache,
|
||||||
cache: cache,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,18 +37,14 @@ func (t *FindSkillsTool) Parameters() map[string]any {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"query": 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')",
|
"description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')",
|
||||||
},
|
},
|
||||||
"limit": map[string]any{
|
"limit": map[string]any{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
|
|
||||||
"description": "Maximum number of results to return (1-20, default 5)",
|
"description": "Maximum number of results to return (1-20, default 5)",
|
||||||
|
"minimum": 1.0,
|
||||||
"minimum": 1.0,
|
"maximum": 20.0,
|
||||||
|
|
||||||
"maximum": 20.0,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": []string{"query"},
|
"required": []string{"query"},
|
||||||
|
|
|
||||||
|
|
@ -29,47 +29,37 @@ func (t *SPITool) Parameters() map[string]any {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"action": map[string]any{
|
"action": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
"enum": []string{"list", "transfer", "read"},
|
||||||
"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)",
|
"description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)",
|
||||||
},
|
},
|
||||||
"device": map[string]any{
|
"device": map[string]any{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
||||||
"description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.",
|
"description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.",
|
||||||
},
|
},
|
||||||
"speed": map[string]any{
|
"speed": map[string]any{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
|
|
||||||
"description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).",
|
"description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).",
|
||||||
},
|
},
|
||||||
"mode": map[string]any{
|
"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.",
|
"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{
|
"bits": map[string]any{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
|
|
||||||
"description": "Bits per word. Default: 8.",
|
"description": "Bits per word. Default: 8.",
|
||||||
},
|
},
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
"items": map[string]any{"type": "integer"},
|
||||||
"items": map[string]any{"type": "integer"},
|
|
||||||
|
|
||||||
"description": "Bytes to send (0-255 each). Required for transfer action.",
|
"description": "Bytes to send (0-255 each). Required for transfer action.",
|
||||||
},
|
},
|
||||||
"length": map[string]any{
|
"length": map[string]any{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
|
|
||||||
"description": "Number of bytes to read (1-4096). Required for read action.",
|
"description": "Number of bytes to read (1-4096). Required for read action.",
|
||||||
},
|
},
|
||||||
"confirm": map[string]any{
|
"confirm": map[string]any{
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
|
|
||||||
"description": "Must be true for transfer operations. Safety guard to prevent accidental writes.",
|
"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 {
|
type devInfo struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
|
||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,8 @@ type spiTransfer struct {
|
||||||
pad uint8
|
pad uint8
|
||||||
}
|
}
|
||||||
|
|
||||||
// configureSPI opens an SPI device and sets mode, bits per word, and speed.
|
// configureSPI opens an SPI device and sets mode, bits per word, and speed
|
||||||
func configureSPI(
|
func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) {
|
||||||
devPath string, mode uint8, bits uint8, speed uint32,
|
|
||||||
) (int, *ToolResult) {
|
|
||||||
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
|
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1, ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err))
|
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
|
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 {
|
func (t *SPITool) transfer(args map[string]any) *ToolResult {
|
||||||
confirm, _ := args["confirm"].(bool)
|
confirm, _ := args["confirm"].(bool)
|
||||||
if !confirm {
|
if !confirm {
|
||||||
return ErrorResult(
|
return ErrorResult(
|
||||||
"transfer operations require confirm: true." +
|
"transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.",
|
||||||
" 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)
|
defer syscall.Close(fd)
|
||||||
|
|
||||||
rxBuf := make([]byte, len(txBuf))
|
rxBuf := make([]byte, len(txBuf))
|
||||||
|
|
||||||
xfer := spiTransfer{
|
xfer := spiTransfer{
|
||||||
txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))),
|
txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))),
|
||||||
rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[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))
|
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 {
|
func (t *SPITool) readDevice(args map[string]any) *ToolResult {
|
||||||
dev, speed, mode, bits, errMsg := parseSPIArgs(args)
|
dev, speed, mode, bits, errMsg := parseSPIArgs(args)
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
|
|
@ -167,6 +165,7 @@ func (t *SPITool) readDevice(args map[string]any) *ToolResult {
|
||||||
|
|
||||||
txBuf := make([]byte, length) // zeros
|
txBuf := make([]byte, length) // zeros
|
||||||
rxBuf := make([]byte, length)
|
rxBuf := make([]byte, length)
|
||||||
|
|
||||||
xfer := spiTransfer{
|
xfer := spiTransfer{
|
||||||
txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))),
|
txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))),
|
||||||
rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))),
|
rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue