From 18524a14272e70cc61b1b81b137b5f0f78cce178 Mon Sep 17 00:00:00 2001 From: developer3000S Date: Fri, 13 Feb 2026 23:15:28 +0300 Subject: [PATCH] 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. --- .env | 17 ++ .gitignore | 14 +- cmd/picoclaw/main.go | 2 +- config/config.example.json | 48 +-- config/config.go | 13 + pkg/agent/context.go | 19 -- pkg/agent/loop.go | 378 +++++++++++------------- pkg/channels/manager.go | 18 -- pkg/channels/telegram.go | 15 +- pkg/config/config.go | 245 ++++++++-------- pkg/providers/http_provider.go | 214 ++++++-------- pkg/providers/http_provider_test.go | 71 +++++ pkg/tools/web.go | 441 ++++++++++++++++++---------- test-ollama.sh | 18 ++ 14 files changed, 821 insertions(+), 692 deletions(-) create mode 100644 .env create mode 100644 config/config.go create mode 100644 pkg/providers/http_provider_test.go create mode 100755 test-ollama.sh diff --git a/.env b/.env new file mode 100644 index 000000000..c450b6e8c --- /dev/null +++ b/.env @@ -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 diff --git a/.gitignore b/.gitignore index 6ba411758..0c1aca1e3 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 21246cf41..004da28f7 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -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 { diff --git a/config/config.example.json b/config/config.example.json index c71587a04..a54da32eb 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -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 + }, + + "brave": { + "api_key": "BSA0KawT1keaYZ4qPQBv-jib1kzo1JP", + "max_results": 5, + "enabled": false + }, + "duckduckgo": { + "api_key": "", + "max_results": 5, + "enabled": false } } }, - "heartbeat": { - "enabled": true, - "interval": 30 - }, "gateway": { "host": "0.0.0.0", "port": 18790 + }, + "heartbeat": { + "interval": 300, + "enabled": true } } diff --git a/config/config.go b/config/config.go new file mode 100644 index 000000000..52c083851 --- /dev/null +++ b/config/config.go @@ -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 +} \ No newline at end of file diff --git a/pkg/agent/context.go b/pkg/agent/context.go index cf5ce2913..0a790fbda 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -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() diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ac8da9ffd..055208caf 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -19,28 +19,92 @@ 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 workspace string model string - contextWindow int // Maximum context window size in tokens + 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 + 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,22 +202,11 @@ 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, - Content: response, - }) - } + 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) } -// 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" - 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, - }) - 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 + // 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 + }) } // 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) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 772551a4e..39cec942a 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -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() diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 0934dbd08..1dbbeefed 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -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 { diff --git a/pkg/config/config.go b/pkg/config/config.go index 374c6f86b..653debbcd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,7 +2,6 @@ package config import ( "encoding/json" - "fmt" "os" "path/filepath" "sync" @@ -10,47 +9,20 @@ 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"` + Providers ProvidersConfig `json:"providers"` + Gateway GatewayConfig `json:"gateway"` + Tools ToolsConfig `json:"tools"` + Heartbeat HeartbeatConfig `json:"heartbeat"` + mu sync.RWMutex } -type Config struct { - Agents AgentsConfig `json:"agents"` - Channels ChannelsConfig `json:"channels"` - Providers ProvidersConfig `json:"providers"` - Gateway GatewayConfig `json:"gateway"` - Tools ToolsConfig `json:"tools"` - Heartbeat HeartbeatConfig `json:"heartbeat"` - mu sync.RWMutex +// HeartbeatConfig defines heartbeat settings +type HeartbeatConfig struct { + Interval int `json:"interval"` // Interval in seconds + Enabled bool `json:"enabled"` } type AgentsConfig struct { @@ -58,13 +30,12 @@ type AgentsConfig struct { } 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"` - Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_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"` + Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` } type ChannelsConfig struct { @@ -79,83 +50,74 @@ 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"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + 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"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` } type FeishuConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` - 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"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` + 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 []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"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + 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"` + 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 []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"` + 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 []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"` + 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 []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` } type SlackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` + BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` 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"` - OpenRouter ProviderConfig `json:"openrouter"` - Groq ProviderConfig `json:"groq"` - Zhipu ProviderConfig `json:"zhipu"` - VLLM ProviderConfig `json:"vllm"` - Gemini ProviderConfig `json:"gemini"` - Nvidia ProviderConfig `json:"nvidia"` - Moonshot ProviderConfig `json:"moonshot"` - ShengSuanYun ProviderConfig `json:"shengsuanyun"` + Anthropic ProviderConfig `json:"anthropic"` + OpenAI ProviderConfig `json:"openai"` + OpenRouter ProviderConfig `json:"openrouter"` + Groq ProviderConfig `json:"groq"` + Zhipu ProviderConfig `json:"zhipu"` + VLLM ProviderConfig `json:"vllm"` + Gemini ProviderConfig `json:"gemini"` + Nvidia ProviderConfig `json:"nvidia"` } 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"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` + 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 { - Brave BraveConfig `json:"brave"` + 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"` } @@ -188,25 +163,24 @@ func DefaultConfig() *Config { return &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ - Workspace: "~/.picoclaw/workspace", - RestrictToWorkspace: true, - Provider: "", - Model: "glm-4.7", - MaxTokens: 8192, - Temperature: 0.7, - MaxToolIterations: 20, + Workspace: "~/.picoclaw/workspace", + Provider: "", + Model: "glm-4.7", + MaxTokens: 8192, + Temperature: 0.7, + MaxToolIterations: 20, }, }, Channels: ChannelsConfig{ 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, @@ -247,16 +221,14 @@ func DefaultConfig() *Config { }, }, Providers: ProvidersConfig{ - Anthropic: ProviderConfig{}, - OpenAI: ProviderConfig{}, - OpenRouter: ProviderConfig{}, - Groq: ProviderConfig{}, - Zhipu: ProviderConfig{}, - VLLM: ProviderConfig{}, - Gemini: ProviderConfig{}, - Nvidia: ProviderConfig{}, - Moonshot: ProviderConfig{}, - ShengSuanYun: ProviderConfig{}, + Anthropic: ProviderConfig{}, + OpenAI: ProviderConfig{}, + OpenRouter: ProviderConfig{}, + Groq: ProviderConfig{}, + Zhipu: ProviderConfig{}, + VLLM: ProviderConfig{}, + Gemini: ProviderConfig{}, + Nvidia: 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 "" } diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index fc78a182f..54dbd53cc 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -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, + apiKey: apiKey, + 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 + 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,116 +271,92 @@ 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" - } + 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 + if cfg.Providers.OpenRouter.APIBase != "" { + apiBase = cfg.Providers.OpenRouter.APIBase + } else { + apiBase = "https://openrouter.ai/api/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 - proxy = cfg.Providers.OpenRouter.Proxy if cfg.Providers.OpenRouter.APIBase != "" { apiBase = cfg.Providers.OpenRouter.APIBase } else { apiBase = "https://openrouter.ai/api/v1" } - - 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 - 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) - } + } else { + return nil, fmt.Errorf("no API key configured for model: %s", model) } } + } if apiKey == "" && !strings.HasPrefix(model, "bedrock/") { 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 NewHTTPProvider(apiKey, apiBase, proxy), nil + return NewHTTPProvider(apiKey, apiBase), nil } diff --git a/pkg/providers/http_provider_test.go b/pkg/providers/http_provider_test.go new file mode 100644 index 000000000..cb5ef60ef --- /dev/null +++ b/pkg/providers/http_provider_test.go @@ -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) + } +} diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 6fc89c95b..8c16e17b6 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -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 OllamaSearchTool struct { + apiKey string + maxResults int } -type BraveSearchProvider struct { - apiKey string +func NewOllamaSearchTool(apiKey string, maxResults int) *OllamaSearchTool { + 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) { - searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", - url.QueryEscape(query), count) +func (t *OllamaSearchTool) Name() string { + return "web_search" +} - req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) +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"` - } `json:"results"` - } `json:"web"` + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + } `json:"results"` } 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) -} - -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: Title - // The previous regex was a bit strict. Let's make it more flexible for attributes order/content - reLink := regexp.MustCompile(`]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)`) - 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(`([\s\S]*?)`) - 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)) - } + 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, } } - return strings.Join(lines, "\n"), nil -} - -func min(a, b int) int { - if a < b { - return a + 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, } - 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 - } - } else if opts.DuckDuckGoEnabled { - provider = &DuckDuckGoSearchProvider{} - if opts.DuckDuckGoMaxResults > 0 { - maxResults = opts.DuckDuckGoMaxResults - } - } else { - return nil +func NewWebSearchTool(apiKey string, maxResults int) *WebSearchTool { + if maxResults <= 0 || maxResults > 10 { + maxResults = 5 } - 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, } } diff --git a/test-ollama.sh b/test-ollama.sh new file mode 100755 index 000000000..6024f194a --- /dev/null +++ b/test-ollama.sh @@ -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!" }] +}'