refactor(config)!: simplify configuration and remove unused providers

- Remove ShengSuanYun and Moonshot providers, add Ollama web tool support
- Remove FlexibleStringSlice type and RestrictToWorkspace config option
- Refactor AgentState replacing state.Manager with simpler persistence
- Remove proxy support from HTTPProvider and Telegram channel
- Remove Feishu channel initialization
- Remove complex subagent/session state handling
- Update default model to glm-5:cloud with 198000 tokens
- Increase default heartbeat interval to 300s
- Simplify tool registration and web search/fetch tools

BREAKING CHANGE: Removed Moonshot and ShengSuanYun provider support.
Removed RestrictToWorkspace config option (now always restricted).
Removed proxy support in HTTP provider and Telegram channel.
This commit is contained in:
developer3000S 2026-02-13 23:15:28 +03:00
parent 45351a6a79
commit 18524a1427
14 changed files with 821 additions and 692 deletions

17
.env Normal file
View file

@ -0,0 +1,17 @@
# ── LLM Provider ──────────────────────────
# Uncomment and set the API key for your provider
# OPENROUTER_API_KEY=sk-or-v1-xxx
# ZHIPU_API_KEY=xxx
# ANTHROPIC_API_KEY=sk-ant-xxx
# OPENAI_API_KEY=sk-xxx
# GEMINI_API_KEY=xxx
# ── Chat Channel ──────────────────────────
# TELEGRAM_BOT_TOKEN=123456:ABC...
# DISCORD_BOT_TOKEN=xxx
# ── Web Search (optional) ────────────────
# BRAVE_SEARCH_API_KEY=BSA...
# ── Timezone ──────────────────────────────
TZ=Asia/Tokyo

14
.gitignore vendored
View file

@ -1,7 +1,5 @@
# Binaries
# Go build artifacts
bin/
build/
*.exe
*.dll
*.so
@ -10,22 +8,14 @@ build/
*.out
/picoclaw
/picoclaw-test
/docs
# Picoclaw specific
# PicoClaw
.picoclaw/
config.json
sessions/
build/
# Coverage
# Secrets & Config (keep templates, ignore actual secrets)
.env
config/config.json
# Test
coverage.txt
coverage.html
@ -34,5 +24,3 @@ coverage.html
# Ralph workspace
ralph/
.ralph/
tasks/

View file

@ -592,7 +592,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print(fmt.Sprintf("%s You: ", logo))
fmt.Printf("%s You: ", logo)
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {

View file

@ -2,9 +2,8 @@
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
"model": "glm-4.7",
"max_tokens": 8192,
"model": "glm-5:cloud",
"max_tokens": 198000,
"temperature": 0.7,
"max_tool_iterations": 20
}
@ -12,9 +11,10 @@
"channels": {
"telegram": {
"enabled": false,
"token": "YOUR_TELEGRAM_BOT_TOKEN",
"proxy": "",
"allow_from": ["YOUR_USER_ID"]
"token": "7902424029:AAFXa7EDs13wetH2v24RvsiGDfdH7QCH4ZY",
"allow_from": [
"5352726595"
]
},
"discord": {
"enabled": false,
@ -78,34 +78,44 @@
"api_key": "",
"api_base": ""
},
"vllm": {
"api_key": "",
"api_base": ""
},
"nvidia": {
"api_key": "nvapi-xxx",
"api_base": "",
"proxy": "http://127.0.0.1:7890"
"api_base": ""
},
"moonshot": {
"api_key": "sk-xxx",
"ollama": {
"api_key": "",
"api_base": "http://localhost:11434"
},
"vllm": {
"api_key": "",
"api_base": ""
}
},
"tools": {
"web": {
"search": {
"api_key": "YOUR_BRAVE_API_KEY",
"api_key": "BSA0KawT1keaYZ4qPQBv-jib1kzo1JP",
"max_results": 5
}
}
},
"heartbeat": {
"enabled": true,
"interval": 30
"brave": {
"api_key": "BSA0KawT1keaYZ4qPQBv-jib1kzo1JP",
"max_results": 5,
"enabled": false
},
"duckduckgo": {
"api_key": "",
"max_results": 5,
"enabled": false
}
}
},
"gateway": {
"host": "0.0.0.0",
"port": 18790
},
"heartbeat": {
"interval": 300,
"enabled": true
}
}

13
config/config.go Normal file
View file

@ -0,0 +1,13 @@
package config
type Config struct {
workspacePath string `json:"workspace_path"`
Heartbeat struct {
Interval int `json:"interval"`
Enabled bool `json:"enabled"`
} `json:"heartbeat"`
}
func (c *Config) WorkspacePath() string {
return c.workspacePath
}

View file

@ -234,25 +234,6 @@ func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, cont
return messages
}
func (cb *ContextBuilder) loadSkills() string {
allSkills := cb.skillsLoader.ListSkills()
if len(allSkills) == 0 {
return ""
}
var skillNames []string
for _, s := range allSkills {
skillNames = append(skillNames, s.Name)
}
content := cb.skillsLoader.LoadSkillsForContext(skillNames)
if content == "" {
return ""
}
return "# Skill Definitions\n\n" + content
}
// GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
allSkills := cb.skillsLoader.ListSkills()

View file

@ -19,15 +19,79 @@ import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
)
// AgentState manages persistent state for the agent (last channel, chat ID, etc.)
type AgentState struct {
mu sync.RWMutex
lastChannel string
lastChatID string
stateDir string
}
// NewAgentState creates a new agent state manager
func NewAgentState(workspace string) *AgentState {
stateDir := filepath.Join(workspace, "state")
os.MkdirAll(stateDir, 0755)
state := &AgentState{
stateDir: stateDir,
}
state.load()
return state
}
// load loads state from disk
func (s *AgentState) load() {
s.mu.Lock()
defer s.mu.Unlock()
// Load last channel
channelFile := filepath.Join(s.stateDir, "last_channel.txt")
if data, err := os.ReadFile(channelFile); err == nil {
s.lastChannel = strings.TrimSpace(string(data))
}
// Load last chat ID
chatIDFile := filepath.Join(s.stateDir, "last_chat_id.txt")
if data, err := os.ReadFile(chatIDFile); err == nil {
s.lastChatID = strings.TrimSpace(string(data))
}
}
// save saves state to disk
func (s *AgentState) save() {
s.mu.Lock()
defer s.mu.Unlock()
// Save last channel
channelFile := filepath.Join(s.stateDir, "last_channel.txt")
os.WriteFile(channelFile, []byte(s.lastChannel), 0644)
// Save last chat ID
chatIDFile := filepath.Join(s.stateDir, "last_chat_id.txt")
os.WriteFile(chatIDFile, []byte(s.lastChatID), 0644)
}
// GetLastChannel returns the last used channel
func (s *AgentState) GetLastChannel() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.lastChannel
}
// GetLastChatID returns the last used chat ID
func (s *AgentState) GetLastChatID() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.lastChatID
}
type AgentLoop struct {
bus *bus.MessageBus
provider providers.LLMProvider
@ -36,11 +100,11 @@ type AgentLoop struct {
contextWindow int // Maximum context window size in tokens
maxIterations int
sessions *session.SessionManager
state *state.Manager
contextBuilder *ContextBuilder
tools *tools.ToolRegistry
running atomic.Bool
summarizing sync.Map // Tracks which sessions are currently being summarized
state *AgentState // Persistent state manager
}
// processOptions configures how a message is processed
@ -52,37 +116,29 @@ type processOptions struct {
DefaultResponse string // Response when LLM returns empty
EnableSummary bool // Whether to trigger summarization
SendResponse bool // Whether to send response via bus
NoHistory bool // If true, don't load session history (for heartbeat)
}
// createToolRegistry creates a tool registry with common tools.
// This is shared between main agent and subagents.
func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msgBus *bus.MessageBus) *tools.ToolRegistry {
registry := tools.NewToolRegistry()
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
workspace := cfg.WorkspacePath()
os.MkdirAll(workspace, 0755)
// File system tools
registry.Register(tools.NewReadFileTool(workspace, restrict))
registry.Register(tools.NewWriteFileTool(workspace, restrict))
registry.Register(tools.NewListDirTool(workspace, restrict))
registry.Register(tools.NewEditFileTool(workspace, restrict))
registry.Register(tools.NewAppendFileTool(workspace, restrict))
toolsRegistry := tools.NewToolRegistry()
toolsRegistry.Register(&tools.ReadFileTool{})
toolsRegistry.Register(&tools.WriteFileTool{})
toolsRegistry.Register(&tools.ListDirTool{})
toolsRegistry.Register(tools.NewExecTool(workspace, true))
// Shell execution
registry.Register(tools.NewExecTool(workspace, restrict))
if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
}); searchTool != nil {
registry.Register(searchTool)
ollamaAPIKey := cfg.Tools.Web.Ollama.APIKey
if ollamaAPIKey != "" {
toolsRegistry.Register(tools.NewOllamaSearchTool(ollamaAPIKey, cfg.Tools.Web.Ollama.MaxResults))
toolsRegistry.Register(tools.NewOllamaFetchTool(ollamaAPIKey, 50000))
} else {
braveAPIKey := cfg.Tools.Web.Search.APIKey
toolsRegistry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults))
toolsRegistry.Register(tools.NewWebFetchTool(50000))
}
registry.Register(tools.NewWebFetchTool(50000))
// Message tool - available to both agent and subagent
// Subagent uses it to communicate directly with user
// Register message tool
messageTool := tools.NewMessageTool()
messageTool.SetSendCallback(func(channel, chatID, content string) error {
msgBus.PublishOutbound(bus.OutboundMessage{
@ -92,43 +148,26 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
})
return nil
})
registry.Register(messageTool)
toolsRegistry.Register(messageTool)
return registry
}
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
workspace := cfg.WorkspacePath()
os.MkdirAll(workspace, 0755)
restrict := cfg.Agents.Defaults.RestrictToWorkspace
// Create tool registry for main agent
toolsRegistry := createToolRegistry(workspace, restrict, cfg, msgBus)
// Create subagent manager with its own tool registry
// Register spawn tool
subagentManager := tools.NewSubagentManager(provider, cfg.Agents.Defaults.Model, workspace, msgBus)
subagentTools := createToolRegistry(workspace, restrict, cfg, msgBus)
// Subagent doesn't need spawn/subagent tools to avoid recursion
subagentManager.SetTools(subagentTools)
// Register spawn tool (for main agent)
spawnTool := tools.NewSpawnTool(subagentManager)
toolsRegistry.Register(spawnTool)
// Register subagent tool (synchronous execution)
subagentTool := tools.NewSubagentTool(subagentManager)
toolsRegistry.Register(subagentTool)
// Register edit file tool
editFileTool := tools.NewEditFileTool(workspace, true)
toolsRegistry.Register(editFileTool)
sessionsManager := session.NewSessionManager(filepath.Join(workspace, "sessions"))
// Create state manager for atomic state persistence
stateManager := state.NewManager(workspace)
// Create context builder and set tools registry
contextBuilder := NewContextBuilder(workspace)
contextBuilder.SetToolsRegistry(toolsRegistry)
// Create state manager
stateManager := NewAgentState(workspace)
return &AgentLoop{
bus: msgBus,
provider: provider,
@ -137,10 +176,10 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
contextWindow: cfg.Agents.Defaults.MaxTokens, // Restore context window for summarization
maxIterations: cfg.Agents.Defaults.MaxToolIterations,
sessions: sessionsManager,
state: stateManager,
contextBuilder: contextBuilder,
tools: toolsRegistry,
summarizing: sync.Map{},
state: stateManager,
}
}
@ -163,16 +202,6 @@ func (al *AgentLoop) Run(ctx context.Context) error {
}
if response != "" {
// Check if the message tool already sent a response during this round.
// If so, skip publishing to avoid duplicate messages to the user.
alreadySent := false
if tool, ok := al.tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
alreadySent = mt.HasSentInRound()
}
}
if !alreadySent {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
@ -181,7 +210,6 @@ func (al *AgentLoop) Run(ctx context.Context) error {
}
}
}
}
return nil
}
@ -194,16 +222,46 @@ func (al *AgentLoop) RegisterTool(tool tools.Tool) {
al.tools.Register(tool)
}
// RecordLastChannel records the last active channel for this workspace.
// This uses the atomic state save mechanism to prevent data loss on crash.
// RecordLastChannel records the last used channel
func (al *AgentLoop) RecordLastChannel(channel string) error {
return al.state.SetLastChannel(channel)
al.state.mu.Lock()
al.state.lastChannel = channel
al.state.mu.Unlock()
al.state.save()
return nil
}
// RecordLastChatID records the last active chat ID for this workspace.
// This uses the atomic state save mechanism to prevent data loss on crash.
// RecordLastChatID records the last used chat ID
func (al *AgentLoop) RecordLastChatID(chatID string) error {
return al.state.SetLastChatID(chatID)
al.state.mu.Lock()
al.state.lastChatID = chatID
al.state.mu.Unlock()
al.state.save()
return nil
}
// ProcessHeartbeat processes a heartbeat prompt without using session history
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, prompt, channel, chatID string) (string, error) {
// Record the channel and chat ID for future use
_ = al.RecordLastChannel(channel)
_ = al.RecordLastChatID(chatID)
// Use processOptions with no history and no summarization
response, err := al.runAgentLoop(ctx, processOptions{
SessionKey: "heartbeat:direct",
Channel: channel,
ChatID: chatID,
UserMessage: prompt,
DefaultResponse: "HEARTBEAT_OK",
EnableSummary: false,
SendResponse: false,
})
if err != nil {
return "", err
}
return response, nil
}
func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) {
@ -222,30 +280,10 @@ func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sess
return al.processMessage(ctx, msg)
}
// ProcessHeartbeat processes a heartbeat request without session history.
// Each heartbeat is independent and doesn't accumulate context.
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
return al.runAgentLoop(ctx, processOptions{
SessionKey: "heartbeat",
Channel: channel,
ChatID: chatID,
UserMessage: content,
DefaultResponse: "I've completed processing but have no response to give.",
EnableSummary: false,
SendResponse: false,
NoHistory: true, // Don't load session history for heartbeat
})
}
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
// Add message preview to log (show full content for error messages)
var logContent string
if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") {
logContent = msg.Content // Full content for errors
} else {
logContent = utils.Truncate(msg.Content, 80)
}
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
// Add message preview to log
preview := utils.Truncate(msg.Content, 80)
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, preview),
map[string]interface{}{
"channel": msg.Channel,
"chat_id": msg.ChatID,
@ -282,70 +320,41 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
"chat_id": msg.ChatID,
})
// Parse origin channel from chat_id (format: "channel:chat_id")
var originChannel string
// Parse origin from chat_id (format: "channel:chat_id")
var originChannel, originChatID string
if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
originChannel = msg.ChatID[:idx]
originChatID = msg.ChatID[idx+1:]
} else {
// Fallback
originChannel = "cli"
originChatID = msg.ChatID
}
// Extract subagent result from message content
// Format: "Task 'label' completed.\n\nResult:\n<actual content>"
content := msg.Content
if idx := strings.Index(content, "Result:\n"); idx >= 0 {
content = content[idx+8:] // Extract just the result part
}
// Use the origin session for context
sessionKey := fmt.Sprintf("%s:%s", originChannel, originChatID)
// Skip internal channels - only log, don't send to user
if constants.IsInternalChannel(originChannel) {
logger.InfoCF("agent", "Subagent completed (internal channel)",
map[string]interface{}{
"sender_id": msg.SenderID,
"content_len": len(content),
"channel": originChannel,
// Process as system message with routing back to origin
return al.runAgentLoop(ctx, processOptions{
SessionKey: sessionKey,
Channel: originChannel,
ChatID: originChatID,
UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content),
DefaultResponse: "Background task completed.",
EnableSummary: false,
SendResponse: true, // Send response back to original channel
})
return "", nil
}
// Agent acts as dispatcher only - subagent handles user interaction via message tool
// Don't forward result here, subagent should use message tool to communicate with user
logger.InfoCF("agent", "Subagent completed",
map[string]interface{}{
"sender_id": msg.SenderID,
"channel": originChannel,
"content_len": len(content),
})
// Agent only logs, does not respond to user
return "", nil
}
// runAgentLoop is the core message processing logic.
// It handles context building, LLM calls, tool execution, and response handling.
func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (string, error) {
// 0. Record last channel for heartbeat notifications (skip internal channels)
if opts.Channel != "" && opts.ChatID != "" {
// Don't record internal channels (cli, system, subagent)
if !constants.IsInternalChannel(opts.Channel) {
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
if err := al.RecordLastChannel(channelKey); err != nil {
logger.WarnCF("agent", "Failed to record last channel: %v", map[string]interface{}{"error": err.Error()})
}
}
}
// 1. Update tool contexts
al.updateToolContexts(opts.Channel, opts.ChatID)
// 2. Build messages (skip history for heartbeat)
var history []providers.Message
var summary string
if !opts.NoHistory {
history = al.sessions.GetHistory(opts.SessionKey)
summary = al.sessions.GetSummary(opts.SessionKey)
}
// 2. Build messages
history := al.sessions.GetHistory(opts.SessionKey)
summary := al.sessions.GetSummary(opts.SessionKey)
messages := al.contextBuilder.BuildMessages(
history,
summary,
@ -364,9 +373,6 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
return "", err
}
// If last tool had ForUser content and we already sent it, we might not need to send final response
// This is controlled by the tool's Silent flag and ForUser content
// 5. Handle empty response
if finalContent == "" {
finalContent = opts.DefaultResponse
@ -418,7 +424,18 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
})
// Build tool definitions
providerToolDefs := al.tools.ToProviderDefs()
toolDefs := al.tools.GetDefinitions()
providerToolDefs := make([]providers.ToolDefinition, 0, len(toolDefs))
for _, td := range toolDefs {
providerToolDefs = append(providerToolDefs, providers.ToolDefinition{
Type: td["type"].(string),
Function: providers.ToolFunctionDefinition{
Name: td["function"].(map[string]interface{})["name"].(string),
Description: td["function"].(map[string]interface{})["description"].(string),
Parameters: td["function"].(map[string]interface{})["parameters"].(map[string]interface{}),
},
})
}
// Log LLM request details
logger.DebugCF("agent", "LLM request",
@ -474,7 +491,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
logger.InfoCF("agent", "LLM requested tool calls",
map[string]interface{}{
"tools": toolNames,
"count": len(response.ToolCalls),
"count": len(toolNames),
"iteration": iteration,
})
@ -510,47 +527,14 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
"iteration": iteration,
})
// Create async callback for tools that implement AsyncTool
// NOTE: Following openclaw's design, async tools do NOT send results directly to users.
// Instead, they notify the agent via PublishInbound, and the agent decides
// whether to forward the result to the user (in processSystemMessage).
asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
// Log the async completion but don't send directly to user
// The agent will handle user notification via processSystemMessage
if !result.Silent && result.ForUser != "" {
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
map[string]interface{}{
"tool": tc.Name,
"content_len": len(result.ForUser),
})
}
}
toolResult := al.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
// Send ForUser content to user immediately if not Silent
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: toolResult.ForUser,
})
logger.DebugCF("agent", "Sent tool result to user",
map[string]interface{}{
"tool": tc.Name,
"content_len": len(toolResult.ForUser),
})
}
// Determine content for LLM based on tool result
contentForLLM := toolResult.ForLLM
if contentForLLM == "" && toolResult.Err != nil {
contentForLLM = toolResult.Err.Error()
result := al.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, nil)
if result.Err != nil {
result = tools.ErrorResult(fmt.Sprintf("Error: %v", result.Err))
}
toolResultMsg := providers.Message{
Role: "tool",
Content: contentForLLM,
Content: result.ForLLM,
ToolCallID: tc.ID,
}
messages = append(messages, toolResultMsg)
@ -565,19 +549,13 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
// updateToolContexts updates the context for tools that need channel/chatID info.
func (al *AgentLoop) updateToolContexts(channel, chatID string) {
// Use ContextualTool interface instead of type assertions
if tool, ok := al.tools.Get("message"); ok {
if mt, ok := tool.(tools.ContextualTool); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
mt.SetContext(channel, chatID)
}
}
if tool, ok := al.tools.Get("spawn"); ok {
if st, ok := tool.(tools.ContextualTool); ok {
st.SetContext(channel, chatID)
}
}
if tool, ok := al.tools.Get("subagent"); ok {
if st, ok := tool.(tools.ContextualTool); ok {
if st, ok := tool.(*tools.SpawnTool); ok {
st.SetContext(channel, chatID)
}
}
@ -626,7 +604,7 @@ func formatMessagesForLog(messages []providers.Message) string {
result += "[\n"
for i, msg := range messages {
result += fmt.Sprintf(" [%d] Role: %s\n", i, msg.Role)
if msg.ToolCalls != nil && len(msg.ToolCalls) > 0 {
if len(msg.ToolCalls) > 0 {
result += " ToolCalls:\n"
for _, tc := range msg.ToolCalls {
result += fmt.Sprintf(" - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)

View file

@ -13,7 +13,6 @@ import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
)
@ -72,18 +71,6 @@ func (m *Manager) initChannels() error {
}
}
if m.config.Channels.Feishu.Enabled {
logger.DebugC("channels", "Attempting to initialize Feishu channel")
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{
"error": err.Error(),
})
} else {
m.channels["feishu"] = feishu
logger.InfoC("channels", "Feishu channel enabled successfully")
}
}
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
logger.DebugC("channels", "Attempting to initialize Discord channel")
@ -230,11 +217,6 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
continue
}
// Silently skip internal channels
if constants.IsInternalChannel(msg.Channel) {
continue
}
m.mu.RLock()
channel, exists := m.channels[msg.Channel]
m.mu.RUnlock()

View file

@ -3,8 +3,7 @@ package channels
import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"strings"
@ -44,17 +43,7 @@ func (c *thinkingCancel) Cancel() {
func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) {
var opts []telego.BotOption
if cfg.Proxy != "" {
proxyURL, parseErr := url.Parse(cfg.Proxy)
if parseErr != nil {
return nil, fmt.Errorf("invalid proxy URL %q: %w", cfg.Proxy, parseErr)
}
opts = append(opts, telego.WithHTTPClient(&http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
},
}))
}
bot, err := telego.NewBot(cfg.Token, opts...)
if err != nil {

View file

@ -2,7 +2,6 @@ package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
@ -10,39 +9,6 @@ import (
"github.com/caarlos0/env/v11"
)
// FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123.
type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
// Try []string first
var ss []string
if err := json.Unmarshal(data, &ss); err == nil {
*f = ss
return nil
}
// Try []interface{} to handle mixed types
var raw []interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
result := make([]string, 0, len(raw))
for _, v := range raw {
switch val := v.(type) {
case string:
result = append(result, val)
case float64:
result = append(result, fmt.Sprintf("%.0f", val))
default:
result = append(result, fmt.Sprintf("%v", val))
}
}
*f = result
return nil
}
type Config struct {
Agents AgentsConfig `json:"agents"`
Channels ChannelsConfig `json:"channels"`
@ -53,13 +19,18 @@ type Config struct {
mu sync.RWMutex
}
// HeartbeatConfig defines heartbeat settings
type HeartbeatConfig struct {
Interval int `json:"interval"` // Interval in seconds
Enabled bool `json:"enabled"`
}
type AgentsConfig struct {
Defaults AgentDefaults `json:"defaults"`
}
type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
@ -81,14 +52,13 @@ type ChannelsConfig struct {
type WhatsAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
}
type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
}
type FeishuConfig struct {
@ -97,34 +67,34 @@ type FeishuConfig struct {
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
}
type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
}
type MaixCamConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
}
type QQConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
}
type DingTalkConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
}
type SlackConfig struct {
@ -134,11 +104,6 @@ type SlackConfig struct {
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
}
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
}
type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"`
OpenAI ProviderConfig `json:"openai"`
@ -148,14 +113,11 @@ type ProvidersConfig struct {
VLLM ProviderConfig `json:"vllm"`
Gemini ProviderConfig `json:"gemini"`
Nvidia ProviderConfig `json:"nvidia"`
Moonshot ProviderConfig `json:"moonshot"`
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
}
type ProviderConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
}
@ -164,22 +126,35 @@ type GatewayConfig struct {
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
}
type WebSearchConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_SEARCH_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARCH_MAX_RESULTS"`
}
type BraveConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
}
type DuckDuckGoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
}
type WebToolsConfig struct {
Search WebSearchConfig `json:"search"`
Ollama OllamaConfig `json:"ollama"`
Brave BraveConfig `json:"brave"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
}
type OllamaConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_OLLAMA_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_OLLAMA_MAX_RESULTS"`
}
type ToolsConfig struct {
Web WebToolsConfig `json:"web"`
}
@ -189,7 +164,6 @@ func DefaultConfig() *Config {
Agents: AgentsConfig{
Defaults: AgentDefaults{
Workspace: "~/.picoclaw/workspace",
RestrictToWorkspace: true,
Provider: "",
Model: "glm-4.7",
MaxTokens: 8192,
@ -201,12 +175,12 @@ func DefaultConfig() *Config {
WhatsApp: WhatsAppConfig{
Enabled: false,
BridgeURL: "ws://localhost:3001",
AllowFrom: FlexibleStringSlice{},
AllowFrom: []string{},
},
Telegram: TelegramConfig{
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
AllowFrom: []string{},
},
Feishu: FeishuConfig{
Enabled: false,
@ -214,30 +188,30 @@ func DefaultConfig() *Config {
AppSecret: "",
EncryptKey: "",
VerificationToken: "",
AllowFrom: FlexibleStringSlice{},
AllowFrom: []string{},
},
Discord: DiscordConfig{
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
AllowFrom: []string{},
},
MaixCam: MaixCamConfig{
Enabled: false,
Host: "0.0.0.0",
Port: 18790,
AllowFrom: FlexibleStringSlice{},
AllowFrom: []string{},
},
QQ: QQConfig{
Enabled: false,
AppID: "",
AppSecret: "",
AllowFrom: FlexibleStringSlice{},
AllowFrom: []string{},
},
DingTalk: DingTalkConfig{
Enabled: false,
ClientID: "",
ClientSecret: "",
AllowFrom: FlexibleStringSlice{},
AllowFrom: []string{},
},
Slack: SlackConfig{
Enabled: false,
@ -255,8 +229,6 @@ func DefaultConfig() *Config {
VLLM: ProviderConfig{},
Gemini: ProviderConfig{},
Nvidia: ProviderConfig{},
Moonshot: ProviderConfig{},
ShengSuanYun: ProviderConfig{},
},
Gateway: GatewayConfig{
Host: "0.0.0.0",
@ -264,20 +236,29 @@ func DefaultConfig() *Config {
},
Tools: ToolsConfig{
Web: WebToolsConfig{
Brave: BraveConfig{
Enabled: false,
Search: WebSearchConfig{
APIKey: "",
MaxResults: 5,
},
DuckDuckGo: DuckDuckGoConfig{
Enabled: true,
Ollama: OllamaConfig{
APIKey: "",
MaxResults: 5,
},
Brave: BraveConfig{
APIKey: "",
MaxResults: 5,
Enabled: false,
},
DuckDuckGo: DuckDuckGoConfig{
APIKey: "",
MaxResults: 5,
Enabled: false,
},
},
},
Heartbeat: HeartbeatConfig{
Interval: 300, // 5 minutes default
Enabled: true,
Interval: 30, // default 30 minutes
},
}
}
@ -351,8 +332,8 @@ func (c *Config) GetAPIKey() string {
if c.Providers.VLLM.APIKey != "" {
return c.Providers.VLLM.APIKey
}
if c.Providers.ShengSuanYun.APIKey != "" {
return c.Providers.ShengSuanYun.APIKey
if c.Providers.Nvidia.APIKey != "" {
return c.Providers.Nvidia.APIKey
}
return ""
}
@ -372,6 +353,12 @@ func (c *Config) GetAPIBase() string {
if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" {
return c.Providers.VLLM.APIBase
}
if c.Providers.Nvidia.APIKey != "" {
if c.Providers.Nvidia.APIBase != "" {
return c.Providers.Nvidia.APIBase
}
return "https://integrate.api.nvidia.com/v1"
}
return ""
}

View file

@ -13,7 +13,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/sipeed/picoclaw/pkg/auth"
@ -26,24 +25,13 @@ type HTTPProvider struct {
httpClient *http.Client
}
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
client := &http.Client{
Timeout: 0,
}
if proxy != "" {
proxyURL, err := url.Parse(proxy)
if err == nil {
client.Transport = &http.Transport{
Proxy: http.ProxyURL(proxyURL),
}
}
}
func NewHTTPProvider(apiKey, apiBase string) *HTTPProvider {
return &HTTPProvider{
apiKey: apiKey,
apiBase: strings.TrimRight(apiBase, "/"),
httpClient: client,
apiBase: apiBase,
httpClient: &http.Client{
Timeout: 0,
},
}
}
@ -52,14 +40,6 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
return nil, fmt.Errorf("API base not configured")
}
// Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5)
if idx := strings.Index(model, "/"); idx != -1 {
prefix := model[:idx]
if prefix == "moonshot" || prefix == "nvidia" {
model = model[idx+1:]
}
}
requestBody := map[string]interface{}{
"model": model,
"messages": messages,
@ -80,13 +60,15 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
}
if temperature, ok := options["temperature"].(float64); ok {
lowerModel := strings.ToLower(model)
// Kimi k2 models only support temperature=1
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
requestBody["temperature"] = 1.0
} else {
requestBody["temperature"] = temperature
}
// Add additional options (like chat_template_kwargs for Nvidia)
for k, v := range options {
if k == "max_tokens" || k == "temperature" || k == "model" || k == "messages" {
continue
}
requestBody[k] = v
}
jsonData, err := json.Marshal(requestBody)
@ -116,7 +98,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
return nil, fmt.Errorf("API error: %s", string(body))
}
return p.parseResponse(body)
@ -222,7 +204,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
model := cfg.Agents.Defaults.Model
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
var apiKey, apiBase, proxy string
var apiKey, apiBase string
lowerModel := strings.ToLower(model)
@ -289,37 +271,21 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
apiKey = cfg.Providers.VLLM.APIKey
apiBase = cfg.Providers.VLLM.APIBase
}
case "shengsuanyun":
if cfg.Providers.ShengSuanYun.APIKey != "" {
apiKey = cfg.Providers.ShengSuanYun.APIKey
apiBase = cfg.Providers.ShengSuanYun.APIBase
case "nvidia":
if cfg.Providers.Nvidia.APIKey != "" {
apiKey = cfg.Providers.Nvidia.APIKey
apiBase = cfg.Providers.Nvidia.APIBase
if apiBase == "" {
apiBase = "https://router.shengsuanyun.com/api/v1"
apiBase = "https://integrate.api.nvidia.com/v1"
}
}
case "claude-cli", "claudecode", "claude-code":
workspace := cfg.Agents.Defaults.Workspace
if workspace == "" {
workspace = "."
}
return NewClaudeCliProvider(workspace), nil
}
}
// Fallback: detect provider from model name
if apiKey == "" && apiBase == "" {
switch {
case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
apiKey = cfg.Providers.Moonshot.APIKey
apiBase = cfg.Providers.Moonshot.APIBase
proxy = cfg.Providers.Moonshot.Proxy
if apiBase == "" {
apiBase = "https://api.moonshot.cn/v1"
}
case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"):
switch { case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"):
apiKey = cfg.Providers.OpenRouter.APIKey
proxy = cfg.Providers.OpenRouter.Proxy
if cfg.Providers.OpenRouter.APIBase != "" {
apiBase = cfg.Providers.OpenRouter.APIBase
} else {
@ -332,7 +298,6 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
}
apiKey = cfg.Providers.Anthropic.APIKey
apiBase = cfg.Providers.Anthropic.APIBase
proxy = cfg.Providers.Anthropic.Proxy
if apiBase == "" {
apiBase = "https://api.anthropic.com/v1"
}
@ -343,7 +308,6 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
}
apiKey = cfg.Providers.OpenAI.APIKey
apiBase = cfg.Providers.OpenAI.APIBase
proxy = cfg.Providers.OpenAI.Proxy
if apiBase == "" {
apiBase = "https://api.openai.com/v1"
}
@ -351,7 +315,6 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
apiKey = cfg.Providers.Gemini.APIKey
apiBase = cfg.Providers.Gemini.APIBase
proxy = cfg.Providers.Gemini.Proxy
if apiBase == "" {
apiBase = "https://generativelanguage.googleapis.com/v1beta"
}
@ -359,7 +322,6 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
apiKey = cfg.Providers.Zhipu.APIKey
apiBase = cfg.Providers.Zhipu.APIBase
proxy = cfg.Providers.Zhipu.Proxy
if apiBase == "" {
apiBase = "https://open.bigmodel.cn/api/paas/v4"
}
@ -367,28 +329,24 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
apiKey = cfg.Providers.Groq.APIKey
apiBase = cfg.Providers.Groq.APIBase
proxy = cfg.Providers.Groq.Proxy
if apiBase == "" {
apiBase = "https://api.groq.com/openai/v1"
}
case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
apiKey = cfg.Providers.Nvidia.APIKey
apiBase = cfg.Providers.Nvidia.APIBase
proxy = cfg.Providers.Nvidia.Proxy
if apiBase == "" {
apiBase = "https://integrate.api.nvidia.com/v1"
}
case cfg.Providers.VLLM.APIBase != "":
apiKey = cfg.Providers.VLLM.APIKey
apiBase = cfg.Providers.VLLM.APIBase
proxy = cfg.Providers.VLLM.Proxy
case cfg.Providers.Nvidia.APIKey != "":
apiKey = cfg.Providers.Nvidia.APIKey
apiBase = cfg.Providers.Nvidia.APIBase
if apiBase == "" {
apiBase = "https://integrate.api.nvidia.com/v1"
}
default:
if cfg.Providers.OpenRouter.APIKey != "" {
apiKey = cfg.Providers.OpenRouter.APIKey
proxy = cfg.Providers.OpenRouter.Proxy
if cfg.Providers.OpenRouter.APIBase != "" {
apiBase = cfg.Providers.OpenRouter.APIBase
} else {
@ -408,5 +366,5 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
return nil, fmt.Errorf("no API base configured for provider (model: %s)", model)
}
return NewHTTPProvider(apiKey, apiBase, proxy), nil
return NewHTTPProvider(apiKey, apiBase), nil
}

View file

@ -0,0 +1,71 @@
package providers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHTTPProvider_NvidiaOptions(t *testing.T) {
var capturedBody map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("Expected POST request, got %s", r.Method)
}
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("Expected Authorization header, got %s", r.Header.Get("Authorization"))
}
err := json.NewDecoder(r.Body).Decode(&capturedBody)
if err != nil {
t.Errorf("Failed to decode request body: %v", err)
}
resp := map[string]interface{}{
"choices": []map[string]interface{}{
{
"message": map[string]interface{}{
"content": "Hello from Nvidia!",
},
"finish_reason": "stop",
},
},
"usage": map[string]interface{}{
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
provider := NewHTTPProvider("test-key", server.URL)
ctx := context.Background()
messages := []Message{{Role: "user", Content: "Hi"}}
options := map[string]interface{}{
"chat_template_kwargs": map[string]interface{}{
"thinking": true,
},
"top_p": 1.0,
}
resp, err := provider.Chat(ctx, messages, nil, "nvidia/kimi", options)
if err != nil {
t.Fatalf("Chat failed: %v", err)
}
if resp.Content != "Hello from Nvidia!" {
t.Errorf("Expected content 'Hello from Nvidia!', got %s", resp.Content)
}
// Verify captured body contains the custom options
if kwargs, ok := capturedBody["chat_template_kwargs"].(map[string]interface{}); !ok || !kwargs["thinking"].(bool) {
t.Errorf("Missing or incorrect chat_template_kwargs in request body: %v", capturedBody)
}
if capturedBody["top_p"].(float64) != 1.0 {
t.Errorf("Missing or incorrect top_p in request body: %v", capturedBody)
}
}

View file

@ -1,6 +1,7 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
@ -13,220 +14,292 @@ import (
)
const (
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
userAgent = "Mozilla/5.0 (compatible; picoclaw/1.0)"
)
type SearchProvider interface {
Search(ctx context.Context, query string, count int) (string, error)
}
// --- Ollama Search Tool ---
type BraveSearchProvider struct {
type OllamaSearchTool struct {
apiKey string
maxResults int
}
func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
url.QueryEscape(query), count)
func NewOllamaSearchTool(apiKey string, maxResults int) *OllamaSearchTool {
if maxResults <= 0 || maxResults > 10 {
maxResults = 5
}
return &OllamaSearchTool{
apiKey: apiKey,
maxResults: maxResults,
}
}
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
func (t *OllamaSearchTool) Name() string {
return "web_search"
}
func (t *OllamaSearchTool) Description() string {
return "Search the web for current information using Ollama. Returns titles, URLs, and snippets."
}
func (t *OllamaSearchTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{
"type": "string",
"description": "Search query",
},
"count": map[string]interface{}{
"type": "integer",
"description": "Number of results (1-10)",
"minimum": 1.0,
"maximum": 10.0,
},
},
"required": []string{"query"},
}
}
func (t *OllamaSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
query, ok := args["query"].(string)
if !ok {
return ErrorResult("query is required")
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Subscription-Token", p.apiKey)
count := t.maxResults
if c, ok := args["count"].(float64); ok {
if int(c) > 0 && int(c) <= 10 {
count = int(c)
}
}
client := &http.Client{Timeout: 10 * time.Second}
requestBody := map[string]interface{}{
"query": query,
"max_results": count,
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to marshal request: %v", err))
}
req, err := http.NewRequestWithContext(ctx, "POST", "https://ollama.com/api/web_search", bytes.NewReader(jsonData))
if err != nil {
return ErrorResult(fmt.Sprintf("failed to create request: %v", err))
}
req.Header.Set("Content-Type", "application/json")
if t.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+t.apiKey)
} else {
return &ToolResult{
ForLLM: "Error: OLLAMA_API_KEY not configured",
ForUser: "Error: OLLAMA_API_KEY not configured",
IsError: false,
}
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
return ErrorResult(fmt.Sprintf("request failed: %v", err))
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
}
if resp.StatusCode != http.StatusOK {
return &ToolResult{
ForLLM: fmt.Sprintf("Error: Ollama API returned %d: %s", resp.StatusCode, string(body)),
ForUser: fmt.Sprintf("Error: Ollama API returned %d", resp.StatusCode),
IsError: false,
}
}
var searchResp struct {
Web struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
Content string `json:"content"`
} `json:"results"`
} `json:"web"`
}
if err := json.Unmarshal(body, &searchResp); err != nil {
// Log error body for debugging
fmt.Printf("Brave API Error Body: %s\n", string(body))
return "", fmt.Errorf("failed to parse response: %w", err)
return ErrorResult(fmt.Sprintf("failed to parse response: %v", err))
}
results := searchResp.Web.Results
if len(results) == 0 {
return fmt.Sprintf("No results for: %s", query), nil
if len(searchResp.Results) == 0 {
return &ToolResult{
ForLLM: fmt.Sprintf("No results for: %s", query),
ForUser: fmt.Sprintf("No results for: %s", query),
IsError: false,
}
}
var lines []string
lines = append(lines, fmt.Sprintf("Results for: %s", query))
for i, item := range results {
if i >= count {
break
}
for i, item := range searchResp.Results {
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
if item.Description != "" {
lines = append(lines, fmt.Sprintf(" %s", item.Description))
if item.Content != "" {
lines = append(lines, fmt.Sprintf(" %s", item.Content))
}
}
return strings.Join(lines, "\n"), nil
return &ToolResult{
ForLLM: strings.Join(lines, "\n"),
ForUser: strings.Join(lines, "\n"),
IsError: false,
}
}
type DuckDuckGoSearchProvider struct{}
// --- Ollama Fetch Tool ---
func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query))
type OllamaFetchTool struct {
apiKey string
maxChars int
}
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
func NewOllamaFetchTool(apiKey string, maxChars int) *OllamaFetchTool {
if maxChars <= 0 {
maxChars = 50000
}
return &OllamaFetchTool{
apiKey: apiKey,
maxChars: maxChars,
}
}
func (t *OllamaFetchTool) Name() string {
return "web_fetch"
}
func (t *OllamaFetchTool) Description() string {
return "Fetch a URL and extract readable content using Ollama's Web Fetch API."
}
func (t *OllamaFetchTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{
"type": "string",
"description": "URL to fetch",
},
},
"required": []string{"url"},
}
}
func (t *OllamaFetchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
urlStr, ok := args["url"].(string)
if !ok {
return ErrorResult("url is required")
}
req.Header.Set("User-Agent", userAgent)
requestBody := map[string]interface{}{
"url": urlStr,
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to marshal request: %v", err))
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "POST", "https://ollama.com/api/web_fetch", bytes.NewReader(jsonData))
if err != nil {
return ErrorResult(fmt.Sprintf("failed to create request: %v", err))
}
req.Header.Set("Content-Type", "application/json")
if t.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+t.apiKey)
} else {
return &ToolResult{
ForLLM: "Error: OLLAMA_API_KEY not configured",
ForUser: "Error: OLLAMA_API_KEY not configured",
IsError: false,
}
}
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
return ErrorResult(fmt.Sprintf("request failed: %v", err))
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
}
return p.extractResults(string(body), count, query)
if resp.StatusCode != http.StatusOK {
return &ToolResult{
ForLLM: fmt.Sprintf("Error: Ollama API returned %d: %s", resp.StatusCode, string(body)),
ForUser: fmt.Sprintf("Error: Ollama API returned %d", resp.StatusCode),
IsError: false,
}
}
var fetchResp struct {
Title string `json:"title"`
Content string `json:"content"`
Links []string `json:"links"`
}
if err := json.Unmarshal(body, &fetchResp); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse response: %v", err))
}
text := fetchResp.Content
if len(text) > t.maxChars {
text = text[:t.maxChars]
}
result := map[string]interface{}{
"url": urlStr,
"title": fetchResp.Title,
"status": resp.StatusCode,
"extractor": "ollama",
"truncated": len(fetchResp.Content) > t.maxChars,
"length": len(text),
"text": text,
"links": fetchResp.Links,
}
resultJSON, _ := json.MarshalIndent(result, "", " ")
return &ToolResult{
ForLLM: string(resultJSON),
ForUser: string(resultJSON),
IsError: false,
}
}
func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) {
// Simple regex based extraction for DDG HTML
// Strategy: Find all result containers or key anchors directly
// Try finding the result links directly first, as they are the most critical
// Pattern: <a class="result__a" href="...">Title</a>
// The previous regex was a bit strict. Let's make it more flexible for attributes order/content
reLink := regexp.MustCompile(`<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`)
matches := reLink.FindAllStringSubmatch(html, count+5)
if len(matches) == 0 {
return fmt.Sprintf("No results found or extraction failed. Query: %s", query), nil
}
var lines []string
lines = append(lines, fmt.Sprintf("Results for: %s (via DuckDuckGo)", query))
// Pre-compile snippet regex to run inside the loop
// We'll search for snippets relative to the link position or just globally if needed
// But simple global search for snippets might mismatch order.
// Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex)
// Or better: Let's assume the snippet follows the link in the HTML
// A better regex approach: iterate through text and find matches in order
// But for now, let's grab all snippets too
reSnippet := regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
snippetMatches := reSnippet.FindAllStringSubmatch(html, count+5)
maxItems := min(len(matches), count)
for i := 0; i < maxItems; i++ {
urlStr := matches[i][1]
title := stripTags(matches[i][2])
title = strings.TrimSpace(title)
// URL decoding if needed
if strings.Contains(urlStr, "uddg=") {
if u, err := url.QueryUnescape(urlStr); err == nil {
idx := strings.Index(u, "uddg=")
if idx != -1 {
urlStr = u[idx+5:]
}
}
}
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, title, urlStr))
// Attempt to attach snippet if available and index aligns
if i < len(snippetMatches) {
snippet := stripTags(snippetMatches[i][1])
snippet = strings.TrimSpace(snippet)
if snippet != "" {
lines = append(lines, fmt.Sprintf(" %s", snippet))
}
}
}
return strings.Join(lines, "\n"), nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func stripTags(content string) string {
re := regexp.MustCompile(`<[^>]+>`)
return re.ReplaceAllString(content, "")
}
// --- Original Brave Search Tool ---
type WebSearchTool struct {
provider SearchProvider
apiKey string
maxResults int
}
type WebSearchToolOptions struct {
BraveAPIKey string
BraveMaxResults int
BraveEnabled bool
DuckDuckGoMaxResults int
DuckDuckGoEnabled bool
}
func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
var provider SearchProvider
maxResults := 5
// Priority: Brave > DuckDuckGo
if opts.BraveEnabled && opts.BraveAPIKey != "" {
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey}
if opts.BraveMaxResults > 0 {
maxResults = opts.BraveMaxResults
func NewWebSearchTool(apiKey string, maxResults int) *WebSearchTool {
if maxResults <= 0 || maxResults > 10 {
maxResults = 5
}
} else if opts.DuckDuckGoEnabled {
provider = &DuckDuckGoSearchProvider{}
if opts.DuckDuckGoMaxResults > 0 {
maxResults = opts.DuckDuckGoMaxResults
}
} else {
return nil
}
return &WebSearchTool{
provider: provider,
apiKey: apiKey,
maxResults: maxResults,
}
}
func (t *WebSearchTool) Name() string {
return "web_search"
return "web_search_brave"
}
func (t *WebSearchTool) Description() string {
return "Search the web for current information. Returns titles, URLs, and snippets from search results."
return "Search the web for current information using Brave Search. Returns titles, URLs, and snippets."
}
func (t *WebSearchTool) Parameters() map[string]interface{} {
@ -249,6 +322,14 @@ func (t *WebSearchTool) Parameters() map[string]interface{} {
}
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
if t.apiKey == "" {
return &ToolResult{
ForLLM: "Error: BRAVE_API_KEY not configured",
ForUser: "Error: BRAVE_API_KEY not configured",
IsError: false,
}
}
query, ok := args["query"].(string)
if !ok {
return ErrorResult("query is required")
@ -261,17 +342,73 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}
}
}
result, err := t.provider.Search(ctx, query, count)
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
url.QueryEscape(query), count)
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return ErrorResult(fmt.Sprintf("search failed: %v", err))
return ErrorResult(fmt.Sprintf("failed to create request: %v", err))
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Subscription-Token", t.apiKey)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return ErrorResult(fmt.Sprintf("request failed: %v", err))
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
}
var searchResp struct {
Web struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
} `json:"results"`
} `json:"web"`
}
if err := json.Unmarshal(body, &searchResp); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse response: %v", err))
}
results := searchResp.Web.Results
if len(results) == 0 {
return &ToolResult{
ForLLM: fmt.Sprintf("No results for: %s", query),
ForUser: fmt.Sprintf("No results for: %s", query),
IsError: false,
}
}
var lines []string
lines = append(lines, fmt.Sprintf("Results for: %s", query))
for i, item := range results {
if i >= count {
break
}
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
if item.Description != "" {
lines = append(lines, fmt.Sprintf(" %s", item.Description))
}
}
return &ToolResult{
ForLLM: result,
ForUser: result,
ForLLM: strings.Join(lines, "\n"),
ForUser: strings.Join(lines, "\n"),
IsError: false,
}
}
// --- Original Web Fetch Tool ---
type WebFetchTool struct {
maxChars int
}
@ -286,11 +423,11 @@ func NewWebFetchTool(maxChars int) *WebFetchTool {
}
func (t *WebFetchTool) Name() string {
return "web_fetch"
return "web_fetch_raw"
}
func (t *WebFetchTool) Description() string {
return "Fetch a URL and extract readable content (HTML to text). Use this to get weather info, news, articles, or any web content."
return "Fetch a URL and extract readable content (HTML to text) directly. Use if Ollama fetch fails."
}
func (t *WebFetchTool) Parameters() map[string]interface{} {
@ -409,10 +546,10 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{})
}
resultJSON, _ := json.MarshalIndent(result, "", " ")
return &ToolResult{
ForLLM: fmt.Sprintf("Fetched %d bytes from %s (extractor: %s, truncated: %v)", len(text), urlStr, extractor, truncated),
ForLLM: string(resultJSON),
ForUser: string(resultJSON),
IsError: false,
}
}

18
test-ollama.sh Executable file
View file

@ -0,0 +1,18 @@
#!/bin/bash
curl http://localhost:11434/api/chat -d '{
"model": "glm-5:cloud",
"messages": [{ "role": "user", "content": "Hello!" }]
}'
curl -X POST http://localhost:11434/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "glm-5:cloud",
"messages": [{ "role": "user", "content": "Say this is a test" }]
}'
curl http://localhost:11434/v1/completions -d '{
"model": "glm-5:cloud",
"messages": [{ "role": "user", "content": "Hello!" }]
}'