+
-
-
-
-
-
@@ -88,7 +100,7 @@
PicoClaw can be deployed on almost any Linux device!
-- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring
@@ -165,7 +177,7 @@ docker compose --profile gateway up -d
> [!TIP]
> Set your API key in `~/.picoclaw/config.json`.
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
-> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month)
+> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
**1. Initialize**
@@ -194,9 +206,14 @@ picoclaw onboard
},
"tools": {
"web": {
- "search": {
+ "brave": {
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
}
}
}
@@ -205,8 +222,8 @@ picoclaw onboard
**3. Get API Keys**
-- **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
-- **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
+* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
> **Note**: See `config.example.json` for a complete configuration template.
@@ -222,23 +239,24 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps
-Talk to your picoclaw through Telegram, Discord, or DingTalk
+Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
-| Channel | Setup |
-|---------|-------|
-| **Telegram** | Easy (just a token) |
-| **Discord** | Easy (bot token + intents) |
-| **QQ** | Easy (AppID + AppSecret) |
-| **DingTalk** | Medium (app credentials) |
+| Channel | Setup |
+| ------------ | ---------------------------------- |
+| **Telegram** | Easy (just a token) |
+| **Discord** | Easy (bot token + intents) |
+| **QQ** | Easy (AppID + AppSecret) |
+| **DingTalk** | Medium (app credentials) |
+| **LINE** | Medium (credentials + webhook URL) |
@@ -557,21 +812,28 @@ This is normal if you haven't configured a search API key yet. PicoClaw will pro
To enable web search:
-1. Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month)
-2. Add to `~/.picoclaw/config.json`:
+1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results.
+2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required).
- ```json
- {
- "tools": {
- "web": {
- "search": {
- "api_key": "YOUR_BRAVE_API_KEY",
- "max_results": 5
- }
- }
- }
- }
- ```
+Add the key to `~/.picoclaw/config.json` if using Brave:
+
+```json
+{
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
### Getting content filtering errors
@@ -585,9 +847,9 @@ This happens when another instance of the bot is running. Make sure only one `pi
## 📝 API Key Comparison
-| Service | Free Tier | Use Case |
-|---------|-----------|-----------|
-| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
-| **Zhipu** | 200K tokens/month | Best for Chinese users |
-| **Brave Search** | 2000 queries/month | Web search functionality |
-| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
+| Service | Free Tier | Use Case |
+| ---------------- | ------------------- | ------------------------------------- |
+| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
+| **Zhipu** | 200K tokens/month | Best for Chinese users |
+| **Brave Search** | 2000 queries/month | Web search functionality |
+| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
diff --git a/README.zh.md b/README.zh.md
new file mode 100644
index 000000000..5a1c3c50b
--- /dev/null
+++ b/README.zh.md
@@ -0,0 +1,719 @@
+
+
+|
+
+ |
+
+
+ |
+
+
+## 🦾 演示
+
+### 🛠️ 标准助手工作流
+
+🧩 全栈工程师模式 |
+🗂️ 日志与规划管理 |
+🔎 网络搜索与学习 |
+
|---|---|---|
|
+
|
+
|
+
| 开发 • 部署 • 扩展 | +日程 • 自动化 • 记忆 | +发现 • 洞察 • 趋势 | +
+
+## 🐛 疑难解答 (Troubleshooting)
+
+### 网络搜索提示 "API 配置问题"
+
+如果您尚未配置搜索 API Key,这是正常的。PicoClaw 会提供手动搜索的帮助链接。
+
+启用网络搜索:
+
+1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
+2. 添加到 `~/.picoclaw/config.json`:
+```json
+{
+ "tools": {
+ "web": {
+ "search": {
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ }
+ }
+ }
+}
+
+```
+
+
+
+### 遇到内容过滤错误 (Content Filtering Errors)
+
+某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
+
+### Telegram bot 提示 "Conflict: terminated by other getUpdates"
+
+这表示有另一个机器人实例正在运行。请确保同一时间只有一个 `picoclaw gateway` 进程在运行。
+
+---
+
+## 📝 API Key 对比
+
+| 服务 | 免费层级 | 适用场景 |
+| --- | --- | --- |
+| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
+| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
+| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
+| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
\ No newline at end of file
diff --git a/assets/wechat.png b/assets/wechat.png
index 73b09da68..d62c8d09d 100644
Binary files a/assets/wechat.png and b/assets/wechat.png differ
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index dff9c89d7..cd2aef2ac 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -9,9 +9,11 @@ package main
import (
"bufio"
"context"
+ "embed"
"fmt"
"io"
"net/http"
+ "io/fs"
"os"
"os/signal"
"path/filepath"
@@ -27,32 +29,57 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/health"
+ "github.com/sipeed/picoclaw/pkg/devices"
"github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/migrate"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills"
+ "github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/voice"
)
+//go:generate cp -r ../../workspace .
+//go:embed workspace
+var embeddedFiles embed.FS
+
var (
version = "dev"
+ gitCommit string
buildTime string
goVersion string
)
const logo = "🦞"
-func printVersion() {
- fmt.Printf("%s picoclaw %s\n", logo, version)
- if buildTime != "" {
- fmt.Printf(" Build: %s\n", buildTime)
+// formatVersion returns the version string with optional git commit
+func formatVersion() string {
+ v := version
+ if gitCommit != "" {
+ v += fmt.Sprintf(" (git: %s)", gitCommit)
}
- goVer := goVersion
+ return v
+}
+
+// formatBuildInfo returns build time and go version info
+func formatBuildInfo() (build string, goVer string) {
+ if buildTime != "" {
+ build = buildTime
+ }
+ goVer = goVersion
if goVer == "" {
goVer = runtime.Version()
}
+ return
+}
+
+func printVersion() {
+ fmt.Printf("%s picoclaw %s\n", logo, formatVersion())
+ build, goVer := formatBuildInfo()
+ if build != "" {
+ fmt.Printf(" Build: %s\n", build)
+ }
if goVer != "" {
fmt.Printf(" Go: %s\n", goVer)
}
@@ -210,10 +237,6 @@ func onboard() {
}
workspace := cfg.WorkspacePath()
- os.MkdirAll(workspace, 0755)
- os.MkdirAll(filepath.Join(workspace, "memory"), 0755)
- os.MkdirAll(filepath.Join(workspace, "skills"), 0755)
-
createWorkspaceTemplates(workspace)
fmt.Printf("%s picoclaw is ready!\n", logo)
@@ -223,170 +246,57 @@ func onboard() {
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
}
+func copyEmbeddedToTarget(targetDir string) error {
+ // Ensure target directory exists
+ if err := os.MkdirAll(targetDir, 0755); err != nil {
+ return fmt.Errorf("Failed to create target directory: %w", err)
+ }
+
+ // Walk through all files in embed.FS
+ err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // Skip directories
+ if d.IsDir() {
+ return nil
+ }
+
+ // Read embedded file
+ data, err := embeddedFiles.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
+ }
+
+ new_path, err := filepath.Rel("workspace", path)
+ if err != nil {
+ return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
+ }
+
+ // Build target file path
+ targetPath := filepath.Join(targetDir, new_path)
+
+ // Ensure target file's directory exists
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
+ return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
+ }
+
+ // Write file
+ if err := os.WriteFile(targetPath, data, 0644); err != nil {
+ return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
+ }
+
+ return nil
+ })
+
+ return err
+}
+
func createWorkspaceTemplates(workspace string) {
- templates := map[string]string{
- "AGENTS.md": `# Agent Instructions
-
-You are a helpful AI assistant. Be concise, accurate, and friendly.
-
-## Guidelines
-
-- Always explain what you're doing before taking actions
-- Ask for clarification when request is ambiguous
-- Use tools to help accomplish tasks
-- Remember important information in your memory files
-- Be proactive and helpful
-- Learn from user feedback
-`,
- "SOUL.md": `# Soul
-
-I am picoclaw, a lightweight AI assistant powered by AI.
-
-## Personality
-
-- Helpful and friendly
-- Concise and to the point
-- Curious and eager to learn
-- Honest and transparent
-
-## Values
-
-- Accuracy over speed
-- User privacy and safety
-- Transparency in actions
-- Continuous improvement
-`,
- "USER.md": `# User
-
-Information about user goes here.
-
-## Preferences
-
-- Communication style: (casual/formal)
-- Timezone: (your timezone)
-- Language: (your preferred language)
-
-## Personal Information
-
-- Name: (optional)
-- Location: (optional)
-- Occupation: (optional)
-
-## Learning Goals
-
-- What the user wants to learn from AI
-- Preferred interaction style
-- Areas of interest
-`,
- "IDENTITY.md": `# Identity
-
-## Name
-PicoClaw 🦞
-
-## Description
-Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
-
-## Version
-0.1.0
-
-## Purpose
-- Provide intelligent AI assistance with minimal resource usage
-- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.)
-- Enable easy customization through skills system
-- Run on minimal hardware ($10 boards, <10MB RAM)
-
-## Capabilities
-
-- Web search and content fetching
-- File system operations (read, write, edit)
-- Shell command execution
-- Multi-channel messaging (Telegram, WhatsApp, Feishu)
-- Skill-based extensibility
-- Memory and context management
-
-## Philosophy
-
-- Simplicity over complexity
-- Performance over features
-- User control and privacy
-- Transparent operation
-- Community-driven development
-
-## Goals
-
-- Provide a fast, lightweight AI assistant
-- Support offline-first operation where possible
-- Enable easy customization and extension
-- Maintain high quality responses
-- Run efficiently on constrained hardware
-
-## License
-MIT License - Free and open source
-
-## Repository
-https://github.com/sipeed/picoclaw
-
-## Contact
-Issues: https://github.com/sipeed/picoclaw/issues
-Discussions: https://github.com/sipeed/picoclaw/discussions
-
----
-
-"Every bit helps, every bit matters."
-- Picoclaw
-`,
- }
-
- for filename, content := range templates {
- filePath := filepath.Join(workspace, filename)
- if _, err := os.Stat(filePath); os.IsNotExist(err) {
- os.WriteFile(filePath, []byte(content), 0644)
- fmt.Printf(" Created %s\n", filename)
- }
- }
-
- memoryDir := filepath.Join(workspace, "memory")
- os.MkdirAll(memoryDir, 0755)
- memoryFile := filepath.Join(memoryDir, "MEMORY.md")
- if _, err := os.Stat(memoryFile); os.IsNotExist(err) {
- memoryContent := `# Long-term Memory
-
-This file stores important information that should persist across sessions.
-
-## User Information
-
-(Important facts about user)
-
-## Preferences
-
-(User preferences learned over time)
-
-## Important Notes
-
-(Things to remember)
-
-## Configuration
-
-- Model preferences
-- Channel settings
-- Skills enabled
-`
- os.WriteFile(memoryFile, []byte(memoryContent), 0644)
- fmt.Println(" Created memory/MEMORY.md")
-
- skillsDir := filepath.Join(workspace, "skills")
- if _, err := os.Stat(skillsDir); os.IsNotExist(err) {
- os.MkdirAll(skillsDir, 0755)
- fmt.Println(" Created skills/")
- }
- }
-
- for filename, content := range templates {
- filePath := filepath.Join(workspace, filename)
- if _, err := os.Stat(filePath); os.IsNotExist(err) {
- os.WriteFile(filePath, []byte(content), 0644)
- fmt.Printf(" Created %s\n", filename)
- }
+ err := copyEmbeddedToTarget(workspace)
+ if err != nil {
+ fmt.Printf("Error copying workspace templates: %v\n", err)
}
}
@@ -656,10 +566,27 @@ func gatewayCmd() {
heartbeatService := heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
- nil,
- 30*60,
- true,
+ cfg.Heartbeat.Interval,
+ cfg.Heartbeat.Enabled,
)
+ heartbeatService.SetBus(msgBus)
+ heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
+ // Use cli:direct as fallback if no valid channel
+ if channel == "" || chatID == "" {
+ channel, chatID = "cli", "direct"
+ }
+ // Use ProcessHeartbeat - no session history, each heartbeat is independent
+ response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
+ if err != nil {
+ return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
+ }
+ if response == "HEARTBEAT_OK" {
+ return tools.SilentResult("Heartbeat OK")
+ }
+ // For heartbeat, always return silent - the subagent result will be
+ // sent to user via processSystemMessage when the async task completes
+ return tools.SilentResult(response)
+ })
channelManager, err := channels.NewManager(cfg, msgBus)
if err != nil {
@@ -717,6 +644,18 @@ func gatewayCmd() {
}
fmt.Println("✓ Heartbeat service started")
+ stateManager := state.NewManager(cfg.WorkspacePath())
+ deviceService := devices.NewService(devices.Config{
+ Enabled: cfg.Devices.Enabled,
+ MonitorUSB: cfg.Devices.MonitorUSB,
+ }, stateManager)
+ deviceService.SetBus(msgBus)
+ if err := deviceService.Start(ctx); err != nil {
+ fmt.Printf("Error starting device service: %v\n", err)
+ } else if cfg.Devices.Enabled {
+ fmt.Println("✓ Device event service started")
+ }
+
if err := channelManager.StartAll(ctx); err != nil {
fmt.Printf("Error starting channels: %v\n", err)
}
@@ -738,6 +677,7 @@ func gatewayCmd() {
fmt.Println("\nShutting down...")
cancel()
healthServer.Stop(context.Background())
+ deviceService.Stop()
heartbeatService.Stop()
cronService.Stop()
agentLoop.Stop()
@@ -754,7 +694,13 @@ func statusCmd() {
configPath := getConfigPath()
- fmt.Printf("%s picoclaw Status\n\n", logo)
+ fmt.Printf("%s picoclaw Status\n", logo)
+ fmt.Printf("Version: %s\n", formatVersion())
+ build, _ := formatBuildInfo()
+ if build != "" {
+ fmt.Printf("Build: %s\n", build)
+ }
+ fmt.Println()
if _, err := os.Stat(configPath); err == nil {
fmt.Println("Config:", configPath, "✓")
@@ -1275,53 +1221,6 @@ func cronEnableCmd(storePath string, disable bool) {
}
}
-func skillsCmd() {
- if len(os.Args) < 3 {
- skillsHelp()
- return
- }
-
- subcommand := os.Args[2]
-
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- os.Exit(1)
- }
-
- workspace := cfg.WorkspacePath()
- installer := skills.NewSkillInstaller(workspace)
- // 获取全局配置目录和内置 skills 目录
- globalDir := filepath.Dir(getConfigPath())
- globalSkillsDir := filepath.Join(globalDir, "skills")
- builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
- skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
-
- switch subcommand {
- case "list":
- skillsListCmd(skillsLoader)
- case "install":
- skillsInstallCmd(installer)
- case "remove", "uninstall":
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw skills remove Content here
")) + })) + defer server.Close() + + tool := NewWebFetchTool(50000) + ctx := context.Background() + args := map[string]interface{}{ + "url": server.URL, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForUser should contain the fetched content + if !strings.Contains(result.ForUser, "Test Page") { + t.Errorf("Expected ForUser to contain 'Test Page', got: %s", result.ForUser) + } + + // ForLLM should contain summary + if !strings.Contains(result.ForLLM, "bytes") && !strings.Contains(result.ForLLM, "extractor") { + t.Errorf("Expected ForLLM to contain summary, got: %s", result.ForLLM) + } +} + +// TestWebTool_WebFetch_JSON verifies JSON content handling +func TestWebTool_WebFetch_JSON(t *testing.T) { + testData := map[string]string{"key": "value", "number": "123"} + expectedJSON, _ := json.MarshalIndent(testData, "", " ") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(expectedJSON) + })) + defer server.Close() + + tool := NewWebFetchTool(50000) + ctx := context.Background() + args := map[string]interface{}{ + "url": server.URL, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForUser should contain formatted JSON + if !strings.Contains(result.ForUser, "key") && !strings.Contains(result.ForUser, "value") { + t.Errorf("Expected ForUser to contain JSON data, got: %s", result.ForUser) + } +} + +// TestWebTool_WebFetch_InvalidURL verifies error handling for invalid URL +func TestWebTool_WebFetch_InvalidURL(t *testing.T) { + tool := NewWebFetchTool(50000) + ctx := context.Background() + args := map[string]interface{}{ + "url": "not-a-valid-url", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error for invalid URL") + } + + // Should contain error message (either "invalid URL" or scheme error) + if !strings.Contains(result.ForLLM, "URL") && !strings.Contains(result.ForUser, "URL") { + t.Errorf("Expected error message for invalid URL, got ForLLM: %s", result.ForLLM) + } +} + +// TestWebTool_WebFetch_UnsupportedScheme verifies error handling for non-http URLs +func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { + tool := NewWebFetchTool(50000) + ctx := context.Background() + args := map[string]interface{}{ + "url": "ftp://example.com/file.txt", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error for unsupported URL scheme") + } + + // Should mention only http/https allowed + if !strings.Contains(result.ForLLM, "http/https") && !strings.Contains(result.ForUser, "http/https") { + t.Errorf("Expected scheme error message, got ForLLM: %s", result.ForLLM) + } +} + +// TestWebTool_WebFetch_MissingURL verifies error handling for missing URL +func TestWebTool_WebFetch_MissingURL(t *testing.T) { + tool := NewWebFetchTool(50000) + ctx := context.Background() + args := map[string]interface{}{} + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when URL is missing") + } + + // Should mention URL is required + if !strings.Contains(result.ForLLM, "url is required") && !strings.Contains(result.ForUser, "url is required") { + t.Errorf("Expected 'url is required' message, got ForLLM: %s", result.ForLLM) + } +} + +// TestWebTool_WebFetch_Truncation verifies content truncation +func TestWebTool_WebFetch_Truncation(t *testing.T) { + longContent := strings.Repeat("x", 20000) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(longContent)) + })) + defer server.Close() + + tool := NewWebFetchTool(1000) // Limit to 1000 chars + ctx := context.Background() + args := map[string]interface{}{ + "url": server.URL, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForUser should contain truncated content (not the full 20000 chars) + resultMap := make(map[string]interface{}) + json.Unmarshal([]byte(result.ForUser), &resultMap) + if text, ok := resultMap["text"].(string); ok { + if len(text) > 1100 { // Allow some margin + t.Errorf("Expected content to be truncated to ~1000 chars, got: %d", len(text)) + } + } + + // Should be marked as truncated + if truncated, ok := resultMap["truncated"].(bool); !ok || !truncated { + t.Errorf("Expected 'truncated' to be true in result") + } +} + +// TestWebTool_WebSearch_NoApiKey verifies that nil is returned when no provider is configured +func TestWebTool_WebSearch_NoApiKey(t *testing.T) { + tool := NewWebSearchTool(WebSearchToolOptions{BraveAPIKey: "", BraveMaxResults: 5}) + + // Should return nil when no provider is enabled + if tool != nil { + t.Errorf("Expected nil when no search provider is configured") + } +} + +// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query +func TestWebTool_WebSearch_MissingQuery(t *testing.T) { + tool := NewWebSearchTool(WebSearchToolOptions{BraveAPIKey: "test-key", BraveMaxResults: 5, BraveEnabled: true}) + ctx := context.Background() + args := map[string]interface{}{} + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when query is missing") + } +} + +// TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction +func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`Content
`)) + })) + defer server.Close() + + tool := NewWebFetchTool(50000) + ctx := context.Background() + args := map[string]interface{}{ + "url": server.URL, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForUser should contain extracted text (without script/style tags) + if !strings.Contains(result.ForUser, "Title") && !strings.Contains(result.ForUser, "Content") { + t.Errorf("Expected ForUser to contain extracted text, got: %s", result.ForUser) + } + + // Should NOT contain script or style tags + if strings.Contains(result.ForUser, "