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:
parent
45351a6a79
commit
18524a1427
14 changed files with 821 additions and 692 deletions
17
.env
Normal file
17
.env
Normal 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
14
.gitignore
vendored
|
|
@ -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/
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
13
config/config.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
pkg/providers/http_provider_test.go
Normal file
71
pkg/providers/http_provider_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
441
pkg/tools/web.go
441
pkg/tools/web.go
|
|
@ -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
18
test-ollama.sh
Executable 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!" }]
|
||||||
|
}'
|
||||||
Loading…
Add table
Reference in a new issue