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

View file

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

View file

@ -2,9 +2,8 @@
"agents": { "agents": {
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true, "model": "glm-5:cloud",
"model": "glm-4.7", "max_tokens": 198000,
"max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20
} }
@ -12,9 +11,10 @@
"channels": { "channels": {
"telegram": { "telegram": {
"enabled": false, "enabled": false,
"token": "YOUR_TELEGRAM_BOT_TOKEN", "token": "7902424029:AAFXa7EDs13wetH2v24RvsiGDfdH7QCH4ZY",
"proxy": "", "allow_from": [
"allow_from": ["YOUR_USER_ID"] "5352726595"
]
}, },
"discord": { "discord": {
"enabled": false, "enabled": false,
@ -78,34 +78,44 @@
"api_key": "", "api_key": "",
"api_base": "" "api_base": ""
}, },
"vllm": {
"api_key": "",
"api_base": ""
},
"nvidia": { "nvidia": {
"api_key": "nvapi-xxx", "api_key": "nvapi-xxx",
"api_base": "", "api_base": ""
"proxy": "http://127.0.0.1:7890"
}, },
"moonshot": { "ollama": {
"api_key": "sk-xxx", "api_key": "",
"api_base": "http://localhost:11434"
},
"vllm": {
"api_key": "",
"api_base": "" "api_base": ""
} }
}, },
"tools": { "tools": {
"web": { "web": {
"search": { "search": {
"api_key": "YOUR_BRAVE_API_KEY", "api_key": "BSA0KawT1keaYZ4qPQBv-jib1kzo1JP",
"max_results": 5 "max_results": 5
},
"brave": {
"api_key": "BSA0KawT1keaYZ4qPQBv-jib1kzo1JP",
"max_results": 5,
"enabled": false
},
"duckduckgo": {
"api_key": "",
"max_results": 5,
"enabled": false
} }
} }
}, },
"heartbeat": {
"enabled": true,
"interval": 30
},
"gateway": { "gateway": {
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 18790 "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 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. // GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} { func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
allSkills := cb.skillsLoader.ListSkills() allSkills := cb.skillsLoader.ListSkills()

View file

@ -19,28 +19,92 @@ import (
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils" "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 { type AgentLoop struct {
bus *bus.MessageBus bus *bus.MessageBus
provider providers.LLMProvider provider providers.LLMProvider
workspace string workspace string
model string model string
contextWindow int // Maximum context window size in tokens contextWindow int // Maximum context window size in tokens
maxIterations int maxIterations int
sessions *session.SessionManager sessions *session.SessionManager
state *state.Manager
contextBuilder *ContextBuilder contextBuilder *ContextBuilder
tools *tools.ToolRegistry tools *tools.ToolRegistry
running atomic.Bool running atomic.Bool
summarizing sync.Map // Tracks which sessions are currently being summarized summarizing sync.Map // Tracks which sessions are currently being summarized
state *AgentState // Persistent state manager
} }
// processOptions configures how a message is processed // processOptions configures how a message is processed
@ -52,37 +116,29 @@ type processOptions struct {
DefaultResponse string // Response when LLM returns empty DefaultResponse string // Response when LLM returns empty
EnableSummary bool // Whether to trigger summarization EnableSummary bool // Whether to trigger summarization
SendResponse bool // Whether to send response via bus 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. func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
// This is shared between main agent and subagents. workspace := cfg.WorkspacePath()
func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msgBus *bus.MessageBus) *tools.ToolRegistry { os.MkdirAll(workspace, 0755)
registry := tools.NewToolRegistry()
// File system tools toolsRegistry := tools.NewToolRegistry()
registry.Register(tools.NewReadFileTool(workspace, restrict)) toolsRegistry.Register(&tools.ReadFileTool{})
registry.Register(tools.NewWriteFileTool(workspace, restrict)) toolsRegistry.Register(&tools.WriteFileTool{})
registry.Register(tools.NewListDirTool(workspace, restrict)) toolsRegistry.Register(&tools.ListDirTool{})
registry.Register(tools.NewEditFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewExecTool(workspace, true))
registry.Register(tools.NewAppendFileTool(workspace, restrict))
// Shell execution ollamaAPIKey := cfg.Tools.Web.Ollama.APIKey
registry.Register(tools.NewExecTool(workspace, restrict)) if ollamaAPIKey != "" {
toolsRegistry.Register(tools.NewOllamaSearchTool(ollamaAPIKey, cfg.Tools.Web.Ollama.MaxResults))
if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{ toolsRegistry.Register(tools.NewOllamaFetchTool(ollamaAPIKey, 50000))
BraveAPIKey: cfg.Tools.Web.Brave.APIKey, } else {
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, braveAPIKey := cfg.Tools.Web.Search.APIKey
BraveEnabled: cfg.Tools.Web.Brave.Enabled, toolsRegistry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults))
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, toolsRegistry.Register(tools.NewWebFetchTool(50000))
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
}); searchTool != nil {
registry.Register(searchTool)
} }
registry.Register(tools.NewWebFetchTool(50000))
// Message tool - available to both agent and subagent // Register message tool
// Subagent uses it to communicate directly with user
messageTool := tools.NewMessageTool() messageTool := tools.NewMessageTool()
messageTool.SetSendCallback(func(channel, chatID, content string) error { messageTool.SetSendCallback(func(channel, chatID, content string) error {
msgBus.PublishOutbound(bus.OutboundMessage{ msgBus.PublishOutbound(bus.OutboundMessage{
@ -92,43 +148,26 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
}) })
return nil return nil
}) })
registry.Register(messageTool) toolsRegistry.Register(messageTool)
return registry // Register spawn tool
}
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
subagentManager := tools.NewSubagentManager(provider, cfg.Agents.Defaults.Model, workspace, msgBus) 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) spawnTool := tools.NewSpawnTool(subagentManager)
toolsRegistry.Register(spawnTool) toolsRegistry.Register(spawnTool)
// Register subagent tool (synchronous execution) // Register edit file tool
subagentTool := tools.NewSubagentTool(subagentManager) editFileTool := tools.NewEditFileTool(workspace, true)
toolsRegistry.Register(subagentTool) toolsRegistry.Register(editFileTool)
sessionsManager := session.NewSessionManager(filepath.Join(workspace, "sessions")) 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 // Create context builder and set tools registry
contextBuilder := NewContextBuilder(workspace) contextBuilder := NewContextBuilder(workspace)
contextBuilder.SetToolsRegistry(toolsRegistry) contextBuilder.SetToolsRegistry(toolsRegistry)
// Create state manager
stateManager := NewAgentState(workspace)
return &AgentLoop{ return &AgentLoop{
bus: msgBus, bus: msgBus,
provider: provider, 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 contextWindow: cfg.Agents.Defaults.MaxTokens, // Restore context window for summarization
maxIterations: cfg.Agents.Defaults.MaxToolIterations, maxIterations: cfg.Agents.Defaults.MaxToolIterations,
sessions: sessionsManager, sessions: sessionsManager,
state: stateManager,
contextBuilder: contextBuilder, contextBuilder: contextBuilder,
tools: toolsRegistry, tools: toolsRegistry,
summarizing: sync.Map{}, summarizing: sync.Map{},
state: stateManager,
} }
} }
@ -163,22 +202,11 @@ func (al *AgentLoop) Run(ctx context.Context) error {
} }
if response != "" { if response != "" {
// Check if the message tool already sent a response during this round. al.bus.PublishOutbound(bus.OutboundMessage{
// If so, skip publishing to avoid duplicate messages to the user. Channel: msg.Channel,
alreadySent := false ChatID: msg.ChatID,
if tool, ok := al.tools.Get("message"); ok { Content: response,
if mt, ok := tool.(*tools.MessageTool); ok { })
alreadySent = mt.HasSentInRound()
}
}
if !alreadySent {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: response,
})
}
} }
} }
} }
@ -194,16 +222,46 @@ func (al *AgentLoop) RegisterTool(tool tools.Tool) {
al.tools.Register(tool) al.tools.Register(tool)
} }
// RecordLastChannel records the last active channel for this workspace. // RecordLastChannel records the last used channel
// This uses the atomic state save mechanism to prevent data loss on crash.
func (al *AgentLoop) RecordLastChannel(channel string) error { 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. // RecordLastChatID records the last used chat ID
// This uses the atomic state save mechanism to prevent data loss on crash.
func (al *AgentLoop) RecordLastChatID(chatID string) error { 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) { 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) 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) { func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
// Add message preview to log (show full content for error messages) // Add message preview to log
var logContent string preview := utils.Truncate(msg.Content, 80)
if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, preview),
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),
map[string]interface{}{ map[string]interface{}{
"channel": msg.Channel, "channel": msg.Channel,
"chat_id": msg.ChatID, "chat_id": msg.ChatID,
@ -282,70 +320,41 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
"chat_id": msg.ChatID, "chat_id": msg.ChatID,
}) })
// Parse origin channel from chat_id (format: "channel:chat_id") // Parse origin from chat_id (format: "channel:chat_id")
var originChannel string var originChannel, originChatID string
if idx := strings.Index(msg.ChatID, ":"); idx > 0 { if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
originChannel = msg.ChatID[:idx] originChannel = msg.ChatID[:idx]
originChatID = msg.ChatID[idx+1:]
} else { } else {
// Fallback // Fallback
originChannel = "cli" originChannel = "cli"
originChatID = msg.ChatID
} }
// Extract subagent result from message content // Use the origin session for context
// Format: "Task 'label' completed.\n\nResult:\n<actual content>" sessionKey := fmt.Sprintf("%s:%s", originChannel, originChatID)
content := msg.Content
if idx := strings.Index(content, "Result:\n"); idx >= 0 {
content = content[idx+8:] // Extract just the result part
}
// Skip internal channels - only log, don't send to user // Process as system message with routing back to origin
if constants.IsInternalChannel(originChannel) { return al.runAgentLoop(ctx, processOptions{
logger.InfoCF("agent", "Subagent completed (internal channel)", SessionKey: sessionKey,
map[string]interface{}{ Channel: originChannel,
"sender_id": msg.SenderID, ChatID: originChatID,
"content_len": len(content), UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content),
"channel": originChannel, DefaultResponse: "Background task completed.",
}) EnableSummary: false,
return "", nil SendResponse: true, // Send response back to original channel
} })
// 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. // runAgentLoop is the core message processing logic.
// It handles context building, LLM calls, tool execution, and response handling. // It handles context building, LLM calls, tool execution, and response handling.
func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (string, error) { 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 // 1. Update tool contexts
al.updateToolContexts(opts.Channel, opts.ChatID) al.updateToolContexts(opts.Channel, opts.ChatID)
// 2. Build messages (skip history for heartbeat) // 2. Build messages
var history []providers.Message history := al.sessions.GetHistory(opts.SessionKey)
var summary string summary := al.sessions.GetSummary(opts.SessionKey)
if !opts.NoHistory {
history = al.sessions.GetHistory(opts.SessionKey)
summary = al.sessions.GetSummary(opts.SessionKey)
}
messages := al.contextBuilder.BuildMessages( messages := al.contextBuilder.BuildMessages(
history, history,
summary, summary,
@ -364,9 +373,6 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
return "", err 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 // 5. Handle empty response
if finalContent == "" { if finalContent == "" {
finalContent = opts.DefaultResponse finalContent = opts.DefaultResponse
@ -418,7 +424,18 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
}) })
// Build tool definitions // 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 // Log LLM request details
logger.DebugCF("agent", "LLM request", 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", logger.InfoCF("agent", "LLM requested tool calls",
map[string]interface{}{ map[string]interface{}{
"tools": toolNames, "tools": toolNames,
"count": len(response.ToolCalls), "count": len(toolNames),
"iteration": iteration, "iteration": iteration,
}) })
@ -510,47 +527,14 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
"iteration": iteration, "iteration": iteration,
}) })
// Create async callback for tools that implement AsyncTool result := al.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, nil)
// NOTE: Following openclaw's design, async tools do NOT send results directly to users. if result.Err != nil {
// Instead, they notify the agent via PublishInbound, and the agent decides result = tools.ErrorResult(fmt.Sprintf("Error: %v", result.Err))
// 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()
} }
toolResultMsg := providers.Message{ toolResultMsg := providers.Message{
Role: "tool", Role: "tool",
Content: contentForLLM, Content: result.ForLLM,
ToolCallID: tc.ID, ToolCallID: tc.ID,
} }
messages = append(messages, toolResultMsg) 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. // updateToolContexts updates the context for tools that need channel/chatID info.
func (al *AgentLoop) updateToolContexts(channel, chatID string) { func (al *AgentLoop) updateToolContexts(channel, chatID string) {
// Use ContextualTool interface instead of type assertions
if tool, ok := al.tools.Get("message"); ok { 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) mt.SetContext(channel, chatID)
} }
} }
if tool, ok := al.tools.Get("spawn"); ok { if tool, ok := al.tools.Get("spawn"); ok {
if st, ok := tool.(tools.ContextualTool); ok { if st, ok := tool.(*tools.SpawnTool); ok {
st.SetContext(channel, chatID)
}
}
if tool, ok := al.tools.Get("subagent"); ok {
if st, ok := tool.(tools.ContextualTool); ok {
st.SetContext(channel, chatID) st.SetContext(channel, chatID)
} }
} }
@ -626,7 +604,7 @@ func formatMessagesForLog(messages []providers.Message) string {
result += "[\n" result += "[\n"
for i, msg := range messages { for i, msg := range messages {
result += fmt.Sprintf(" [%d] Role: %s\n", i, msg.Role) 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" result += " ToolCalls:\n"
for _, tc := range msg.ToolCalls { for _, tc := range msg.ToolCalls {
result += fmt.Sprintf(" - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) 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/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger" "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 != "" { if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
logger.DebugC("channels", "Attempting to initialize Discord channel") logger.DebugC("channels", "Attempting to initialize Discord channel")
@ -230,11 +217,6 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
continue continue
} }
// Silently skip internal channels
if constants.IsInternalChannel(msg.Channel) {
continue
}
m.mu.RLock() m.mu.RLock()
channel, exists := m.channels[msg.Channel] channel, exists := m.channels[msg.Channel]
m.mu.RUnlock() m.mu.RUnlock()

View file

@ -3,8 +3,7 @@ package channels
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
"net/url"
"os" "os"
"regexp" "regexp"
"strings" "strings"
@ -44,17 +43,7 @@ func (c *thinkingCancel) Cancel() {
func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) { func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) {
var opts []telego.BotOption 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...) bot, err := telego.NewBot(cfg.Token, opts...)
if err != nil { if err != nil {

View file

@ -2,7 +2,6 @@ package config
import ( import (
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@ -10,47 +9,20 @@ import (
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
) )
// FlexibleStringSlice is a []string that also accepts JSON numbers, type Config struct {
// so allow_from can contain both "123" and 123. Agents AgentsConfig `json:"agents"`
type FlexibleStringSlice []string Channels ChannelsConfig `json:"channels"`
Providers ProvidersConfig `json:"providers"`
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { Gateway GatewayConfig `json:"gateway"`
// Try []string first Tools ToolsConfig `json:"tools"`
var ss []string Heartbeat HeartbeatConfig `json:"heartbeat"`
if err := json.Unmarshal(data, &ss); err == nil { mu sync.RWMutex
*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 { // HeartbeatConfig defines heartbeat settings
Agents AgentsConfig `json:"agents"` type HeartbeatConfig struct {
Channels ChannelsConfig `json:"channels"` Interval int `json:"interval"` // Interval in seconds
Providers ProvidersConfig `json:"providers"` Enabled bool `json:"enabled"`
Gateway GatewayConfig `json:"gateway"`
Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"`
mu sync.RWMutex
} }
type AgentsConfig struct { type AgentsConfig struct {
@ -58,13 +30,12 @@ type AgentsConfig struct {
} }
type AgentDefaults struct { type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` 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"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
} }
type ChannelsConfig struct { type ChannelsConfig struct {
@ -79,83 +50,74 @@ type ChannelsConfig struct {
} }
type WhatsAppConfig struct { type WhatsAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` 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 { type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
} }
type FeishuConfig struct { type FeishuConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` 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 { type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` 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 { type MaixCamConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` 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 { type QQConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` 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 { type DingTalkConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` 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 { type SlackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` 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 { type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"` Anthropic ProviderConfig `json:"anthropic"`
OpenAI ProviderConfig `json:"openai"` OpenAI ProviderConfig `json:"openai"`
OpenRouter ProviderConfig `json:"openrouter"` OpenRouter ProviderConfig `json:"openrouter"`
Groq ProviderConfig `json:"groq"` Groq ProviderConfig `json:"groq"`
Zhipu ProviderConfig `json:"zhipu"` Zhipu ProviderConfig `json:"zhipu"`
VLLM ProviderConfig `json:"vllm"` VLLM ProviderConfig `json:"vllm"`
Gemini ProviderConfig `json:"gemini"` Gemini ProviderConfig `json:"gemini"`
Nvidia ProviderConfig `json:"nvidia"` Nvidia ProviderConfig `json:"nvidia"`
Moonshot ProviderConfig `json:"moonshot"`
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
} }
type ProviderConfig struct { type ProviderConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` 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"` 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"` 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 { 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"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` 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 { 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"` 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 { type WebToolsConfig struct {
Brave BraveConfig `json:"brave"` Search WebSearchConfig `json:"search"`
Ollama OllamaConfig `json:"ollama"`
Brave BraveConfig `json:"brave"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` 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 { type ToolsConfig struct {
Web WebToolsConfig `json:"web"` Web WebToolsConfig `json:"web"`
} }
@ -188,25 +163,24 @@ func DefaultConfig() *Config {
return &Config{ return &Config{
Agents: AgentsConfig{ Agents: AgentsConfig{
Defaults: AgentDefaults{ Defaults: AgentDefaults{
Workspace: "~/.picoclaw/workspace", Workspace: "~/.picoclaw/workspace",
RestrictToWorkspace: true, Provider: "",
Provider: "", Model: "glm-4.7",
Model: "glm-4.7", MaxTokens: 8192,
MaxTokens: 8192, Temperature: 0.7,
Temperature: 0.7, MaxToolIterations: 20,
MaxToolIterations: 20,
}, },
}, },
Channels: ChannelsConfig{ Channels: ChannelsConfig{
WhatsApp: WhatsAppConfig{ WhatsApp: WhatsAppConfig{
Enabled: false, Enabled: false,
BridgeURL: "ws://localhost:3001", BridgeURL: "ws://localhost:3001",
AllowFrom: FlexibleStringSlice{}, AllowFrom: []string{},
}, },
Telegram: TelegramConfig{ Telegram: TelegramConfig{
Enabled: false, Enabled: false,
Token: "", Token: "",
AllowFrom: FlexibleStringSlice{}, AllowFrom: []string{},
}, },
Feishu: FeishuConfig{ Feishu: FeishuConfig{
Enabled: false, Enabled: false,
@ -214,30 +188,30 @@ func DefaultConfig() *Config {
AppSecret: "", AppSecret: "",
EncryptKey: "", EncryptKey: "",
VerificationToken: "", VerificationToken: "",
AllowFrom: FlexibleStringSlice{}, AllowFrom: []string{},
}, },
Discord: DiscordConfig{ Discord: DiscordConfig{
Enabled: false, Enabled: false,
Token: "", Token: "",
AllowFrom: FlexibleStringSlice{}, AllowFrom: []string{},
}, },
MaixCam: MaixCamConfig{ MaixCam: MaixCamConfig{
Enabled: false, Enabled: false,
Host: "0.0.0.0", Host: "0.0.0.0",
Port: 18790, Port: 18790,
AllowFrom: FlexibleStringSlice{}, AllowFrom: []string{},
}, },
QQ: QQConfig{ QQ: QQConfig{
Enabled: false, Enabled: false,
AppID: "", AppID: "",
AppSecret: "", AppSecret: "",
AllowFrom: FlexibleStringSlice{}, AllowFrom: []string{},
}, },
DingTalk: DingTalkConfig{ DingTalk: DingTalkConfig{
Enabled: false, Enabled: false,
ClientID: "", ClientID: "",
ClientSecret: "", ClientSecret: "",
AllowFrom: FlexibleStringSlice{}, AllowFrom: []string{},
}, },
Slack: SlackConfig{ Slack: SlackConfig{
Enabled: false, Enabled: false,
@ -247,16 +221,14 @@ func DefaultConfig() *Config {
}, },
}, },
Providers: ProvidersConfig{ Providers: ProvidersConfig{
Anthropic: ProviderConfig{}, Anthropic: ProviderConfig{},
OpenAI: ProviderConfig{}, OpenAI: ProviderConfig{},
OpenRouter: ProviderConfig{}, OpenRouter: ProviderConfig{},
Groq: ProviderConfig{}, Groq: ProviderConfig{},
Zhipu: ProviderConfig{}, Zhipu: ProviderConfig{},
VLLM: ProviderConfig{}, VLLM: ProviderConfig{},
Gemini: ProviderConfig{}, Gemini: ProviderConfig{},
Nvidia: ProviderConfig{}, Nvidia: ProviderConfig{},
Moonshot: ProviderConfig{},
ShengSuanYun: ProviderConfig{},
}, },
Gateway: GatewayConfig{ Gateway: GatewayConfig{
Host: "0.0.0.0", Host: "0.0.0.0",
@ -264,20 +236,29 @@ func DefaultConfig() *Config {
}, },
Tools: ToolsConfig{ Tools: ToolsConfig{
Web: WebToolsConfig{ Web: WebToolsConfig{
Brave: BraveConfig{ Search: WebSearchConfig{
Enabled: false,
APIKey: "", APIKey: "",
MaxResults: 5, MaxResults: 5,
}, },
DuckDuckGo: DuckDuckGoConfig{ Ollama: OllamaConfig{
Enabled: true, APIKey: "",
MaxResults: 5, MaxResults: 5,
}, },
Brave: BraveConfig{
APIKey: "",
MaxResults: 5,
Enabled: false,
},
DuckDuckGo: DuckDuckGoConfig{
APIKey: "",
MaxResults: 5,
Enabled: false,
},
}, },
}, },
Heartbeat: HeartbeatConfig{ Heartbeat: HeartbeatConfig{
Interval: 300, // 5 minutes default
Enabled: true, Enabled: true,
Interval: 30, // default 30 minutes
}, },
} }
} }
@ -351,8 +332,8 @@ func (c *Config) GetAPIKey() string {
if c.Providers.VLLM.APIKey != "" { if c.Providers.VLLM.APIKey != "" {
return c.Providers.VLLM.APIKey return c.Providers.VLLM.APIKey
} }
if c.Providers.ShengSuanYun.APIKey != "" { if c.Providers.Nvidia.APIKey != "" {
return c.Providers.ShengSuanYun.APIKey return c.Providers.Nvidia.APIKey
} }
return "" return ""
} }
@ -372,6 +353,12 @@ func (c *Config) GetAPIBase() string {
if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" { if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" {
return 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 "" return ""
} }

View file

@ -13,7 +13,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/auth"
@ -26,24 +25,13 @@ type HTTPProvider struct {
httpClient *http.Client httpClient *http.Client
} }
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { func NewHTTPProvider(apiKey, apiBase 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),
}
}
}
return &HTTPProvider{ return &HTTPProvider{
apiKey: apiKey, apiKey: apiKey,
apiBase: strings.TrimRight(apiBase, "/"), apiBase: apiBase,
httpClient: client, 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") 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{}{ requestBody := map[string]interface{}{
"model": model, "model": model,
"messages": messages, "messages": messages,
@ -80,13 +60,15 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
} }
if temperature, ok := options["temperature"].(float64); ok { if temperature, ok := options["temperature"].(float64); ok {
lowerModel := strings.ToLower(model) requestBody["temperature"] = temperature
// Kimi k2 models only support temperature=1 }
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
requestBody["temperature"] = 1.0 // Add additional options (like chat_template_kwargs for Nvidia)
} else { for k, v := range options {
requestBody["temperature"] = temperature if k == "max_tokens" || k == "temperature" || k == "model" || k == "messages" {
continue
} }
requestBody[k] = v
} }
jsonData, err := json.Marshal(requestBody) 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 { 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) return p.parseResponse(body)
@ -222,7 +204,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
model := cfg.Agents.Defaults.Model model := cfg.Agents.Defaults.Model
providerName := strings.ToLower(cfg.Agents.Defaults.Provider) providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
var apiKey, apiBase, proxy string var apiKey, apiBase string
lowerModel := strings.ToLower(model) lowerModel := strings.ToLower(model)
@ -289,116 +271,92 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
apiKey = cfg.Providers.VLLM.APIKey apiKey = cfg.Providers.VLLM.APIKey
apiBase = cfg.Providers.VLLM.APIBase apiBase = cfg.Providers.VLLM.APIBase
} }
case "shengsuanyun": case "nvidia":
if cfg.Providers.ShengSuanYun.APIKey != "" { if cfg.Providers.Nvidia.APIKey != "" {
apiKey = cfg.Providers.ShengSuanYun.APIKey apiKey = cfg.Providers.Nvidia.APIKey
apiBase = cfg.Providers.ShengSuanYun.APIBase apiBase = cfg.Providers.Nvidia.APIBase
if 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 // Fallback: detect provider from model name
if apiKey == "" && apiBase == "" { if apiKey == "" && apiBase == "" {
switch { 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/"):
case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "": apiKey = cfg.Providers.OpenRouter.APIKey
apiKey = cfg.Providers.Moonshot.APIKey if cfg.Providers.OpenRouter.APIBase != "" {
apiBase = cfg.Providers.Moonshot.APIBase apiBase = cfg.Providers.OpenRouter.APIBase
proxy = cfg.Providers.Moonshot.Proxy } else {
if apiBase == "" { apiBase = "https://openrouter.ai/api/v1"
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/"): case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
return createClaudeAuthProvider()
}
apiKey = cfg.Providers.Anthropic.APIKey
apiBase = cfg.Providers.Anthropic.APIBase
if apiBase == "" {
apiBase = "https://api.anthropic.com/v1"
}
case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
return createCodexAuthProvider()
}
apiKey = cfg.Providers.OpenAI.APIKey
apiBase = cfg.Providers.OpenAI.APIBase
if apiBase == "" {
apiBase = "https://api.openai.com/v1"
}
case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
apiKey = cfg.Providers.Gemini.APIKey
apiBase = cfg.Providers.Gemini.APIBase
if apiBase == "" {
apiBase = "https://generativelanguage.googleapis.com/v1beta"
}
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
if apiBase == "" {
apiBase = "https://open.bigmodel.cn/api/paas/v4"
}
case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
apiKey = cfg.Providers.Groq.APIKey
apiBase = cfg.Providers.Groq.APIBase
if apiBase == "" {
apiBase = "https://api.groq.com/openai/v1"
}
case cfg.Providers.VLLM.APIBase != "":
apiKey = cfg.Providers.VLLM.APIKey
apiBase = cfg.Providers.VLLM.APIBase
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 apiKey = cfg.Providers.OpenRouter.APIKey
proxy = cfg.Providers.OpenRouter.Proxy
if cfg.Providers.OpenRouter.APIBase != "" { if cfg.Providers.OpenRouter.APIBase != "" {
apiBase = cfg.Providers.OpenRouter.APIBase apiBase = cfg.Providers.OpenRouter.APIBase
} else { } else {
apiBase = "https://openrouter.ai/api/v1" apiBase = "https://openrouter.ai/api/v1"
} }
} else {
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): return nil, fmt.Errorf("no API key configured for model: %s", model)
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
return createClaudeAuthProvider()
}
apiKey = cfg.Providers.Anthropic.APIKey
apiBase = cfg.Providers.Anthropic.APIBase
proxy = cfg.Providers.Anthropic.Proxy
if apiBase == "" {
apiBase = "https://api.anthropic.com/v1"
}
case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
return createCodexAuthProvider()
}
apiKey = cfg.Providers.OpenAI.APIKey
apiBase = cfg.Providers.OpenAI.APIBase
proxy = cfg.Providers.OpenAI.Proxy
if apiBase == "" {
apiBase = "https://api.openai.com/v1"
}
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"
}
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"
}
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
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 {
apiBase = "https://openrouter.ai/api/v1"
}
} else {
return nil, fmt.Errorf("no API key configured for model: %s", model)
}
} }
} }
}
if apiKey == "" && !strings.HasPrefix(model, "bedrock/") { if apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
return nil, fmt.Errorf("no API key configured for provider (model: %s)", model) return nil, fmt.Errorf("no API key configured for provider (model: %s)", model)
@ -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 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 package tools
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@ -13,220 +14,292 @@ import (
) )
const ( 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 { // --- Ollama Search Tool ---
Search(ctx context.Context, query string, count int) (string, error)
type OllamaSearchTool struct {
apiKey string
maxResults int
} }
type BraveSearchProvider struct { func NewOllamaSearchTool(apiKey string, maxResults int) *OllamaSearchTool {
apiKey string if maxResults <= 0 || maxResults > 10 {
maxResults = 5
}
return &OllamaSearchTool{
apiKey: apiKey,
maxResults: maxResults,
}
} }
func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { func (t *OllamaSearchTool) Name() string {
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", return "web_search"
url.QueryEscape(query), count) }
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) func (t *OllamaSearchTool) Description() string {
if err != nil { return "Search the web for current information using Ollama. Returns titles, URLs, and snippets."
return "", fmt.Errorf("failed to create request: %w", err) }
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") count := t.maxResults
req.Header.Set("X-Subscription-Token", p.apiKey) 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) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("request failed: %w", err) return ErrorResult(fmt.Sprintf("request failed: %v", err))
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { 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 { var searchResp struct {
Web struct { Results []struct {
Results []struct { Title string `json:"title"`
Title string `json:"title"` URL string `json:"url"`
URL string `json:"url"` Content string `json:"content"`
Description string `json:"description"` } `json:"results"`
} `json:"results"`
} `json:"web"`
} }
if err := json.Unmarshal(body, &searchResp); err != nil { if err := json.Unmarshal(body, &searchResp); err != nil {
// Log error body for debugging return ErrorResult(fmt.Sprintf("failed to parse response: %v", err))
fmt.Printf("Brave API Error Body: %s\n", string(body))
return "", fmt.Errorf("failed to parse response: %w", err)
} }
results := searchResp.Web.Results if len(searchResp.Results) == 0 {
if len(results) == 0 { return &ToolResult{
return fmt.Sprintf("No results for: %s", query), nil ForLLM: fmt.Sprintf("No results for: %s", query),
ForUser: fmt.Sprintf("No results for: %s", query),
IsError: false,
}
} }
var lines []string var lines []string
lines = append(lines, fmt.Sprintf("Results for: %s", query)) lines = append(lines, fmt.Sprintf("Results for: %s", query))
for i, item := range results { for i, item := range searchResp.Results {
if i >= count {
break
}
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
if item.Description != "" { if item.Content != "" {
lines = append(lines, fmt.Sprintf(" %s", item.Description)) 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) { type OllamaFetchTool struct {
searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query)) apiKey string
maxChars int
}
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) func NewOllamaFetchTool(apiKey string, maxChars int) *OllamaFetchTool {
if err != nil { if maxChars <= 0 {
return "", fmt.Errorf("failed to create request: %w", err) 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) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("request failed: %w", err) return ErrorResult(fmt.Sprintf("request failed: %v", err))
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { 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)),
func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) { ForUser: fmt.Sprintf("Error: Ollama API returned %d", resp.StatusCode),
// Simple regex based extraction for DDG HTML IsError: false,
// 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 var fetchResp struct {
} Title string `json:"title"`
Content string `json:"content"`
func min(a, b int) int { Links []string `json:"links"`
if a < b { }
return a
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,
} }
return b
} }
func stripTags(content string) string { // --- Original Brave Search Tool ---
re := regexp.MustCompile(`<[^>]+>`)
return re.ReplaceAllString(content, "")
}
type WebSearchTool struct { type WebSearchTool struct {
provider SearchProvider apiKey string
maxResults int maxResults int
} }
type WebSearchToolOptions struct { func NewWebSearchTool(apiKey string, maxResults int) *WebSearchTool {
BraveAPIKey string if maxResults <= 0 || maxResults > 10 {
BraveMaxResults int maxResults = 5
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
}
} else if opts.DuckDuckGoEnabled {
provider = &DuckDuckGoSearchProvider{}
if opts.DuckDuckGoMaxResults > 0 {
maxResults = opts.DuckDuckGoMaxResults
}
} else {
return nil
} }
return &WebSearchTool{ return &WebSearchTool{
provider: provider, apiKey: apiKey,
maxResults: maxResults, maxResults: maxResults,
} }
} }
func (t *WebSearchTool) Name() string { func (t *WebSearchTool) Name() string {
return "web_search" return "web_search_brave"
} }
func (t *WebSearchTool) Description() string { 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{} { 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 { 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) query, ok := args["query"].(string)
if !ok { if !ok {
return ErrorResult("query is required") 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 { 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{ return &ToolResult{
ForLLM: result, ForLLM: strings.Join(lines, "\n"),
ForUser: result, ForUser: strings.Join(lines, "\n"),
IsError: false,
} }
} }
// --- Original Web Fetch Tool ---
type WebFetchTool struct { type WebFetchTool struct {
maxChars int maxChars int
} }
@ -286,11 +423,11 @@ func NewWebFetchTool(maxChars int) *WebFetchTool {
} }
func (t *WebFetchTool) Name() string { func (t *WebFetchTool) Name() string {
return "web_fetch" return "web_fetch_raw"
} }
func (t *WebFetchTool) Description() string { 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{} { 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, "", " ") resultJSON, _ := json.MarshalIndent(result, "", " ")
return &ToolResult{ 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), 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!" }]
}'