Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,33 @@
"moonshot": {
"api_key": "sk-xxx",
"api_base": ""
},
"mistral": {
"api_key": "YOUR_MISTRAL_API_KEY",
"api_base": "https://api.mistral.ai/v1"
}
},
"tools": {
"web": {
"search": {
"brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
"duckduckgo": {
"enabled": true,
"max_results": 5
}
},
"firecrawl": {
"enabled": false,
"api_key": "YOUR_FIRECRAWL_API_KEY",
"api_base": "https://api.firecrawl.dev/v1"
},
"serpapi": {
"enabled": false,
"api_key": "YOUR_SERPAPI_KEY",
"max_results": 10
}
},
"heartbeat": {
Expand Down
10 changes: 10 additions & 0 deletions pkg/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
}
registry.Register(tools.NewWebFetchTool(50000))

// Firecrawl tool for advanced web scraping
if cfg.Tools.Firecrawl.Enabled && cfg.Tools.Firecrawl.APIKey != "" {
registry.Register(tools.NewFirecrawlTool(cfg.Tools.Firecrawl.APIKey, cfg.Tools.Firecrawl.APIBase))
}

// SerpAPI tool for Google search results
if cfg.Tools.SerpAPI.Enabled && cfg.Tools.SerpAPI.APIKey != "" {
registry.Register(tools.NewSerpAPITool(cfg.Tools.SerpAPI.APIKey, cfg.Tools.SerpAPI.MaxResults))
}

// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
registry.Register(tools.NewI2CTool())
registry.Register(tools.NewSPITool())
Expand Down
28 changes: 27 additions & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ type ProvidersConfig struct {
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
DeepSeek ProviderConfig `json:"deepseek"`
GitHubCopilot ProviderConfig `json:"github_copilot"`
Mistral ProviderConfig `json:"mistral"`
}

type ProviderConfig struct {
Expand Down Expand Up @@ -210,8 +211,22 @@ type WebToolsConfig struct {
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
}

type FirecrawlConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_FIRECRAWL_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_FIRECRAWL_API_KEY"`
APIBase string `json:"api_base" env:"PICOCLAW_TOOLS_FIRECRAWL_API_BASE"`
}

type SerpAPIConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_SERPAPI_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_SERPAPI_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_SERPAPI_MAX_RESULTS"`
}

type ToolsConfig struct {
Web WebToolsConfig `json:"web"`
Web WebToolsConfig `json:"web"`
Firecrawl FirecrawlConfig `json:"firecrawl"`
SerpAPI SerpAPIConfig `json:"serpapi"`
}

func DefaultConfig() *Config {
Expand Down Expand Up @@ -304,6 +319,7 @@ func DefaultConfig() *Config {
Nvidia: ProviderConfig{},
Moonshot: ProviderConfig{},
ShengSuanYun: ProviderConfig{},
Mistral: ProviderConfig{},
},
Gateway: GatewayConfig{
Host: "0.0.0.0",
Expand All @@ -321,6 +337,16 @@ func DefaultConfig() *Config {
MaxResults: 5,
},
},
Firecrawl: FirecrawlConfig{
Enabled: false,
APIKey: "",
APIBase: "https://api.firecrawl.dev/v1",
},
SerpAPI: SerpAPIConfig{
Enabled: false,
APIKey: "",
MaxResults: 10,
},
},
Heartbeat: HeartbeatConfig{
Enabled: true,
Expand Down
19 changes: 18 additions & 1 deletion pkg/providers/http_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
// 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" {
if prefix == "moonshot" || prefix == "nvidia" || prefix == "mistral" {
model = model[idx+1:]
}
}
Expand Down Expand Up @@ -322,6 +322,15 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
apiBase = "localhost:4321"
}
return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model)
case "mistral":
if cfg.Providers.Mistral.APIKey != "" {
apiKey = cfg.Providers.Mistral.APIKey
apiBase = cfg.Providers.Mistral.APIBase
proxy = cfg.Providers.Mistral.Proxy
if apiBase == "" {
apiBase = "https://api.mistral.ai/v1"
}
}

}

Expand Down Expand Up @@ -406,6 +415,14 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
apiBase = cfg.Providers.VLLM.APIBase
proxy = cfg.Providers.VLLM.Proxy

case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "":
apiKey = cfg.Providers.Mistral.APIKey
apiBase = cfg.Providers.Mistral.APIBase
proxy = cfg.Providers.Mistral.Proxy
if apiBase == "" {
apiBase = "https://api.mistral.ai/v1"
}

default:
if cfg.Providers.OpenRouter.APIKey != "" {
apiKey = cfg.Providers.OpenRouter.APIKey
Expand Down
34 changes: 34 additions & 0 deletions pkg/providers/mistral_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package providers

import (
"context"
"os"
"testing"
)

func TestMistralProvider_Integration(t *testing.T) {
apiKey := os.Getenv("MISTRAL_API_KEY")
if apiKey == "" {
t.Skip("MISTRAL_API_KEY not set")
}

provider := NewHTTPProvider(apiKey, "https://api.mistral.ai/v1", "")

messages := []Message{
{Role: "user", Content: "Say 'Hello from Mistral' in exactly 3 words"},
}

resp, err := provider.Chat(context.Background(), messages, nil, "mistral-tiny", map[string]interface{}{
"max_tokens": 50,
})

if err != nil {
t.Fatalf("Mistral chat failed: %v", err)
}

if resp.Content == "" {
t.Errorf("Expected non-empty response")
}

t.Logf("Mistral response: %s", resp.Content)
}
201 changes: 201 additions & 0 deletions pkg/tools/firecrawl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package tools

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)

type FirecrawlTool struct {
apiKey string
apiBase string
}

func NewFirecrawlTool(apiKey, apiBase string) *FirecrawlTool {
if apiBase == "" {
apiBase = "https://api.firecrawl.dev/v1"
}
return &FirecrawlTool{
apiKey: apiKey,
apiBase: apiBase,
}
}

func (t *FirecrawlTool) Name() string {
return "firecrawl"
}

func (t *FirecrawlTool) Description() string {
return "Scrape web pages and extract structured data using Firecrawl. Supports markdown extraction, screenshot capture, and structured data extraction with LLM."
}

func (t *FirecrawlTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{
"type": "string",
"description": "URL to scrape",
},
"formats": map[string]interface{}{
"type": "array",
"description": "Output formats (markdown, html, screenshot, links, extract)",
"items": map[string]interface{}{
"type": "string",
},
},
"only_main_content": map[string]interface{}{
"type": "boolean",
"description": "Extract only main content, excluding navigation, headers, footers",
},
"extract": map[string]interface{}{
"type": "object",
"description": "Schema for structured data extraction using LLM",
"properties": map[string]interface{}{
"prompt": map[string]interface{}{
"type": "string",
"description": "Prompt describing what to extract",
},
"schema": map[string]interface{}{
"type": "object",
"description": "JSON schema for structured output",
},
},
},
},
"required": []string{"url"},
}
}

func (t *FirecrawlTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
if t.apiKey == "" {
return ErrorResult("Firecrawl API key not configured")
}

url, ok := args["url"].(string)
if !ok || url == "" {
return ErrorResult("url is required")
}

// Build request body
requestBody := map[string]interface{}{
"url": url,
}

if formats, ok := args["formats"].([]interface{}); ok && len(formats) > 0 {
requestBody["formats"] = formats
} else {
// Default to markdown
requestBody["formats"] = []string{"markdown"}
}

if onlyMain, ok := args["only_main_content"].(bool); ok {
requestBody["onlyMainContent"] = onlyMain
}

if extract, ok := args["extract"].(map[string]interface{}); ok {
requestBody["extract"] = extract
}

jsonData, err := json.Marshal(requestBody)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to marshal request: %v", err))
}

req, err := http.NewRequestWithContext(ctx, "POST", t.apiBase+"/scrape", bytes.NewReader(jsonData))
if err != nil {
return ErrorResult(fmt.Sprintf("failed to create request: %v", err))
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+t.apiKey)

client := &http.Client{Timeout: 60 * 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))
}

if resp.StatusCode != http.StatusOK {
return ErrorResult(fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(body)))
}

var result struct {
Success bool `json:"success"`
Data struct {
Markdown string `json:"markdown"`
HTML string `json:"html"`
Metadata map[string]interface{} `json:"metadata"`
Extract map[string]interface{} `json:"extract"`
Screenshot string `json:"screenshot"`
Links []string `json:"links"`
} `json:"data"`
Error string `json:"error"`
}

if err := json.Unmarshal(body, &result); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse response: %v", err))
}

if !result.Success {
return ErrorResult(fmt.Sprintf("Firecrawl error: %s", result.Error))
}

// Build response
var output strings.Builder
output.WriteString(fmt.Sprintf("# Scraped: %s\n\n", url))

if result.Data.Metadata != nil {
if title, ok := result.Data.Metadata["title"].(string); ok && title != "" {
output.WriteString(fmt.Sprintf("**Title:** %s\n\n", title))
}
if description, ok := result.Data.Metadata["description"].(string); ok && description != "" {
output.WriteString(fmt.Sprintf("**Description:** %s\n\n", description))
}
}

if result.Data.Markdown != "" {
output.WriteString("## Content (Markdown)\n\n")
output.WriteString(result.Data.Markdown)
output.WriteString("\n\n")
}

if result.Data.Extract != nil && len(result.Data.Extract) > 0 {
output.WriteString("## Extracted Data\n\n")
extractJSON, _ := json.MarshalIndent(result.Data.Extract, "", " ")
output.WriteString("```json\n")
output.WriteString(string(extractJSON))
output.WriteString("\n```\n\n")
}

if len(result.Data.Links) > 0 {
output.WriteString(fmt.Sprintf("## Links Found: %d\n\n", len(result.Data.Links)))
for i, link := range result.Data.Links {
if i >= 20 { // Limit to 20 links
output.WriteString(fmt.Sprintf("\n... and %d more links\n", len(result.Data.Links)-20))
break
}
output.WriteString(fmt.Sprintf("- %s\n", link))
}
}

if result.Data.Screenshot != "" {
output.WriteString("\n**Screenshot:** [Base64 encoded image available]\n")
}

return &ToolResult{
ForLLM: output.String(),
ForUser: output.String(),
}
}
Loading