* refactor tools system
This commit is contained in:
parent
2c8416e658
commit
2b9e74007f
51 changed files with 2056 additions and 1479 deletions
|
|
@ -24,6 +24,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
cron_tool "github.com/sipeed/picoclaw/pkg/tools/cron"
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -232,14 +233,15 @@ func setupCronTool(
|
||||||
cronService := cron.NewCronService(cronStorePath, nil)
|
cronService := cron.NewCronService(cronStorePath, nil)
|
||||||
|
|
||||||
// Create and register CronTool
|
// Create and register CronTool
|
||||||
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
if cfg.Tools.Cron.Enabled {
|
||||||
agentLoop.RegisterTool(cronTool)
|
cronTool := cron_tool.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||||
|
agentLoop.RegisterTool(cronTool)
|
||||||
// Set the onJob handler
|
|
||||||
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
|
||||||
result := cronTool.ExecuteJob(context.Background(), job)
|
|
||||||
return result, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// Set the onJob handler
|
||||||
|
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
||||||
|
result := cronTool.ExecuteJob(context.Background(), job)
|
||||||
|
return result, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
return cronService
|
return cronService
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,13 +47,7 @@ func NewAgentInstance(
|
||||||
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
||||||
|
|
||||||
restrict := defaults.RestrictToWorkspace
|
restrict := defaults.RestrictToWorkspace
|
||||||
toolsRegistry := tools.NewToolRegistry()
|
toolsRegistry := tools.NewToolRegistry(cfg, workspace, restrict)
|
||||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
|
|
||||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
|
|
||||||
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
|
|
||||||
toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg))
|
|
||||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
|
|
||||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
|
|
||||||
|
|
||||||
sessionsDir := filepath.Join(workspace, "sessions")
|
sessionsDir := filepath.Join(workspace, "sessions")
|
||||||
sessionsManager := session.NewSessionManager(sessionsDir)
|
sessionsManager := session.NewSessionManager(sessionsDir)
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,10 @@ import (
|
||||||
"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/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/message"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/subagent"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -92,63 +93,31 @@ func registerSharedTools(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web tools
|
|
||||||
if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
|
||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
|
||||||
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
|
||||||
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
|
||||||
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
|
||||||
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
|
||||||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
|
||||||
Proxy: cfg.Tools.Web.Proxy,
|
|
||||||
}); searchTool != nil {
|
|
||||||
agent.Tools.Register(searchTool)
|
|
||||||
}
|
|
||||||
agent.Tools.Register(tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy))
|
|
||||||
|
|
||||||
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
|
||||||
agent.Tools.Register(tools.NewI2CTool())
|
|
||||||
agent.Tools.Register(tools.NewSPITool())
|
|
||||||
|
|
||||||
// Message tool
|
// Message tool
|
||||||
messageTool := tools.NewMessageTool()
|
if cfg.Tools.Message.Enabled {
|
||||||
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
messageTool := message.NewMessageTool()
|
||||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
||||||
Channel: channel,
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||||
ChatID: chatID,
|
Channel: channel,
|
||||||
Content: content,
|
ChatID: chatID,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
return nil
|
agent.Tools.Register(messageTool)
|
||||||
})
|
}
|
||||||
agent.Tools.Register(messageTool)
|
|
||||||
|
|
||||||
// Skill discovery and installation tools
|
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
|
||||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
|
||||||
})
|
|
||||||
searchCache := skills.NewSearchCache(
|
|
||||||
cfg.Tools.Skills.SearchCache.MaxSize,
|
|
||||||
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
|
||||||
)
|
|
||||||
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
|
||||||
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
|
||||||
|
|
||||||
// Spawn tool with allowlist checker
|
// Spawn tool with allowlist checker
|
||||||
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
|
if cfg.Tools.Spawn.Enabled {
|
||||||
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
subagentManager := subagent.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
|
||||||
spawnTool := tools.NewSpawnTool(subagentManager)
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||||
currentAgentID := agentID
|
spawnTool := subagent.NewSpawnTool(subagentManager)
|
||||||
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
currentAgentID := agentID
|
||||||
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
||||||
})
|
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
||||||
agent.Tools.Register(spawnTool)
|
})
|
||||||
|
agent.Tools.Register(spawnTool)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,7 +147,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
if defaultAgent != nil {
|
if defaultAgent != nil {
|
||||||
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
if tool, ok := defaultAgent.Tools.Get("message"); ok {
|
||||||
if mt, ok := tool.(*tools.MessageTool); ok {
|
if mt, ok := tool.(*message.MessageTool); ok {
|
||||||
alreadySent = mt.HasSentInRound()
|
alreadySent = mt.HasSentInRound()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -452,6 +452,7 @@ type PerplexityConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebToolsConfig struct {
|
type WebToolsConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_ENABLED"`
|
||||||
Brave BraveConfig `json:"brave"`
|
Brave BraveConfig `json:"brave"`
|
||||||
Tavily TavilyConfig `json:"tavily"`
|
Tavily TavilyConfig `json:"tavily"`
|
||||||
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
||||||
|
|
@ -461,19 +462,53 @@ type WebToolsConfig struct {
|
||||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronToolsConfig struct {
|
type CronToolConfig struct {
|
||||||
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_CRON_ENABLED"`
|
||||||
|
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_ENABLED"` // Default env var, can be overridden per tool
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecConfig struct {
|
type ExecConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_EXEC_ENABLED"`
|
||||||
EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
|
EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
|
||||||
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
Web WebToolsConfig `json:"web"`
|
// Web tools
|
||||||
Cron CronToolsConfig `json:"cron"`
|
Web WebToolsConfig `json:"web"`
|
||||||
Exec ExecConfig `json:"exec"`
|
|
||||||
|
// Cron tools
|
||||||
|
Cron CronToolConfig `json:"cron"`
|
||||||
|
|
||||||
|
// File tools
|
||||||
|
ReadFile ToolConfig `json:"read_file" env:"PICOCLAW_TOOLS_READ_FILE_ENABLED"`
|
||||||
|
WriteFile ToolConfig `json:"write_file" env:"PICOCLAW_TOOLS_WRITE_FILE_ENABLED"`
|
||||||
|
EditFile ToolConfig `json:"edit_file" env:"PICOCLAW_TOOLS_EDIT_FILE_ENABLED"`
|
||||||
|
AppendFile ToolConfig `json:"append_file" env:"PICOCLAW_TOOLS_APPEND_FILE_ENABLED"`
|
||||||
|
ListDir ToolConfig `json:"list_dir" env:"PICOCLAW_TOOLS_LIST_DIR_ENABLED"`
|
||||||
|
|
||||||
|
// Exec tool
|
||||||
|
Exec ExecConfig `json:"exec"`
|
||||||
|
|
||||||
|
// Skills tools
|
||||||
|
FindSkills ToolConfig `json:"find_skills" env:"PICOCLAW_TOOLS_FIND_SKILLS_ENABLED"`
|
||||||
|
InstallSkill ToolConfig `json:"install_skill" env:"PICOCLAW_TOOLS_INSTALL_SKILL_ENABLED"`
|
||||||
|
|
||||||
|
// Subagent tools
|
||||||
|
Spawn ToolConfig `json:"spawn" env:"PICOCLAW_TOOLS_SPAWN_ENABLED"`
|
||||||
|
|
||||||
|
// Message tool
|
||||||
|
Message ToolConfig `json:"message" env:"PICOCLAW_TOOLS_MESSAGE_ENABLED"`
|
||||||
|
|
||||||
|
// Hardware tools
|
||||||
|
I2C ToolConfig `json:"i2c" env:"PICOCLAW_TOOLS_I2C_ENABLED"`
|
||||||
|
SPI ToolConfig `json:"spi" env:"PICOCLAW_TOOLS_SPI_ENABLED"`
|
||||||
|
|
||||||
|
// Skills configuration (registry, cache, etc.)
|
||||||
Skills SkillsToolsConfig `json:"skills"`
|
Skills SkillsToolsConfig `json:"skills"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -293,12 +293,53 @@ func DefaultConfig() *Config {
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Cron: CronToolsConfig{
|
Cron: CronToolConfig{
|
||||||
|
Enabled: true,
|
||||||
ExecTimeoutMinutes: 5,
|
ExecTimeoutMinutes: 5,
|
||||||
},
|
},
|
||||||
|
// File tools - each individually configurable
|
||||||
|
ReadFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
WriteFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
EditFile: ToolConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
AppendFile: ToolConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
ListDir: ToolConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
// Exec tool
|
||||||
Exec: ExecConfig{
|
Exec: ExecConfig{
|
||||||
|
Enabled: true,
|
||||||
EnableDenyPatterns: true,
|
EnableDenyPatterns: true,
|
||||||
},
|
},
|
||||||
|
// Skills tools
|
||||||
|
FindSkills: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
InstallSkill: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
// Subagent tools
|
||||||
|
Spawn: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
// Message tool
|
||||||
|
Message: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
// Hardware tools
|
||||||
|
I2C: ToolConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
SPI: ToolConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
Skills: SkillsToolsConfig{
|
Skills: SkillsToolsConfig{
|
||||||
Registries: SkillsRegistriesConfig{
|
Registries: SkillsRegistriesConfig{
|
||||||
ClawHub: ClawHubRegistryConfig{
|
ClawHub: ClawHubRegistryConfig{
|
||||||
|
|
|
||||||
77
pkg/tools/append_file/append_file.go
Normal file
77
pkg/tools/append_file/append_file.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package append_file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppendFileTool struct {
|
||||||
|
fs common.FileSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool {
|
||||||
|
var fs common.FileSystem
|
||||||
|
if restrict {
|
||||||
|
fs = &common.SandboxFs{Workspace: workspace}
|
||||||
|
} else {
|
||||||
|
fs = &common.HostFs{}
|
||||||
|
}
|
||||||
|
return &AppendFileTool{fs: fs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AppendFileTool) Name() string {
|
||||||
|
return "append_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AppendFileTool) Description() string {
|
||||||
|
return "Append content to the end of a file"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AppendFileTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The file path to append to",
|
||||||
|
},
|
||||||
|
"content": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "The content to append",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"path", "content"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
|
path, ok := args["path"].(string)
|
||||||
|
if !ok {
|
||||||
|
return common.ErrorResult("path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
content, ok := args["content"].(string)
|
||||||
|
if !ok {
|
||||||
|
return common.ErrorResult("content is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := appendFile(t.fs, path, content); err != nil {
|
||||||
|
return common.ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
return common.SilentResult(fmt.Sprintf("Appended to %s", path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendFile reads the existing content (if any) via sysFs, appends new content, and writes back.
|
||||||
|
func appendFile(sysFs common.FileSystem, path, appendContent string) error {
|
||||||
|
content, err := sysFs.ReadFile(path)
|
||||||
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newContent := append(content, []byte(appendContent)...)
|
||||||
|
return sysFs.WriteFile(path, newContent)
|
||||||
|
}
|
||||||
103
pkg/tools/append_file/append_file_test.go
Normal file
103
pkg/tools/append_file/append_file_test.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
package append_file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAppendFileTool_AppendToExisting verifies appending to an existing file
|
||||||
|
func TestAppendFileTool_AppendToExisting(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
|
os.WriteFile(testFile, []byte("Hello World"), 0o644)
|
||||||
|
|
||||||
|
tool := NewAppendFileTool(tmpDir, true)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"content": "\nAppended text",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
assert.False(t, result.IsError, "Expected success, got error: %s", result.ForLLM)
|
||||||
|
assert.True(t, result.Silent, "Expected Silent=true for AppendFile")
|
||||||
|
|
||||||
|
content, err := os.ReadFile(testFile)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, string(content), "Appended text")
|
||||||
|
assert.Contains(t, string(content), "Hello World")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppendFileTool_AppendToNonExistent verifies appending to a non-existent file creates it
|
||||||
|
func TestAppendFileTool_AppendToNonExistent(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "newfile.txt")
|
||||||
|
|
||||||
|
tool := NewAppendFileTool(tmpDir, true)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"content": "First content",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
assert.False(t, result.IsError, "Expected success, got error: %s", result.ForLLM)
|
||||||
|
|
||||||
|
content, err := os.ReadFile(testFile)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "First content", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppendFileTool_MissingPath verifies error handling for missing path
|
||||||
|
func TestAppendFileTool_MissingPath(t *testing.T) {
|
||||||
|
tool := NewAppendFileTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"content": "Some content",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
assert.True(t, result.IsError)
|
||||||
|
assert.Contains(t, result.ForLLM, "path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppendFileTool_MissingContent verifies error handling for missing content
|
||||||
|
func TestAppendFileTool_MissingContent(t *testing.T) {
|
||||||
|
tool := NewAppendFileTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": "/tmp/test.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
assert.True(t, result.IsError)
|
||||||
|
assert.Contains(t, result.ForLLM, "content is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppendFileTool_RestrictedMode verifies access control
|
||||||
|
func TestAppendFileTool_RestrictedMode(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
|
os.WriteFile(testFile, []byte("Original"), 0o644)
|
||||||
|
|
||||||
|
tool := NewAppendFileTool(tmpDir, true)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Try to append to a file outside the workspace
|
||||||
|
args := map[string]any{
|
||||||
|
"path": "/etc/passwd",
|
||||||
|
"content": "Malicious content",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
assert.True(t, result.IsError)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package tools
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -13,7 +12,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// validatePath ensures the given path is within the workspace if restrict is true.
|
// validatePath ensures the given path is within the workspace if restrict is true.
|
||||||
func validatePath(path, workspace string, restrict bool) (string, error) {
|
func ValidatePath(path, workspace string, restrict bool) (string, error) {
|
||||||
if workspace == "" {
|
if workspace == "" {
|
||||||
return path, fmt.Errorf("workspace is not defined")
|
return path, fmt.Errorf("workspace is not defined")
|
||||||
}
|
}
|
||||||
|
|
@ -83,183 +82,18 @@ func isWithinWorkspace(candidate, workspace string) bool {
|
||||||
return err == nil && filepath.IsLocal(rel)
|
return err == nil && filepath.IsLocal(rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReadFileTool struct {
|
// FileSystem abstracts reading, writing, and listing files, allowing both
|
||||||
fs fileSystem
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool {
|
|
||||||
var fs fileSystem
|
|
||||||
if restrict {
|
|
||||||
fs = &sandboxFs{workspace: workspace}
|
|
||||||
} else {
|
|
||||||
fs = &hostFs{}
|
|
||||||
}
|
|
||||||
return &ReadFileTool{fs: fs}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *ReadFileTool) Name() string {
|
|
||||||
return "read_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *ReadFileTool) Description() string {
|
|
||||||
return "Read the contents of a file"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *ReadFileTool) Parameters() map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"path": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"description": "Path to the file to read",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []string{"path"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
|
||||||
path, ok := args["path"].(string)
|
|
||||||
if !ok {
|
|
||||||
return ErrorResult("path is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := t.fs.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return ErrorResult(err.Error())
|
|
||||||
}
|
|
||||||
return NewToolResult(string(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
type WriteFileTool struct {
|
|
||||||
fs fileSystem
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool {
|
|
||||||
var fs fileSystem
|
|
||||||
if restrict {
|
|
||||||
fs = &sandboxFs{workspace: workspace}
|
|
||||||
} else {
|
|
||||||
fs = &hostFs{}
|
|
||||||
}
|
|
||||||
return &WriteFileTool{fs: fs}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *WriteFileTool) Name() string {
|
|
||||||
return "write_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *WriteFileTool) Description() string {
|
|
||||||
return "Write content to a file"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *WriteFileTool) Parameters() map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"path": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"description": "Path to the file to write",
|
|
||||||
},
|
|
||||||
"content": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"description": "Content to write to the file",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []string{"path", "content"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
|
||||||
path, ok := args["path"].(string)
|
|
||||||
if !ok {
|
|
||||||
return ErrorResult("path is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
content, ok := args["content"].(string)
|
|
||||||
if !ok {
|
|
||||||
return ErrorResult("content is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := t.fs.WriteFile(path, []byte(content)); err != nil {
|
|
||||||
return ErrorResult(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return SilentResult(fmt.Sprintf("File written: %s", path))
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListDirTool struct {
|
|
||||||
fs fileSystem
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewListDirTool(workspace string, restrict bool) *ListDirTool {
|
|
||||||
var fs fileSystem
|
|
||||||
if restrict {
|
|
||||||
fs = &sandboxFs{workspace: workspace}
|
|
||||||
} else {
|
|
||||||
fs = &hostFs{}
|
|
||||||
}
|
|
||||||
return &ListDirTool{fs: fs}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *ListDirTool) Name() string {
|
|
||||||
return "list_dir"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *ListDirTool) Description() string {
|
|
||||||
return "List files and directories in a path"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *ListDirTool) Parameters() map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"path": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"description": "Path to list",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []string{"path"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
|
||||||
path, ok := args["path"].(string)
|
|
||||||
if !ok {
|
|
||||||
path = "."
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := t.fs.ReadDir(path)
|
|
||||||
if err != nil {
|
|
||||||
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
|
|
||||||
}
|
|
||||||
return formatDirEntries(entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDirEntries(entries []os.DirEntry) *ToolResult {
|
|
||||||
var result strings.Builder
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() {
|
|
||||||
result.WriteString("DIR: " + entry.Name() + "\n")
|
|
||||||
} else {
|
|
||||||
result.WriteString("FILE: " + entry.Name() + "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return NewToolResult(result.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileSystem abstracts reading, writing, and listing files, allowing both
|
|
||||||
// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface.
|
// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface.
|
||||||
type fileSystem interface {
|
type FileSystem interface {
|
||||||
ReadFile(path string) ([]byte, error)
|
ReadFile(path string) ([]byte, error)
|
||||||
WriteFile(path string, data []byte) error
|
WriteFile(path string, data []byte) error
|
||||||
ReadDir(path string) ([]os.DirEntry, error)
|
ReadDir(path string) ([]os.DirEntry, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
|
// HostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
|
||||||
type hostFs struct{}
|
type HostFs struct{}
|
||||||
|
|
||||||
func (h *hostFs) ReadFile(path string) ([]byte, error) {
|
func (h *HostFs) ReadFile(path string) ([]byte, error) {
|
||||||
content, err := os.ReadFile(path)
|
content, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
|
@ -273,33 +107,33 @@ func (h *hostFs) ReadFile(path string) ([]byte, error) {
|
||||||
return content, nil
|
return content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
|
func (h *HostFs) ReadDir(path string) ([]os.DirEntry, error) {
|
||||||
return os.ReadDir(path)
|
return os.ReadDir(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *hostFs) WriteFile(path string, data []byte) error {
|
func (h *HostFs) WriteFile(path string, data []byte) error {
|
||||||
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
// Using 0o600 (owner read/write only) for secure default permissions.
|
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||||
return fileutil.WriteFileAtomic(path, data, 0o600)
|
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
|
// SandboxFs is a sandboxed FileSystem that operates within a strictly defined workspace using os.Root.
|
||||||
type sandboxFs struct {
|
type SandboxFs struct {
|
||||||
workspace string
|
Workspace string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error {
|
func (r *SandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error {
|
||||||
if r.workspace == "" {
|
if r.Workspace == "" {
|
||||||
return fmt.Errorf("workspace is not defined")
|
return fmt.Errorf("workspace is not defined")
|
||||||
}
|
}
|
||||||
|
|
||||||
root, err := os.OpenRoot(r.workspace)
|
root, err := os.OpenRoot(r.Workspace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open workspace: %w", err)
|
return fmt.Errorf("failed to open workspace: %w", err)
|
||||||
}
|
}
|
||||||
defer root.Close()
|
defer root.Close()
|
||||||
|
|
||||||
relPath, err := getSafeRelPath(r.workspace, path)
|
relPath, err := getSafeRelPath(r.Workspace, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -307,7 +141,7 @@ func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string)
|
||||||
return fn(root, relPath)
|
return fn(root, relPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *sandboxFs) ReadFile(path string) ([]byte, error) {
|
func (r *SandboxFs) ReadFile(path string) ([]byte, error) {
|
||||||
var content []byte
|
var content []byte
|
||||||
err := r.execute(path, func(root *os.Root, relPath string) error {
|
err := r.execute(path, func(root *os.Root, relPath string) error {
|
||||||
fileContent, err := root.ReadFile(relPath)
|
fileContent, err := root.ReadFile(relPath)
|
||||||
|
|
@ -328,7 +162,7 @@ func (r *sandboxFs) ReadFile(path string) ([]byte, error) {
|
||||||
return content, err
|
return content, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *sandboxFs) WriteFile(path string, data []byte) error {
|
func (r *SandboxFs) WriteFile(path string, data []byte) error {
|
||||||
return r.execute(path, func(root *os.Root, relPath string) error {
|
return r.execute(path, func(root *os.Root, relPath string) error {
|
||||||
dir := filepath.Dir(relPath)
|
dir := filepath.Dir(relPath)
|
||||||
if dir != "." && dir != "/" {
|
if dir != "." && dir != "/" {
|
||||||
|
|
@ -381,7 +215,7 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) {
|
func (r *SandboxFs) ReadDir(path string) ([]os.DirEntry, error) {
|
||||||
var entries []os.DirEntry
|
var entries []os.DirEntry
|
||||||
err := r.execute(path, func(root *os.Root, relPath string) error {
|
err := r.execute(path, func(root *os.Root, relPath string) error {
|
||||||
dirEntries, err := fs.ReadDir(root.FS(), relPath)
|
dirEntries, err := fs.ReadDir(root.FS(), relPath)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package common
|
||||||
|
|
||||||
import "encoding/json"
|
import "encoding/json"
|
||||||
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package tools
|
package common
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
// Tool is the interface that all tools must implement.
|
// Tool is the interface that all tools must implement.
|
||||||
type Tool interface {
|
type Tool interface {
|
||||||
|
|
@ -68,14 +70,3 @@ type AsyncTool interface {
|
||||||
// The callback will be called from a goroutine and should handle thread-safety if needed.
|
// The callback will be called from a goroutine and should handle thread-safety if needed.
|
||||||
SetCallback(cb AsyncCallback)
|
SetCallback(cb AsyncCallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ToolToSchema(tool Tool) map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"type": "function",
|
|
||||||
"function": map[string]any{
|
|
||||||
"name": tool.Name(),
|
|
||||||
"description": tool.Description(),
|
|
||||||
"parameters": tool.Parameters(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
64
pkg/tools/common/web.go
Normal file
64
pkg/tools/common/web.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-compiled regexes for HTML text extraction
|
||||||
|
var (
|
||||||
|
ReScript = regexp.MustCompile(`<script[\s\S]*?</script>`)
|
||||||
|
ReStyle = regexp.MustCompile(`<style[\s\S]*?</style>`)
|
||||||
|
ReTags = regexp.MustCompile(`<[^>]+>`)
|
||||||
|
ReWhitespace = regexp.MustCompile(`[^\S\n]+`)
|
||||||
|
ReBlankLines = regexp.MustCompile(`\n{3,}`)
|
||||||
|
|
||||||
|
// DuckDuckGo result extraction
|
||||||
|
ReDDGLink = regexp.MustCompile(`<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`)
|
||||||
|
ReDDGSnippet = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// createHTTPClient creates an HTTP client with optional proxy support
|
||||||
|
func CreateHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 10,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
DisableCompression: false,
|
||||||
|
TLSHandshakeTimeout: 15 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if proxyURL != "" {
|
||||||
|
proxy, err := url.Parse(proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid proxy URL: %w", err)
|
||||||
|
}
|
||||||
|
scheme := strings.ToLower(proxy.Scheme)
|
||||||
|
switch scheme {
|
||||||
|
case "http", "https", "socks5", "socks5h":
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"unsupported proxy scheme %q (supported: http, https, socks5, socks5h)",
|
||||||
|
proxy.Scheme,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if proxy.Host == "" {
|
||||||
|
return nil, fmt.Errorf("invalid proxy URL: missing host")
|
||||||
|
}
|
||||||
|
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
|
||||||
|
} else {
|
||||||
|
client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package cron
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -9,6 +9,8 @@ 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/cron"
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/exec"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,7 +24,7 @@ type CronTool struct {
|
||||||
cronService *cron.CronService
|
cronService *cron.CronService
|
||||||
executor JobExecutor
|
executor JobExecutor
|
||||||
msgBus *bus.MessageBus
|
msgBus *bus.MessageBus
|
||||||
execTool *ExecTool
|
execTool *exec.ExecTool
|
||||||
channel string
|
channel string
|
||||||
chatID string
|
chatID string
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
@ -34,7 +36,7 @@ func NewCronTool(
|
||||||
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
|
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
|
||||||
execTimeout time.Duration, config *config.Config,
|
execTimeout time.Duration, config *config.Config,
|
||||||
) *CronTool {
|
) *CronTool {
|
||||||
execTool := NewExecToolWithConfig(workspace, restrict, config)
|
execTool := exec.NewExecToolWithConfig(workspace, restrict, config)
|
||||||
execTool.SetTimeout(execTimeout)
|
execTool.SetTimeout(execTimeout)
|
||||||
return &CronTool{
|
return &CronTool{
|
||||||
cronService: cronService,
|
cronService: cronService,
|
||||||
|
|
@ -106,10 +108,10 @@ func (t *CronTool) SetContext(channel, chatID string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute runs the tool with the given arguments
|
// Execute runs the tool with the given arguments
|
||||||
func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *CronTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
action, ok := args["action"].(string)
|
action, ok := args["action"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("action is required")
|
return common.ErrorResult("action is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
|
|
@ -124,23 +126,23 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
case "disable":
|
case "disable":
|
||||||
return t.enableJob(args, false)
|
return t.enableJob(args, false)
|
||||||
default:
|
default:
|
||||||
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
|
return common.ErrorResult(fmt.Sprintf("unknown action: %s", action))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *CronTool) addJob(args map[string]any) *ToolResult {
|
func (t *CronTool) addJob(args map[string]any) *common.ToolResult {
|
||||||
t.mu.RLock()
|
t.mu.RLock()
|
||||||
channel := t.channel
|
channel := t.channel
|
||||||
chatID := t.chatID
|
chatID := t.chatID
|
||||||
t.mu.RUnlock()
|
t.mu.RUnlock()
|
||||||
|
|
||||||
if channel == "" || chatID == "" {
|
if channel == "" || chatID == "" {
|
||||||
return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.")
|
return common.ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.")
|
||||||
}
|
}
|
||||||
|
|
||||||
message, ok := args["message"].(string)
|
message, ok := args["message"].(string)
|
||||||
if !ok || message == "" {
|
if !ok || message == "" {
|
||||||
return ErrorResult("message is required for add")
|
return common.ErrorResult("message is required for add")
|
||||||
}
|
}
|
||||||
|
|
||||||
var schedule cron.CronSchedule
|
var schedule cron.CronSchedule
|
||||||
|
|
@ -169,7 +171,7 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
|
||||||
Expr: cronExpr,
|
Expr: cronExpr,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required")
|
return common.ErrorResult("one of at_seconds, every_seconds, or cron_expr is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read deliver parameter, default to true
|
// Read deliver parameter, default to true
|
||||||
|
|
@ -199,7 +201,7 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
|
||||||
chatID,
|
chatID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("Error adding job: %v", err))
|
return common.ErrorResult(fmt.Sprintf("Error adding job: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if command != "" {
|
if command != "" {
|
||||||
|
|
@ -208,14 +210,14 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
|
||||||
t.cronService.UpdateJob(job)
|
t.cronService.UpdateJob(job)
|
||||||
}
|
}
|
||||||
|
|
||||||
return SilentResult(fmt.Sprintf("Cron job added: %s (id: %s)", job.Name, job.ID))
|
return common.SilentResult(fmt.Sprintf("Cron job added: %s (id: %s)", job.Name, job.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *CronTool) listJobs() *ToolResult {
|
func (t *CronTool) listJobs() *common.ToolResult {
|
||||||
jobs := t.cronService.ListJobs(false)
|
jobs := t.cronService.ListJobs(false)
|
||||||
|
|
||||||
if len(jobs) == 0 {
|
if len(jobs) == 0 {
|
||||||
return SilentResult("No scheduled jobs")
|
return common.SilentResult("No scheduled jobs")
|
||||||
}
|
}
|
||||||
|
|
||||||
result := "Scheduled jobs:\n"
|
result := "Scheduled jobs:\n"
|
||||||
|
|
@ -233,37 +235,37 @@ func (t *CronTool) listJobs() *ToolResult {
|
||||||
result += fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo)
|
result += fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
return SilentResult(result)
|
return common.SilentResult(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *CronTool) removeJob(args map[string]any) *ToolResult {
|
func (t *CronTool) removeJob(args map[string]any) *common.ToolResult {
|
||||||
jobID, ok := args["job_id"].(string)
|
jobID, ok := args["job_id"].(string)
|
||||||
if !ok || jobID == "" {
|
if !ok || jobID == "" {
|
||||||
return ErrorResult("job_id is required for remove")
|
return common.ErrorResult("job_id is required for remove")
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.cronService.RemoveJob(jobID) {
|
if t.cronService.RemoveJob(jobID) {
|
||||||
return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID))
|
return common.SilentResult(fmt.Sprintf("Cron job removed: %s", jobID))
|
||||||
}
|
}
|
||||||
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
return common.ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult {
|
func (t *CronTool) enableJob(args map[string]any, enable bool) *common.ToolResult {
|
||||||
jobID, ok := args["job_id"].(string)
|
jobID, ok := args["job_id"].(string)
|
||||||
if !ok || jobID == "" {
|
if !ok || jobID == "" {
|
||||||
return ErrorResult("job_id is required for enable/disable")
|
return common.ErrorResult("job_id is required for enable/disable")
|
||||||
}
|
}
|
||||||
|
|
||||||
job := t.cronService.EnableJob(jobID, enable)
|
job := t.cronService.EnableJob(jobID, enable)
|
||||||
if job == nil {
|
if job == nil {
|
||||||
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
return common.ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
||||||
}
|
}
|
||||||
|
|
||||||
status := "enabled"
|
status := "enabled"
|
||||||
if !enable {
|
if !enable {
|
||||||
status = "disabled"
|
status = "disabled"
|
||||||
}
|
}
|
||||||
return SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status))
|
return common.SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteJob executes a cron job through the agent
|
// ExecuteJob executes a cron job through the agent
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
package tools
|
package edit_file
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EditFileTool edits a file by replacing old_text with new_text.
|
// EditFileTool edits a file by replacing old_text with new_text.
|
||||||
// The old_text must exist exactly in the file.
|
// The old_text must exist exactly in the file.
|
||||||
type EditFileTool struct {
|
type EditFileTool struct {
|
||||||
fs fileSystem
|
fs common.FileSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEditFileTool creates a new EditFileTool with optional directory restriction.
|
// NewEditFileTool creates a new EditFileTool with optional directory restriction.
|
||||||
func NewEditFileTool(workspace string, restrict bool) *EditFileTool {
|
func NewEditFileTool(workspace string, restrict bool) *EditFileTool {
|
||||||
var fs fileSystem
|
var fs common.FileSystem
|
||||||
if restrict {
|
if restrict {
|
||||||
fs = &sandboxFs{workspace: workspace}
|
fs = &common.SandboxFs{Workspace: workspace}
|
||||||
} else {
|
} else {
|
||||||
fs = &hostFs{}
|
fs = &common.HostFs{}
|
||||||
}
|
}
|
||||||
return &EditFileTool{fs: fs}
|
return &EditFileTool{fs: fs}
|
||||||
}
|
}
|
||||||
|
|
@ -54,87 +54,31 @@ func (t *EditFileTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
path, ok := args["path"].(string)
|
path, ok := args["path"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("path is required")
|
return common.ErrorResult("path is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
oldText, ok := args["old_text"].(string)
|
oldText, ok := args["old_text"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("old_text is required")
|
return common.ErrorResult("old_text is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
newText, ok := args["new_text"].(string)
|
newText, ok := args["new_text"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("new_text is required")
|
return common.ErrorResult("new_text is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := editFile(t.fs, path, oldText, newText); err != nil {
|
if err := editFile(t.fs, path, oldText, newText); err != nil {
|
||||||
return ErrorResult(err.Error())
|
return common.ErrorResult(err.Error())
|
||||||
}
|
}
|
||||||
return SilentResult(fmt.Sprintf("File edited: %s", path))
|
return common.SilentResult(fmt.Sprintf("File edited: %s", path))
|
||||||
}
|
|
||||||
|
|
||||||
type AppendFileTool struct {
|
|
||||||
fs fileSystem
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool {
|
|
||||||
var fs fileSystem
|
|
||||||
if restrict {
|
|
||||||
fs = &sandboxFs{workspace: workspace}
|
|
||||||
} else {
|
|
||||||
fs = &hostFs{}
|
|
||||||
}
|
|
||||||
return &AppendFileTool{fs: fs}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *AppendFileTool) Name() string {
|
|
||||||
return "append_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *AppendFileTool) Description() string {
|
|
||||||
return "Append content to the end of a file"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *AppendFileTool) Parameters() map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"path": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"description": "The file path to append to",
|
|
||||||
},
|
|
||||||
"content": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"description": "The content to append",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []string{"path", "content"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
|
||||||
path, ok := args["path"].(string)
|
|
||||||
if !ok {
|
|
||||||
return ErrorResult("path is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
content, ok := args["content"].(string)
|
|
||||||
if !ok {
|
|
||||||
return ErrorResult("content is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := appendFile(t.fs, path, content); err != nil {
|
|
||||||
return ErrorResult(err.Error())
|
|
||||||
}
|
|
||||||
return SilentResult(fmt.Sprintf("Appended to %s", path))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// editFile reads the file via sysFs, performs the replacement, and writes back.
|
// editFile reads the file via sysFs, performs the replacement, and writes back.
|
||||||
// It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes.
|
// It uses a common.FileSystem interface, allowing the same logic for both restricted and unrestricted modes.
|
||||||
func editFile(sysFs fileSystem, path, oldText, newText string) error {
|
func editFile(sysFs common.FileSystem, path, oldText, newText string) error {
|
||||||
content, err := sysFs.ReadFile(path)
|
content, err := sysFs.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -148,17 +92,6 @@ func editFile(sysFs fileSystem, path, oldText, newText string) error {
|
||||||
return sysFs.WriteFile(path, newContent)
|
return sysFs.WriteFile(path, newContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendFile reads the existing content (if any) via sysFs, appends new content, and writes back.
|
|
||||||
func appendFile(sysFs fileSystem, path, appendContent string) error {
|
|
||||||
content, err := sysFs.ReadFile(path)
|
|
||||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
newContent := append(content, []byte(appendContent)...)
|
|
||||||
return sysFs.WriteFile(path, newContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText.
|
// replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText.
|
||||||
func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) {
|
func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) {
|
||||||
contentStr := string(content)
|
contentStr := string(content)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package edit_file
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -218,82 +218,6 @@ func TestEditTool_EditFile_MissingNewText(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEditTool_AppendFile_Success verifies successful file appending
|
|
||||||
func TestEditTool_AppendFile_Success(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
testFile := filepath.Join(tmpDir, "test.txt")
|
|
||||||
os.WriteFile(testFile, []byte("Initial content"), 0o644)
|
|
||||||
|
|
||||||
tool := NewAppendFileTool("", false)
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"path": testFile,
|
|
||||||
"content": "\nAppended content",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Success should not be an error
|
|
||||||
if result.IsError {
|
|
||||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should return SilentResult
|
|
||||||
if !result.Silent {
|
|
||||||
t.Errorf("Expected Silent=true for AppendFile, got false")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForUser should be empty (silent result)
|
|
||||||
if result.ForUser != "" {
|
|
||||||
t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify content was actually appended
|
|
||||||
content, err := os.ReadFile(testFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read file: %v", err)
|
|
||||||
}
|
|
||||||
contentStr := string(content)
|
|
||||||
if !strings.Contains(contentStr, "Initial content") {
|
|
||||||
t.Errorf("Expected original content to remain, got: %s", contentStr)
|
|
||||||
}
|
|
||||||
if !strings.Contains(contentStr, "Appended content") {
|
|
||||||
t.Errorf("Expected appended content, got: %s", contentStr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEditTool_AppendFile_MissingPath verifies error handling for missing path
|
|
||||||
func TestEditTool_AppendFile_MissingPath(t *testing.T) {
|
|
||||||
tool := NewAppendFileTool("", false)
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"content": "test",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Should return error result
|
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected error when path is missing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEditTool_AppendFile_MissingContent verifies error handling for missing content
|
|
||||||
func TestEditTool_AppendFile_MissingContent(t *testing.T) {
|
|
||||||
tool := NewAppendFileTool("", false)
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"path": "/tmp/test.txt",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Should return error result
|
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected error when content is missing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReplaceEditContent verifies the helper function replaceEditContent
|
// TestReplaceEditContent verifies the helper function replaceEditContent
|
||||||
func TestReplaceEditContent(t *testing.T) {
|
func TestReplaceEditContent(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|
@ -343,57 +267,6 @@ func TestReplaceEditContent(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAppendFileTool_AppendToNonExistent_Restricted verifies that AppendFileTool in restricted mode
|
|
||||||
// can append to a file that does not yet exist — it should silently create the file.
|
|
||||||
// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFileWithRW + rootRW.
|
|
||||||
func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) {
|
|
||||||
workspace := t.TempDir()
|
|
||||||
tool := NewAppendFileTool(workspace, true)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
args := map[string]any{
|
|
||||||
"path": "brand_new_file.txt",
|
|
||||||
"content": "first content",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
assert.False(
|
|
||||||
t,
|
|
||||||
result.IsError,
|
|
||||||
"Expected success when appending to non-existent file in restricted mode, got: %s",
|
|
||||||
result.ForLLM,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Verify the file was created with correct content
|
|
||||||
data, err := os.ReadFile(filepath.Join(workspace, "brand_new_file.txt"))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, "first content", string(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAppendFileTool_Restricted_Success verifies that AppendFileTool in restricted mode
|
|
||||||
// correctly appends to an existing file within the sandbox.
|
|
||||||
func TestAppendFileTool_Restricted_Success(t *testing.T) {
|
|
||||||
workspace := t.TempDir()
|
|
||||||
testFile := "existing.txt"
|
|
||||||
err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0o644)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
tool := NewAppendFileTool(workspace, true)
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"path": testFile,
|
|
||||||
"content": " appended",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
|
|
||||||
assert.True(t, result.Silent)
|
|
||||||
|
|
||||||
data, err := os.ReadFile(filepath.Join(workspace, testFile))
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, "initial appended", string(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode
|
// TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode
|
||||||
// correctly edits a file using the single-open editFileInRoot path.
|
// correctly edits a file using the single-open editFileInRoot path.
|
||||||
func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {
|
func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package exec
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExecTool struct {
|
type ExecTool struct {
|
||||||
|
|
@ -134,18 +135,18 @@ func (t *ExecTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
command, ok := args["command"].(string)
|
command, ok := args["command"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("command is required")
|
return common.ErrorResult("command is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
cwd := t.workingDir
|
cwd := t.workingDir
|
||||||
if wd, ok := args["working_dir"].(string); ok && wd != "" {
|
if wd, ok := args["working_dir"].(string); ok && wd != "" {
|
||||||
if t.restrictToWorkspace && t.workingDir != "" {
|
if t.restrictToWorkspace && t.workingDir != "" {
|
||||||
resolvedWD, err := validatePath(wd, t.workingDir, true)
|
resolvedWD, err := common.ValidatePath(wd, t.workingDir, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult("Command blocked by safety guard (" + err.Error() + ")")
|
return common.ErrorResult("Command blocked by safety guard (" + err.Error() + ")")
|
||||||
}
|
}
|
||||||
cwd = resolvedWD
|
cwd = resolvedWD
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -161,7 +162,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
}
|
}
|
||||||
|
|
||||||
if guardError := t.guardCommand(command, cwd); guardError != "" {
|
if guardError := t.guardCommand(command, cwd); guardError != "" {
|
||||||
return ErrorResult(guardError)
|
return common.ErrorResult(guardError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// timeout == 0 means no timeout
|
// timeout == 0 means no timeout
|
||||||
|
|
@ -191,7 +192,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
|
return common.ErrorResult(fmt.Sprintf("failed to start command: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
|
|
@ -222,7 +223,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
|
if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
|
||||||
msg := fmt.Sprintf("Command timed out after %v", t.timeout)
|
msg := fmt.Sprintf("Command timed out after %v", t.timeout)
|
||||||
return &ToolResult{
|
return &common.ToolResult{
|
||||||
ForLLM: msg,
|
ForLLM: msg,
|
||||||
ForUser: msg,
|
ForUser: msg,
|
||||||
IsError: true,
|
IsError: true,
|
||||||
|
|
@ -241,14 +242,14 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ToolResult{
|
return &common.ToolResult{
|
||||||
ForLLM: output,
|
ForLLM: output,
|
||||||
ForUser: output,
|
ForUser: output,
|
||||||
IsError: true,
|
IsError: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ToolResult{
|
return &common.ToolResult{
|
||||||
ForLLM: output,
|
ForLLM: output,
|
||||||
ForUser: output,
|
ForUser: output,
|
||||||
IsError: false,
|
IsError: false,
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//go:build !windows
|
//go:build !windows
|
||||||
|
|
||||||
package tools
|
package exec
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//go:build windows
|
//go:build windows
|
||||||
|
|
||||||
package tools
|
package exec
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package exec
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//go:build !windows
|
//go:build !windows
|
||||||
|
|
||||||
package tools
|
package exec
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,311 +1,14 @@
|
||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestFilesystemTool_ReadFile_Success verifies successful file reading
|
|
||||||
func TestFilesystemTool_ReadFile_Success(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
testFile := filepath.Join(tmpDir, "test.txt")
|
|
||||||
os.WriteFile(testFile, []byte("test content"), 0o644)
|
|
||||||
|
|
||||||
tool := NewReadFileTool("", false)
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"path": testFile,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Success should not be an error
|
|
||||||
if result.IsError {
|
|
||||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForLLM should contain file content
|
|
||||||
if !strings.Contains(result.ForLLM, "test content") {
|
|
||||||
t.Errorf("Expected ForLLM to contain 'test content', got: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadFile returns NewToolResult which only sets ForLLM, not ForUser
|
|
||||||
// This is the expected behavior - file content goes to LLM, not directly to user
|
|
||||||
if result.ForUser != "" {
|
|
||||||
t.Errorf("Expected ForUser to be empty for NewToolResult, got: %s", result.ForUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
|
|
||||||
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
|
||||||
tool := NewReadFileTool("", false)
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"path": "/nonexistent_file_12345.txt",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Failure should be marked as error
|
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected error for missing file, got IsError=false")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should contain error message
|
|
||||||
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
|
|
||||||
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path
|
|
||||||
func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) {
|
|
||||||
tool := &ReadFileTool{}
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Should return error result
|
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected error when path is missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should mention required parameter
|
|
||||||
if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") {
|
|
||||||
t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFilesystemTool_WriteFile_Success verifies successful file writing
|
|
||||||
func TestFilesystemTool_WriteFile_Success(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
testFile := filepath.Join(tmpDir, "newfile.txt")
|
|
||||||
|
|
||||||
tool := NewWriteFileTool("", false)
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"path": testFile,
|
|
||||||
"content": "hello world",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Success should not be an error
|
|
||||||
if result.IsError {
|
|
||||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteFile returns SilentResult
|
|
||||||
if !result.Silent {
|
|
||||||
t.Errorf("Expected Silent=true for WriteFile, got false")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForUser should be empty (silent result)
|
|
||||||
if result.ForUser != "" {
|
|
||||||
t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify file was actually written
|
|
||||||
content, err := os.ReadFile(testFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read written file: %v", err)
|
|
||||||
}
|
|
||||||
if string(content) != "hello world" {
|
|
||||||
t.Errorf("Expected file content 'hello world', got: %s", string(content))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFilesystemTool_WriteFile_CreateDir verifies directory creation
|
|
||||||
func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
testFile := filepath.Join(tmpDir, "subdir", "newfile.txt")
|
|
||||||
|
|
||||||
tool := NewWriteFileTool("", false)
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"path": testFile,
|
|
||||||
"content": "test",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Success should not be an error
|
|
||||||
if result.IsError {
|
|
||||||
t.Errorf("Expected success with directory creation, got IsError=true: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify directory was created and file written
|
|
||||||
content, err := os.ReadFile(testFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read written file: %v", err)
|
|
||||||
}
|
|
||||||
if string(content) != "test" {
|
|
||||||
t.Errorf("Expected file content 'test', got: %s", string(content))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path
|
|
||||||
func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
|
|
||||||
tool := NewWriteFileTool("", false)
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"content": "test",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Should return error result
|
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected error when path is missing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content
|
|
||||||
func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
|
|
||||||
tool := NewWriteFileTool("", false)
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"path": "/tmp/test.txt",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Should return error result
|
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected error when content is missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should mention required parameter
|
|
||||||
if !strings.Contains(result.ForLLM, "content is required") &&
|
|
||||||
!strings.Contains(result.ForUser, "content is required") {
|
|
||||||
t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFilesystemTool_ListDir_Success verifies successful directory listing
|
|
||||||
func TestFilesystemTool_ListDir_Success(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0o644)
|
|
||||||
os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644)
|
|
||||||
os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755)
|
|
||||||
|
|
||||||
tool := NewListDirTool("", false)
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"path": tmpDir,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Success should not be an error
|
|
||||||
if result.IsError {
|
|
||||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should list files and directories
|
|
||||||
if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") {
|
|
||||||
t.Errorf("Expected files in listing, got: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
if !strings.Contains(result.ForLLM, "subdir") {
|
|
||||||
t.Errorf("Expected subdir in listing, got: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory
|
|
||||||
func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
|
|
||||||
tool := NewListDirTool("", false)
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"path": "/nonexistent_directory_12345",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Failure should be marked as error
|
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected error for non-existent directory, got IsError=false")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should contain error message
|
|
||||||
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
|
|
||||||
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory
|
|
||||||
func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
|
|
||||||
tool := NewListDirTool("", false)
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
|
|
||||||
// Should use "." as default path
|
|
||||||
if result.IsError {
|
|
||||||
t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block paths that look inside workspace but point outside via symlink.
|
|
||||||
func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
workspace := filepath.Join(root, "workspace")
|
|
||||||
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
|
||||||
t.Fatalf("failed to create workspace: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
secret := filepath.Join(root, "secret.txt")
|
|
||||||
if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil {
|
|
||||||
t.Fatalf("failed to write secret file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
link := filepath.Join(workspace, "leak.txt")
|
|
||||||
if err := os.Symlink(secret, link); err != nil {
|
|
||||||
t.Skipf("symlink not supported in this environment: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tool := NewReadFileTool(workspace, true)
|
|
||||||
result := tool.Execute(context.Background(), map[string]any{
|
|
||||||
"path": link,
|
|
||||||
})
|
|
||||||
|
|
||||||
if !result.IsError {
|
|
||||||
t.Fatalf("expected symlink escape to be blocked")
|
|
||||||
}
|
|
||||||
// os.Root might return different errors depending on platform/implementation
|
|
||||||
// but it definitely should error.
|
|
||||||
// Our wrapper returns "access denied or file not found"
|
|
||||||
if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") &&
|
|
||||||
!strings.Contains(result.ForLLM, "no such file") {
|
|
||||||
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
|
|
||||||
tool := NewReadFileTool("", true) // restrict=true but workspace=""
|
|
||||||
|
|
||||||
// Try to read a sensitive file (simulated by a temp file outside workspace)
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
secretFile := filepath.Join(tmpDir, "shadow")
|
|
||||||
os.WriteFile(secretFile, []byte("secret data"), 0o600)
|
|
||||||
|
|
||||||
result := tool.Execute(context.Background(), map[string]any{
|
|
||||||
"path": secretFile,
|
|
||||||
})
|
|
||||||
|
|
||||||
// We EXPECT IsError=true (access blocked due to empty workspace)
|
|
||||||
assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
|
|
||||||
|
|
||||||
// Verify it failed for the right reason
|
|
||||||
assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
|
// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
|
||||||
// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path.
|
// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path.
|
||||||
func TestRootMkdirAll(t *testing.T) {
|
func TestRootMkdirAll(t *testing.T) {
|
||||||
|
|
@ -339,28 +42,6 @@ func TestRootMkdirAll(t *testing.T) {
|
||||||
assert.Error(t, err, "expected error when a file exists at the directory path")
|
assert.Error(t, err, "expected error when a file exists at the directory path")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) {
|
|
||||||
workspace := t.TempDir()
|
|
||||||
tool := NewWriteFileTool(workspace, true)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
testFile := "deep/nested/path/to/file.txt"
|
|
||||||
content := "deep content"
|
|
||||||
args := map[string]any{
|
|
||||||
"path": testFile,
|
|
||||||
"content": content,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
|
||||||
assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
|
|
||||||
|
|
||||||
// Verify file content
|
|
||||||
actualPath := filepath.Join(workspace, testFile)
|
|
||||||
data, err := os.ReadFile(actualPath)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, content, string(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors.
|
// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors.
|
||||||
func TestHostRW_Read_PermissionDenied(t *testing.T) {
|
func TestHostRW_Read_PermissionDenied(t *testing.T) {
|
||||||
if os.Getuid() == 0 {
|
if os.Getuid() == 0 {
|
||||||
|
|
@ -372,7 +53,7 @@ func TestHostRW_Read_PermissionDenied(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer os.Chmod(protected, 0o644) // ensure cleanup
|
defer os.Chmod(protected, 0o644) // ensure cleanup
|
||||||
|
|
||||||
_, err = (&hostFs{}).ReadFile(protected)
|
_, err = (&HostFs{}).ReadFile(protected)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "access denied")
|
assert.Contains(t, err.Error(), "access denied")
|
||||||
}
|
}
|
||||||
|
|
@ -381,7 +62,7 @@ func TestHostRW_Read_PermissionDenied(t *testing.T) {
|
||||||
func TestHostRW_Read_Directory(t *testing.T) {
|
func TestHostRW_Read_Directory(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
_, err := (&hostFs{}).ReadFile(tmpDir)
|
_, err := (&HostFs{}).ReadFile(tmpDir)
|
||||||
assert.Error(t, err, "expected error when reading a directory as a file")
|
assert.Error(t, err, "expected error when reading a directory as a file")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,7 +77,7 @@ func TestRootRW_Read_Directory(t *testing.T) {
|
||||||
err = root.Mkdir("subdir", 0o755)
|
err = root.Mkdir("subdir", 0o755)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
_, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir")
|
_, err = (&SandboxFs{Workspace: workspace}).ReadFile("subdir")
|
||||||
assert.Error(t, err, "expected error when reading a directory as a file")
|
assert.Error(t, err, "expected error when reading a directory as a file")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -405,7 +86,7 @@ func TestHostRW_Write_ParentDirMissing(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
target := filepath.Join(tmpDir, "a", "b", "c", "file.txt")
|
target := filepath.Join(tmpDir, "a", "b", "c", "file.txt")
|
||||||
|
|
||||||
err := (&hostFs{}).WriteFile(target, []byte("hello"))
|
err := (&HostFs{}).WriteFile(target, []byte("hello"))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
data, err := os.ReadFile(target)
|
data, err := os.ReadFile(target)
|
||||||
|
|
@ -419,7 +100,7 @@ func TestRootRW_Write_ParentDirMissing(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
|
|
||||||
relPath := "x/y/z/file.txt"
|
relPath := "x/y/z/file.txt"
|
||||||
err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested"))
|
err := (&SandboxFs{Workspace: workspace}).WriteFile(relPath, []byte("nested"))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
data, err := os.ReadFile(filepath.Join(workspace, relPath))
|
data, err := os.ReadFile(filepath.Join(workspace, relPath))
|
||||||
|
|
@ -433,7 +114,7 @@ func TestHostRW_Write(t *testing.T) {
|
||||||
testFile := filepath.Join(tmpDir, "atomic_test.txt")
|
testFile := filepath.Join(tmpDir, "atomic_test.txt")
|
||||||
testData := []byte("atomic test content")
|
testData := []byte("atomic test content")
|
||||||
|
|
||||||
err := (&hostFs{}).WriteFile(testFile, testData)
|
err := (&HostFs{}).WriteFile(testFile, testData)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
content, err := os.ReadFile(testFile)
|
content, err := os.ReadFile(testFile)
|
||||||
|
|
@ -442,7 +123,7 @@ func TestHostRW_Write(t *testing.T) {
|
||||||
|
|
||||||
// Verify it overwrites correctly
|
// Verify it overwrites correctly
|
||||||
newData := []byte("new atomic content")
|
newData := []byte("new atomic content")
|
||||||
err = (&hostFs{}).WriteFile(testFile, newData)
|
err = (&HostFs{}).WriteFile(testFile, newData)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
content, err = os.ReadFile(testFile)
|
content, err = os.ReadFile(testFile)
|
||||||
|
|
@ -457,7 +138,7 @@ func TestRootRW_Write(t *testing.T) {
|
||||||
relPath := "atomic_root_test.txt"
|
relPath := "atomic_root_test.txt"
|
||||||
testData := []byte("atomic root test content")
|
testData := []byte("atomic root test content")
|
||||||
|
|
||||||
erw := &sandboxFs{workspace: tmpDir}
|
erw := &SandboxFs{Workspace: tmpDir}
|
||||||
err := erw.WriteFile(relPath, testData)
|
err := erw.WriteFile(relPath, testData)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package find_skills
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FindSkillsTool allows the LLM agent to search for installable skills from registries.
|
// FindSkillsTool allows the LLM agent to search for installable skills from registries.
|
||||||
|
|
@ -51,11 +52,11 @@ func (t *FindSkillsTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
query, ok := args["query"].(string)
|
query, ok := args["query"].(string)
|
||||||
query = strings.ToLower(strings.TrimSpace(query))
|
query = strings.ToLower(strings.TrimSpace(query))
|
||||||
if !ok || query == "" {
|
if !ok || query == "" {
|
||||||
return ErrorResult("query is required and must be a non-empty string")
|
return common.ErrorResult("query is required and must be a non-empty string")
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := 5
|
limit := 5
|
||||||
|
|
@ -69,14 +70,14 @@ func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *Tool
|
||||||
// Check cache first.
|
// Check cache first.
|
||||||
if t.cache != nil {
|
if t.cache != nil {
|
||||||
if cached, hit := t.cache.Get(query); hit {
|
if cached, hit := t.cache.Get(query); hit {
|
||||||
return SilentResult(formatSearchResults(query, cached, true))
|
return common.SilentResult(formatSearchResults(query, cached, true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search all registries.
|
// Search all registries.
|
||||||
results, err := t.registryMgr.SearchAll(ctx, query, limit)
|
results, err := t.registryMgr.SearchAll(ctx, query, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("skill search failed: %v", err))
|
return common.ErrorResult(fmt.Sprintf("skill search failed: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the results.
|
// Cache the results.
|
||||||
|
|
@ -84,7 +85,7 @@ func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *Tool
|
||||||
t.cache.Put(query, results)
|
t.cache.Put(query, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
return SilentResult(formatSearchResults(query, results, false))
|
return common.SilentResult(formatSearchResults(query, results, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatSearchResults(query string, results []skills.SearchResult, cached bool) string {
|
func formatSearchResults(query string, results []skills.SearchResult, cached bool) string {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package find_skills
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package i2c
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -7,6 +7,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
// I2CTool provides I2C bus interaction for reading sensors and controlling peripherals.
|
// I2CTool provides I2C bus interaction for reading sensors and controlling peripherals.
|
||||||
|
|
@ -63,14 +65,14 @@ func (t *I2CTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
if runtime.GOOS != "linux" {
|
if runtime.GOOS != "linux" {
|
||||||
return ErrorResult("I2C is only supported on Linux. This tool requires /dev/i2c-* device files.")
|
return common.ErrorResult("I2C is only supported on Linux. This tool requires /dev/i2c-* device files.")
|
||||||
}
|
}
|
||||||
|
|
||||||
action, ok := args["action"].(string)
|
action, ok := args["action"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("action is required")
|
return common.ErrorResult("action is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
|
|
@ -83,19 +85,19 @@ func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
case "write":
|
case "write":
|
||||||
return t.writeDevice(args)
|
return t.writeDevice(args)
|
||||||
default:
|
default:
|
||||||
return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action))
|
return common.ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// detect lists available I2C buses by globbing /dev/i2c-*
|
// detect lists available I2C buses by globbing /dev/i2c-*
|
||||||
func (t *I2CTool) detect() *ToolResult {
|
func (t *I2CTool) detect() *common.ToolResult {
|
||||||
matches, err := filepath.Glob("/dev/i2c-*")
|
matches, err := filepath.Glob("/dev/i2c-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to scan for I2C buses: %v", err))
|
return common.ErrorResult(fmt.Sprintf("failed to scan for I2C buses: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
return SilentResult(
|
return common.SilentResult(
|
||||||
"No I2C buses found. You may need to:\n1. Load the i2c-dev module: modprobe i2c-dev\n2. Check that I2C is enabled in device tree\n3. Configure pinmux for your board (see hardware skill)",
|
"No I2C buses found. You may need to:\n1. Load the i2c-dev module: modprobe i2c-dev\n2. Check that I2C is enabled in device tree\n3. Configure pinmux for your board (see hardware skill)",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +116,7 @@ func (t *I2CTool) detect() *ToolResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
result, _ := json.MarshalIndent(buses, "", " ")
|
result, _ := json.MarshalIndent(buses, "", " ")
|
||||||
return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result)))
|
return common.SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for I2C operations (used by platform-specific implementations)
|
// Helper functions for I2C operations (used by platform-specific implementations)
|
||||||
|
|
@ -130,14 +132,14 @@ func isValidBusID(id string) bool {
|
||||||
// parseI2CAddress extracts and validates an I2C address from args
|
// parseI2CAddress extracts and validates an I2C address from args
|
||||||
//
|
//
|
||||||
//nolint:unused // Used by i2c_linux.go
|
//nolint:unused // Used by i2c_linux.go
|
||||||
func parseI2CAddress(args map[string]any) (int, *ToolResult) {
|
func parseI2CAddress(args map[string]any) (int, *common.ToolResult) {
|
||||||
addrFloat, ok := args["address"].(float64)
|
addrFloat, ok := args["address"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)")
|
return 0, common.ErrorResult("address is required (e.g. 0x38 for AHT20)")
|
||||||
}
|
}
|
||||||
addr := int(addrFloat)
|
addr := int(addrFloat)
|
||||||
if addr < 0x03 || addr > 0x77 {
|
if addr < 0x03 || addr > 0x77 {
|
||||||
return 0, ErrorResult("address must be in valid 7-bit range (0x03-0x77)")
|
return 0, common.ErrorResult("address must be in valid 7-bit range (0x03-0x77)")
|
||||||
}
|
}
|
||||||
return addr, nil
|
return addr, nil
|
||||||
}
|
}
|
||||||
|
|
@ -145,13 +147,13 @@ func parseI2CAddress(args map[string]any) (int, *ToolResult) {
|
||||||
// parseI2CBus extracts and validates an I2C bus from args
|
// parseI2CBus extracts and validates an I2C bus from args
|
||||||
//
|
//
|
||||||
//nolint:unused // Used by i2c_linux.go
|
//nolint:unused // Used by i2c_linux.go
|
||||||
func parseI2CBus(args map[string]any) (string, *ToolResult) {
|
func parseI2CBus(args map[string]any) (string, *common.ToolResult) {
|
||||||
bus, ok := args["bus"].(string)
|
bus, ok := args["bus"].(string)
|
||||||
if !ok || bus == "" {
|
if !ok || bus == "" {
|
||||||
return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)")
|
return "", common.ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)")
|
||||||
}
|
}
|
||||||
if !isValidBusID(bus) {
|
if !isValidBusID(bus) {
|
||||||
return "", ErrorResult("invalid bus identifier: must be a number (e.g. \"1\")")
|
return "", common.ErrorResult("invalid bus identifier: must be a number (e.g. \"1\")")
|
||||||
}
|
}
|
||||||
return bus, nil
|
return bus, nil
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package tools
|
package i2c
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
// I2C ioctl constants from Linux kernel headers (<linux/i2c-dev.h>, <linux/i2c.h>)
|
// I2C ioctl constants from Linux kernel headers (<linux/i2c-dev.h>, <linux/i2c.h>)
|
||||||
|
|
@ -74,7 +76,7 @@ func smbusProbe(fd int, addr int, hasQuick bool) bool {
|
||||||
// scan probes valid 7-bit addresses on a bus for connected devices.
|
// scan probes valid 7-bit addresses on a bus for connected devices.
|
||||||
// Uses the same hybrid probe strategy as i2cdetect's MODE_AUTO:
|
// Uses the same hybrid probe strategy as i2cdetect's MODE_AUTO:
|
||||||
// SMBus Quick Write for most addresses, SMBus Read Byte for EEPROM ranges.
|
// SMBus Quick Write for most addresses, SMBus Read Byte for EEPROM ranges.
|
||||||
func (t *I2CTool) scan(args map[string]any) *ToolResult {
|
func (t *I2CTool) scan(args map[string]any) *common.ToolResult {
|
||||||
bus, errResult := parseI2CBus(args)
|
bus, errResult := parseI2CBus(args)
|
||||||
if errResult != nil {
|
if errResult != nil {
|
||||||
return errResult
|
return errResult
|
||||||
|
|
@ -83,7 +85,7 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
|
||||||
devPath := fmt.Sprintf("/dev/i2c-%s", bus)
|
devPath := fmt.Sprintf("/dev/i2c-%s", bus)
|
||||||
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
|
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and i2c-dev module)", devPath, err))
|
return common.ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and i2c-dev module)", devPath, err))
|
||||||
}
|
}
|
||||||
defer syscall.Close(fd)
|
defer syscall.Close(fd)
|
||||||
|
|
||||||
|
|
@ -92,14 +94,14 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
|
||||||
var funcs uintptr
|
var funcs uintptr
|
||||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cFuncs, uintptr(unsafe.Pointer(&funcs)))
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cFuncs, uintptr(unsafe.Pointer(&funcs)))
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return ErrorResult(fmt.Sprintf("failed to query I2C adapter capabilities on %s: %v", devPath, errno))
|
return common.ErrorResult(fmt.Sprintf("failed to query I2C adapter capabilities on %s: %v", devPath, errno))
|
||||||
}
|
}
|
||||||
|
|
||||||
hasQuick := funcs&i2cFuncSmbusQuick != 0
|
hasQuick := funcs&i2cFuncSmbusQuick != 0
|
||||||
hasReadByte := funcs&i2cFuncSmbusReadByte != 0
|
hasReadByte := funcs&i2cFuncSmbusReadByte != 0
|
||||||
|
|
||||||
if !hasQuick && !hasReadByte {
|
if !hasQuick && !hasReadByte {
|
||||||
return ErrorResult(
|
return common.ErrorResult(
|
||||||
fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath),
|
fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +134,7 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(found) == 0 {
|
if len(found) == 0 {
|
||||||
return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath))
|
return common.SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
result, _ := json.MarshalIndent(map[string]any{
|
result, _ := json.MarshalIndent(map[string]any{
|
||||||
|
|
@ -140,11 +142,11 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
|
||||||
"devices": found,
|
"devices": found,
|
||||||
"count": len(found),
|
"count": len(found),
|
||||||
}, "", " ")
|
}, "", " ")
|
||||||
return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result)))
|
return common.SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// readDevice reads bytes from an I2C device, optionally at a specific register
|
// readDevice reads bytes from an I2C device, optionally at a specific register
|
||||||
func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
|
func (t *I2CTool) readDevice(args map[string]any) *common.ToolResult {
|
||||||
bus, errResult := parseI2CBus(args)
|
bus, errResult := parseI2CBus(args)
|
||||||
if errResult != nil {
|
if errResult != nil {
|
||||||
return errResult
|
return errResult
|
||||||
|
|
@ -160,31 +162,31 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
|
||||||
length = int(l)
|
length = int(l)
|
||||||
}
|
}
|
||||||
if length < 1 || length > 256 {
|
if length < 1 || length > 256 {
|
||||||
return ErrorResult("length must be between 1 and 256")
|
return common.ErrorResult("length must be between 1 and 256")
|
||||||
}
|
}
|
||||||
|
|
||||||
devPath := fmt.Sprintf("/dev/i2c-%s", bus)
|
devPath := fmt.Sprintf("/dev/i2c-%s", bus)
|
||||||
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
|
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err))
|
return common.ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err))
|
||||||
}
|
}
|
||||||
defer syscall.Close(fd)
|
defer syscall.Close(fd)
|
||||||
|
|
||||||
// Set slave address
|
// Set slave address
|
||||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr))
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr))
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno))
|
return common.ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno))
|
||||||
}
|
}
|
||||||
|
|
||||||
// If register is specified, write it first
|
// If register is specified, write it first
|
||||||
if regFloat, ok := args["register"].(float64); ok {
|
if regFloat, ok := args["register"].(float64); ok {
|
||||||
reg := int(regFloat)
|
reg := int(regFloat)
|
||||||
if reg < 0 || reg > 255 {
|
if reg < 0 || reg > 255 {
|
||||||
return ErrorResult("register must be between 0x00 and 0xFF")
|
return common.ErrorResult("register must be between 0x00 and 0xFF")
|
||||||
}
|
}
|
||||||
_, err = syscall.Write(fd, []byte{byte(reg)})
|
_, err = syscall.Write(fd, []byte{byte(reg)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to write register 0x%02x: %v", reg, err))
|
return common.ErrorResult(fmt.Sprintf("failed to write register 0x%02x: %v", reg, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,7 +194,7 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
|
||||||
buf := make([]byte, length)
|
buf := make([]byte, length)
|
||||||
n, err := syscall.Read(fd, buf)
|
n, err := syscall.Read(fd, buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to read from device 0x%02x: %v", addr, err))
|
return common.ErrorResult(fmt.Sprintf("failed to read from device 0x%02x: %v", addr, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format as hex bytes
|
// Format as hex bytes
|
||||||
|
|
@ -210,14 +212,14 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
|
||||||
"hex": hexBytes,
|
"hex": hexBytes,
|
||||||
"length": n,
|
"length": n,
|
||||||
}, "", " ")
|
}, "", " ")
|
||||||
return SilentResult(string(result))
|
return common.SilentResult(string(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeDevice writes bytes to an I2C device, optionally at a specific register
|
// writeDevice writes bytes to an I2C device, optionally at a specific register
|
||||||
func (t *I2CTool) writeDevice(args map[string]any) *ToolResult {
|
func (t *I2CTool) writeDevice(args map[string]any) *common.ToolResult {
|
||||||
confirm, _ := args["confirm"].(bool)
|
confirm, _ := args["confirm"].(bool)
|
||||||
if !confirm {
|
if !confirm {
|
||||||
return ErrorResult(
|
return common.ErrorResult(
|
||||||
"write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.",
|
"write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -234,10 +236,10 @@ func (t *I2CTool) writeDevice(args map[string]any) *ToolResult {
|
||||||
|
|
||||||
dataRaw, ok := args["data"].([]any)
|
dataRaw, ok := args["data"].([]any)
|
||||||
if !ok || len(dataRaw) == 0 {
|
if !ok || len(dataRaw) == 0 {
|
||||||
return ErrorResult("data is required for write (array of byte values 0-255)")
|
return common.ErrorResult("data is required for write (array of byte values 0-255)")
|
||||||
}
|
}
|
||||||
if len(dataRaw) > 256 {
|
if len(dataRaw) > 256 {
|
||||||
return ErrorResult("data too long: maximum 256 bytes per I2C transaction")
|
return common.ErrorResult("data too long: maximum 256 bytes per I2C transaction")
|
||||||
}
|
}
|
||||||
|
|
||||||
data := make([]byte, 0, len(dataRaw)+1)
|
data := make([]byte, 0, len(dataRaw)+1)
|
||||||
|
|
@ -246,7 +248,7 @@ func (t *I2CTool) writeDevice(args map[string]any) *ToolResult {
|
||||||
if regFloat, ok := args["register"].(float64); ok {
|
if regFloat, ok := args["register"].(float64); ok {
|
||||||
reg := int(regFloat)
|
reg := int(regFloat)
|
||||||
if reg < 0 || reg > 255 {
|
if reg < 0 || reg > 255 {
|
||||||
return ErrorResult("register must be between 0x00 and 0xFF")
|
return common.ErrorResult("register must be between 0x00 and 0xFF")
|
||||||
}
|
}
|
||||||
data = append(data, byte(reg))
|
data = append(data, byte(reg))
|
||||||
}
|
}
|
||||||
|
|
@ -254,11 +256,11 @@ func (t *I2CTool) writeDevice(args map[string]any) *ToolResult {
|
||||||
for i, v := range dataRaw {
|
for i, v := range dataRaw {
|
||||||
f, ok := v.(float64)
|
f, ok := v.(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i))
|
return common.ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i))
|
||||||
}
|
}
|
||||||
b := int(f)
|
b := int(f)
|
||||||
if b < 0 || b > 255 {
|
if b < 0 || b > 255 {
|
||||||
return ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b))
|
return common.ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b))
|
||||||
}
|
}
|
||||||
data = append(data, byte(b))
|
data = append(data, byte(b))
|
||||||
}
|
}
|
||||||
|
|
@ -266,21 +268,21 @@ func (t *I2CTool) writeDevice(args map[string]any) *ToolResult {
|
||||||
devPath := fmt.Sprintf("/dev/i2c-%s", bus)
|
devPath := fmt.Sprintf("/dev/i2c-%s", bus)
|
||||||
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
|
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err))
|
return common.ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err))
|
||||||
}
|
}
|
||||||
defer syscall.Close(fd)
|
defer syscall.Close(fd)
|
||||||
|
|
||||||
// Set slave address
|
// Set slave address
|
||||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr))
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr))
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno))
|
return common.ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write data
|
// Write data
|
||||||
n, err := syscall.Write(fd, data)
|
n, err := syscall.Write(fd, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to write to device 0x%02x: %v", addr, err))
|
return common.ErrorResult(fmt.Sprintf("failed to write to device 0x%02x: %v", addr, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return SilentResult(fmt.Sprintf("Wrote %d byte(s) to device 0x%02x on %s", n, addr, devPath))
|
return common.SilentResult(fmt.Sprintf("Wrote %d byte(s) to device 0x%02x on %s", n, addr, devPath))
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//go:build !linux
|
//go:build !linux
|
||||||
|
|
||||||
package tools
|
package i2c
|
||||||
|
|
||||||
// scan is a stub for non-Linux platforms.
|
// scan is a stub for non-Linux platforms.
|
||||||
func (t *I2CTool) scan(args map[string]any) *ToolResult {
|
func (t *I2CTool) scan(args map[string]any) *ToolResult {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package install_skill
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -68,7 +69,7 @@ func (t *InstallSkillTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
// Install lock to prevent concurrent directory operations.
|
// Install lock to prevent concurrent directory operations.
|
||||||
// Ideally this should be done at a `slug` level, currently, its at a `workspace` level.
|
// Ideally this should be done at a `slug` level, currently, its at a `workspace` level.
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
|
|
@ -77,13 +78,13 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
|
||||||
// Validate slug
|
// Validate slug
|
||||||
slug, _ := args["slug"].(string)
|
slug, _ := args["slug"].(string)
|
||||||
if err := utils.ValidateSkillIdentifier(slug); err != nil {
|
if err := utils.ValidateSkillIdentifier(slug); err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error()))
|
return common.ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate registry
|
// Validate registry
|
||||||
registryName, _ := args["registry"].(string)
|
registryName, _ := args["registry"].(string)
|
||||||
if err := utils.ValidateSkillIdentifier(registryName); err != nil {
|
if err := utils.ValidateSkillIdentifier(registryName); err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error()))
|
return common.ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
version, _ := args["version"].(string)
|
version, _ := args["version"].(string)
|
||||||
|
|
@ -95,7 +96,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
|
||||||
|
|
||||||
if !force {
|
if !force {
|
||||||
if _, err := os.Stat(targetDir); err == nil {
|
if _, err := os.Stat(targetDir); err == nil {
|
||||||
return ErrorResult(
|
return common.ErrorResult(
|
||||||
fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir),
|
fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -107,12 +108,12 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
|
||||||
// Resolve which registry to use.
|
// Resolve which registry to use.
|
||||||
registry := t.registryMgr.GetRegistry(registryName)
|
registry := t.registryMgr.GetRegistry(registryName)
|
||||||
if registry == nil {
|
if registry == nil {
|
||||||
return ErrorResult(fmt.Sprintf("registry %q not found", registryName))
|
return common.ErrorResult(fmt.Sprintf("registry %q not found", registryName))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure skills directory exists.
|
// Ensure skills directory exists.
|
||||||
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err))
|
return common.ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download and install (handles metadata, version resolution, extraction).
|
// Download and install (handles metadata, version resolution, extraction).
|
||||||
|
|
@ -128,7 +129,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
|
||||||
"error": rmErr.Error(),
|
"error": rmErr.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err))
|
return common.ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moderation: block malware.
|
// Moderation: block malware.
|
||||||
|
|
@ -142,7 +143,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
|
||||||
"error": rmErr.Error(),
|
"error": rmErr.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug))
|
return common.ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write origin metadata.
|
// Write origin metadata.
|
||||||
|
|
@ -172,7 +173,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
|
||||||
}
|
}
|
||||||
output += "\nThe skill is now available and can be loaded in the current session."
|
output += "\nThe skill is now available and can be loaded in the current session."
|
||||||
|
|
||||||
return SilentResult(output)
|
return common.SilentResult(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
// originMeta tracks which registry a skill was installed from.
|
// originMeta tracks which registry a skill was installed from.
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package install_skill
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
70
pkg/tools/list_dir/list_dir.go
Normal file
70
pkg/tools/list_dir/list_dir.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package list_dir
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListDirTool struct {
|
||||||
|
fs common.FileSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListDirTool(workspace string, restrict bool) *ListDirTool {
|
||||||
|
var fs common.FileSystem
|
||||||
|
if restrict {
|
||||||
|
fs = &common.SandboxFs{Workspace: workspace}
|
||||||
|
} else {
|
||||||
|
fs = &common.HostFs{}
|
||||||
|
}
|
||||||
|
return &ListDirTool{fs: fs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ListDirTool) Name() string {
|
||||||
|
return "list_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ListDirTool) Description() string {
|
||||||
|
return "List files and directories in a path"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ListDirTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to list",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
|
path, ok := args["path"].(string)
|
||||||
|
if !ok {
|
||||||
|
path = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := t.fs.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
|
||||||
|
}
|
||||||
|
return formatDirEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDirEntries(entries []os.DirEntry) *common.ToolResult {
|
||||||
|
var result strings.Builder
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
result.WriteString("DIR: " + entry.Name() + "\n")
|
||||||
|
} else {
|
||||||
|
result.WriteString("FILE: " + entry.Name() + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return common.NewToolResult(result.String())
|
||||||
|
}
|
||||||
73
pkg/tools/list_dir/list_dir_test.go
Normal file
73
pkg/tools/list_dir/list_dir_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
package list_dir
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFilesystemTool_ListDir_Success verifies successful directory listing
|
||||||
|
func TestFilesystemTool_ListDir_Success(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644)
|
||||||
|
os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755)
|
||||||
|
|
||||||
|
tool := NewListDirTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": tmpDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Success should not be an error
|
||||||
|
if result.IsError {
|
||||||
|
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should list files and directories
|
||||||
|
if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") {
|
||||||
|
t.Errorf("Expected files in listing, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "subdir") {
|
||||||
|
t.Errorf("Expected subdir in listing, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory
|
||||||
|
func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
|
||||||
|
tool := NewListDirTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": "/nonexistent_directory_12345",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Failure should be marked as error
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error for non-existent directory, got IsError=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should contain error message
|
||||||
|
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
|
||||||
|
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory
|
||||||
|
func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
|
||||||
|
tool := NewListDirTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Should use "." as default path
|
||||||
|
if result.IsError {
|
||||||
|
t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package tools
|
package message
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SendCallback func(channel, chatID, content string) error
|
type SendCallback func(channel, chatID, content string) error
|
||||||
|
|
@ -62,10 +64,10 @@ func (t *MessageTool) SetSendCallback(callback SendCallback) {
|
||||||
t.sendCallback = callback
|
t.sendCallback = callback
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
content, ok := args["content"].(string)
|
content, ok := args["content"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return &ToolResult{ForLLM: "content is required", IsError: true}
|
return &common.ToolResult{ForLLM: "content is required", IsError: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
channel, _ := args["channel"].(string)
|
channel, _ := args["channel"].(string)
|
||||||
|
|
@ -79,15 +81,15 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
||||||
}
|
}
|
||||||
|
|
||||||
if channel == "" || chatID == "" {
|
if channel == "" || chatID == "" {
|
||||||
return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
|
return &common.ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.sendCallback == nil {
|
if t.sendCallback == nil {
|
||||||
return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
|
return &common.ToolResult{ForLLM: "Message sending not configured", IsError: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := t.sendCallback(channel, chatID, content); err != nil {
|
if err := t.sendCallback(channel, chatID, content); err != nil {
|
||||||
return &ToolResult{
|
return &common.ToolResult{
|
||||||
ForLLM: fmt.Sprintf("sending message: %v", err),
|
ForLLM: fmt.Sprintf("sending message: %v", err),
|
||||||
IsError: true,
|
IsError: true,
|
||||||
Err: err,
|
Err: err,
|
||||||
|
|
@ -96,7 +98,7 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
||||||
|
|
||||||
t.sentInRound = true
|
t.sentInRound = true
|
||||||
// Silent: user already received the message directly
|
// Silent: user already received the message directly
|
||||||
return &ToolResult{
|
return &common.ToolResult{
|
||||||
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
|
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
|
||||||
Silent: true,
|
Silent: true,
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package message
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -36,7 +36,7 @@ func TestMessageTool_Execute_Success(t *testing.T) {
|
||||||
t.Errorf("Expected content 'Hello, world!', got '%s'", sentContent)
|
t.Errorf("Expected content 'Hello, world!', got '%s'", sentContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify ToolResult meets US-011 criteria:
|
// Verify tools.ToolResult meets US-011 criteria:
|
||||||
// - Send success returns SilentResult (Silent=true)
|
// - Send success returns SilentResult (Silent=true)
|
||||||
if !result.Silent {
|
if !result.Silent {
|
||||||
t.Error("Expected Silent=true for successful send")
|
t.Error("Expected Silent=true for successful send")
|
||||||
|
|
@ -110,7 +110,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
// Verify ToolResult for send failure:
|
// Verify tools.ToolResult for send failure:
|
||||||
// - Send failure returns ErrorResult (IsError=true)
|
// - Send failure returns ErrorResult (IsError=true)
|
||||||
if !result.IsError {
|
if !result.IsError {
|
||||||
t.Error("Expected IsError=true for failed send")
|
t.Error("Expected IsError=true for failed send")
|
||||||
55
pkg/tools/read_file/read_file.go
Normal file
55
pkg/tools/read_file/read_file.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package read_file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReadFileTool struct {
|
||||||
|
fs common.FileSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool {
|
||||||
|
var fs common.FileSystem
|
||||||
|
if restrict {
|
||||||
|
fs = &common.SandboxFs{Workspace: workspace}
|
||||||
|
} else {
|
||||||
|
fs = &common.HostFs{}
|
||||||
|
}
|
||||||
|
return &ReadFileTool{fs: fs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReadFileTool) Name() string {
|
||||||
|
return "read_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReadFileTool) Description() string {
|
||||||
|
return "Read the contents of a file"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReadFileTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the file to read",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
|
path, ok := args["path"].(string)
|
||||||
|
if !ok {
|
||||||
|
return common.ErrorResult("path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := t.fs.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
return common.NewToolResult(string(content))
|
||||||
|
}
|
||||||
136
pkg/tools/read_file/read_file_test.go
Normal file
136
pkg/tools/read_file/read_file_test.go
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
package read_file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFilesystemTool_ReadFile_Success verifies successful file reading
|
||||||
|
func TestFilesystemTool_ReadFile_Success(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
|
os.WriteFile(testFile, []byte("test content"), 0o644)
|
||||||
|
|
||||||
|
tool := NewReadFileTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Success should not be an error
|
||||||
|
if result.IsError {
|
||||||
|
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForLLM should contain file content
|
||||||
|
if !strings.Contains(result.ForLLM, "test content") {
|
||||||
|
t.Errorf("Expected ForLLM to contain 'test content', got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile returns NewToolResult which only sets ForLLM, not ForUser
|
||||||
|
// This is the expected behavior - file content goes to LLM, not directly to user
|
||||||
|
if result.ForUser != "" {
|
||||||
|
t.Errorf("Expected ForUser to be empty for NewToolResult, got: %s", result.ForUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
|
||||||
|
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
||||||
|
tool := NewReadFileTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": "/nonexistent_file_12345.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Failure should be marked as error
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error for missing file, got IsError=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should contain error message
|
||||||
|
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
|
||||||
|
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path
|
||||||
|
func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) {
|
||||||
|
tool := &ReadFileTool{}
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Should return error result
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error when path is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should mention required parameter
|
||||||
|
if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") {
|
||||||
|
t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block paths that look inside workspace but point outside via symlink.
|
||||||
|
func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
workspace := filepath.Join(root, "workspace")
|
||||||
|
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create workspace: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := filepath.Join(root, "secret.txt")
|
||||||
|
if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write secret file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
link := filepath.Join(workspace, "leak.txt")
|
||||||
|
if err := os.Symlink(secret, link); err != nil {
|
||||||
|
t.Skipf("symlink not supported in this environment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := NewReadFileTool(workspace, true)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": link,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatalf("expected symlink escape to be blocked")
|
||||||
|
}
|
||||||
|
// os.Root might return different errors depending on platform/implementation
|
||||||
|
// but it definitely should error.
|
||||||
|
// Our wrapper returns "access denied or file not found"
|
||||||
|
if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") &&
|
||||||
|
!strings.Contains(result.ForLLM, "no such file") {
|
||||||
|
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
|
||||||
|
tool := NewReadFileTool("", true) // restrict=true but workspace=""
|
||||||
|
|
||||||
|
// Try to read a sensitive file (simulated by a temp file outside workspace)
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
secretFile := filepath.Join(tmpDir, "shadow")
|
||||||
|
os.WriteFile(secretFile, []byte("secret data"), 0o600)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": secretFile,
|
||||||
|
})
|
||||||
|
|
||||||
|
// We EXPECT IsError=true (access blocked due to empty workspace)
|
||||||
|
assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
|
||||||
|
|
||||||
|
// Verify it failed for the right reason
|
||||||
|
assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
|
||||||
|
}
|
||||||
|
|
@ -7,8 +7,23 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"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/skills"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/append_file"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/edit_file"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/exec"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/find_skills"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/i2c"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/install_skill"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/list_dir"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/message"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/read_file"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/spi"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/web_fetch"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/web_search"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/write_file"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ToolRegistry struct {
|
type ToolRegistry struct {
|
||||||
|
|
@ -16,7 +31,98 @@ type ToolRegistry struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewToolRegistry() *ToolRegistry {
|
func NewToolRegistry(cfg *config.Config, workspace string, restrict bool) *ToolRegistry {
|
||||||
|
toolsRegistry := &ToolRegistry{
|
||||||
|
tools: make(map[string]Tool),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle nil config (for testing)
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = config.DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// File tools - each with individual configuration
|
||||||
|
if cfg.Tools.ReadFile.Enabled {
|
||||||
|
toolsRegistry.Register(read_file.NewReadFileTool(workspace, restrict))
|
||||||
|
}
|
||||||
|
if cfg.Tools.WriteFile.Enabled {
|
||||||
|
toolsRegistry.Register(write_file.NewWriteFileTool(workspace, restrict))
|
||||||
|
}
|
||||||
|
if cfg.Tools.EditFile.Enabled {
|
||||||
|
toolsRegistry.Register(edit_file.NewEditFileTool(workspace, restrict))
|
||||||
|
}
|
||||||
|
if cfg.Tools.AppendFile.Enabled {
|
||||||
|
toolsRegistry.Register(append_file.NewAppendFileTool(workspace, restrict))
|
||||||
|
}
|
||||||
|
if cfg.Tools.ListDir.Enabled {
|
||||||
|
toolsRegistry.Register(list_dir.NewListDirTool(workspace, restrict))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exec tool
|
||||||
|
if cfg.Tools.Exec.Enabled {
|
||||||
|
toolsRegistry.Register(exec.NewExecToolWithConfig(workspace, restrict, cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web tools
|
||||||
|
if searchTool := web_search.NewWebSearchTool(web_search.WebSearchToolOptions{
|
||||||
|
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
||||||
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
|
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
||||||
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||||
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
|
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
||||||
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
|
Proxy: cfg.Tools.Web.Proxy,
|
||||||
|
}); searchTool != nil {
|
||||||
|
toolsRegistry.Register(searchTool)
|
||||||
|
}
|
||||||
|
toolsRegistry.Register(web_fetch.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy))
|
||||||
|
|
||||||
|
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||||
|
if cfg.Tools.I2C.Enabled {
|
||||||
|
toolsRegistry.Register(i2c.NewI2CTool())
|
||||||
|
}
|
||||||
|
if cfg.Tools.SPI.Enabled {
|
||||||
|
toolsRegistry.Register(spi.NewSPITool())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill discovery and installation tools
|
||||||
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
|
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||||
|
})
|
||||||
|
searchCache := skills.NewSearchCache(
|
||||||
|
cfg.Tools.Skills.SearchCache.MaxSize,
|
||||||
|
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
||||||
|
)
|
||||||
|
if cfg.Tools.FindSkills.Enabled {
|
||||||
|
toolsRegistry.Register(find_skills.NewFindSkillsTool(registryMgr, searchCache))
|
||||||
|
}
|
||||||
|
if cfg.Tools.InstallSkill.Enabled {
|
||||||
|
toolsRegistry.Register(install_skill.NewInstallSkillTool(registryMgr, workspace))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message tool
|
||||||
|
if cfg.Tools.Message.Enabled {
|
||||||
|
toolsRegistry.Register(message.NewMessageTool())
|
||||||
|
}
|
||||||
|
|
||||||
|
// // Spawn tool
|
||||||
|
// if cfg.Tools.Spawn.Enabled {
|
||||||
|
// // Note: Spawn tool is registered separately in agent loop
|
||||||
|
// }
|
||||||
|
|
||||||
|
return toolsRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEmptyToolRegistry creates a tool registry without pre-registered tools.
|
||||||
|
// This is useful for testing.
|
||||||
|
func NewEmptyToolRegistry() *ToolRegistry {
|
||||||
return &ToolRegistry{
|
return &ToolRegistry{
|
||||||
tools: make(map[string]Tool),
|
tools: make(map[string]Tool),
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +227,17 @@ func (r *ToolRegistry) sortedToolNames() []string {
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ToolToSchema(tool Tool) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]any{
|
||||||
|
"name": tool.Name(),
|
||||||
|
"description": tool.Description(),
|
||||||
|
"parameters": tool.Parameters(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ToolRegistry) GetDefinitions() []map[string]any {
|
func (r *ToolRegistry) GetDefinitions() []map[string]any {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ func newMockTool(name, desc string) *mockRegistryTool {
|
||||||
// --- tests ---
|
// --- tests ---
|
||||||
|
|
||||||
func TestNewToolRegistry(t *testing.T) {
|
func TestNewToolRegistry(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
if r.Count() != 0 {
|
if r.Count() != 0 {
|
||||||
t.Errorf("expected empty registry, got count %d", r.Count())
|
t.Errorf("expected empty registry, got count %d", r.Count())
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +69,7 @@ func TestNewToolRegistry(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_RegisterAndGet(t *testing.T) {
|
func TestToolRegistry_RegisterAndGet(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
tool := newMockTool("echo", "echoes input")
|
tool := newMockTool("echo", "echoes input")
|
||||||
r.Register(tool)
|
r.Register(tool)
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@ func TestToolRegistry_RegisterAndGet(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_Get_NotFound(t *testing.T) {
|
func TestToolRegistry_Get_NotFound(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
_, ok := r.Get("nonexistent")
|
_, ok := r.Get("nonexistent")
|
||||||
if ok {
|
if ok {
|
||||||
t.Error("expected ok=false for unregistered tool")
|
t.Error("expected ok=false for unregistered tool")
|
||||||
|
|
@ -91,7 +91,7 @@ func TestToolRegistry_Get_NotFound(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_RegisterOverwrite(t *testing.T) {
|
func TestToolRegistry_RegisterOverwrite(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
r.Register(newMockTool("dup", "first"))
|
r.Register(newMockTool("dup", "first"))
|
||||||
r.Register(newMockTool("dup", "second"))
|
r.Register(newMockTool("dup", "second"))
|
||||||
|
|
||||||
|
|
@ -105,7 +105,7 @@ func TestToolRegistry_RegisterOverwrite(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_Execute_Success(t *testing.T) {
|
func TestToolRegistry_Execute_Success(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
r.Register(&mockRegistryTool{
|
r.Register(&mockRegistryTool{
|
||||||
name: "greet",
|
name: "greet",
|
||||||
desc: "says hello",
|
desc: "says hello",
|
||||||
|
|
@ -123,7 +123,7 @@ func TestToolRegistry_Execute_Success(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_Execute_NotFound(t *testing.T) {
|
func TestToolRegistry_Execute_NotFound(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
result := r.Execute(context.Background(), "missing", nil)
|
result := r.Execute(context.Background(), "missing", nil)
|
||||||
if !result.IsError {
|
if !result.IsError {
|
||||||
t.Error("expected error for missing tool")
|
t.Error("expected error for missing tool")
|
||||||
|
|
@ -137,7 +137,7 @@ func TestToolRegistry_Execute_NotFound(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
|
func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
ct := &mockCtxTool{
|
ct := &mockCtxTool{
|
||||||
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
|
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +154,7 @@ func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) {
|
func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
ct := &mockCtxTool{
|
ct := &mockCtxTool{
|
||||||
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
|
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
|
||||||
}
|
}
|
||||||
|
|
@ -168,7 +168,7 @@ func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
|
func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
at := &mockAsyncRegistryTool{
|
at := &mockAsyncRegistryTool{
|
||||||
mockRegistryTool: *newMockTool("async_tool", "async work"),
|
mockRegistryTool: *newMockTool("async_tool", "async work"),
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +193,7 @@ func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_GetDefinitions(t *testing.T) {
|
func TestToolRegistry_GetDefinitions(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
r.Register(newMockTool("alpha", "tool A"))
|
r.Register(newMockTool("alpha", "tool A"))
|
||||||
|
|
||||||
defs := r.GetDefinitions()
|
defs := r.GetDefinitions()
|
||||||
|
|
@ -216,7 +216,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_ToProviderDefs(t *testing.T) {
|
func TestToolRegistry_ToProviderDefs(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
params := map[string]any{"type": "object", "properties": map[string]any{}}
|
params := map[string]any{"type": "object", "properties": map[string]any{}}
|
||||||
r.Register(&mockRegistryTool{
|
r.Register(&mockRegistryTool{
|
||||||
name: "beta",
|
name: "beta",
|
||||||
|
|
@ -251,7 +251,7 @@ func TestToolRegistry_ToProviderDefs(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_List(t *testing.T) {
|
func TestToolRegistry_List(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
r.Register(newMockTool("x", ""))
|
r.Register(newMockTool("x", ""))
|
||||||
r.Register(newMockTool("y", ""))
|
r.Register(newMockTool("y", ""))
|
||||||
|
|
||||||
|
|
@ -270,7 +270,7 @@ func TestToolRegistry_List(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_Count(t *testing.T) {
|
func TestToolRegistry_Count(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
if r.Count() != 0 {
|
if r.Count() != 0 {
|
||||||
t.Errorf("expected 0, got %d", r.Count())
|
t.Errorf("expected 0, got %d", r.Count())
|
||||||
}
|
}
|
||||||
|
|
@ -288,7 +288,7 @@ func TestToolRegistry_Count(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_GetSummaries(t *testing.T) {
|
func TestToolRegistry_GetSummaries(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
r.Register(newMockTool("read_file", "Reads a file"))
|
r.Register(newMockTool("read_file", "Reads a file"))
|
||||||
|
|
||||||
summaries := r.GetSummaries()
|
summaries := r.GetSummaries()
|
||||||
|
|
@ -326,7 +326,7 @@ func TestToolToSchema(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestToolRegistry_ConcurrentAccess(t *testing.T) {
|
func TestToolRegistry_ConcurrentAccess(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewEmptyToolRegistry()
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for i := 0; i < 50; i++ {
|
for i := 0; i < 50; i++ {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package spi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -7,6 +7,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SPITool provides SPI bus interaction for high-speed peripheral communication.
|
// SPITool provides SPI bus interaction for high-speed peripheral communication.
|
||||||
|
|
@ -67,14 +69,14 @@ func (t *SPITool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *SPITool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
if runtime.GOOS != "linux" {
|
if runtime.GOOS != "linux" {
|
||||||
return ErrorResult("SPI is only supported on Linux. This tool requires /dev/spidev* device files.")
|
return common.ErrorResult("SPI is only supported on Linux. This tool requires /dev/spidev* device files.")
|
||||||
}
|
}
|
||||||
|
|
||||||
action, ok := args["action"].(string)
|
action, ok := args["action"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("action is required")
|
return common.ErrorResult("action is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
|
|
@ -85,19 +87,19 @@ func (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
case "read":
|
case "read":
|
||||||
return t.readDevice(args)
|
return t.readDevice(args)
|
||||||
default:
|
default:
|
||||||
return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, transfer, read)", action))
|
return common.ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, transfer, read)", action))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// list finds available SPI devices by globbing /dev/spidev*
|
// list finds available SPI devices by globbing /dev/spidev*
|
||||||
func (t *SPITool) list() *ToolResult {
|
func (t *SPITool) list() *common.ToolResult {
|
||||||
matches, err := filepath.Glob("/dev/spidev*")
|
matches, err := filepath.Glob("/dev/spidev*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to scan for SPI devices: %v", err))
|
return common.ErrorResult(fmt.Sprintf("failed to scan for SPI devices: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
return SilentResult(
|
return common.SilentResult(
|
||||||
"No SPI devices found. You may need to:\n1. Enable SPI in device tree\n2. Configure pinmux for your board (see hardware skill)\n3. Check that spidev module is loaded",
|
"No SPI devices found. You may need to:\n1. Enable SPI in device tree\n2. Configure pinmux for your board (see hardware skill)\n3. Check that spidev module is loaded",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +118,7 @@ func (t *SPITool) list() *ToolResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
result, _ := json.MarshalIndent(devices, "", " ")
|
result, _ := json.MarshalIndent(devices, "", " ")
|
||||||
return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result)))
|
return common.SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function for SPI operations (used by platform-specific implementations)
|
// Helper function for SPI operations (used by platform-specific implementations)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package spi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"runtime"
|
"runtime"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SPI ioctl constants from Linux kernel headers.
|
// SPI ioctl constants from Linux kernel headers.
|
||||||
|
|
@ -35,67 +37,67 @@ type spiTransfer struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// configureSPI opens an SPI device and sets mode, bits per word, and speed
|
// configureSPI opens an SPI device and sets mode, bits per word, and speed
|
||||||
func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) {
|
func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *common.ToolResult) {
|
||||||
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
|
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1, ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err))
|
return -1, common.ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set SPI mode
|
// Set SPI mode
|
||||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMode, uintptr(unsafe.Pointer(&mode)))
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMode, uintptr(unsafe.Pointer(&mode)))
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
syscall.Close(fd)
|
syscall.Close(fd)
|
||||||
return -1, ErrorResult(fmt.Sprintf("failed to set SPI mode %d: %v", mode, errno))
|
return -1, common.ErrorResult(fmt.Sprintf("failed to set SPI mode %d: %v", mode, errno))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set bits per word
|
// Set bits per word
|
||||||
_, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrBitsPerWord, uintptr(unsafe.Pointer(&bits)))
|
_, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrBitsPerWord, uintptr(unsafe.Pointer(&bits)))
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
syscall.Close(fd)
|
syscall.Close(fd)
|
||||||
return -1, ErrorResult(fmt.Sprintf("failed to set bits per word %d: %v", bits, errno))
|
return -1, common.ErrorResult(fmt.Sprintf("failed to set bits per word %d: %v", bits, errno))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set max speed
|
// Set max speed
|
||||||
_, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMaxSpeedHz, uintptr(unsafe.Pointer(&speed)))
|
_, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMaxSpeedHz, uintptr(unsafe.Pointer(&speed)))
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
syscall.Close(fd)
|
syscall.Close(fd)
|
||||||
return -1, ErrorResult(fmt.Sprintf("failed to set SPI speed %d Hz: %v", speed, errno))
|
return -1, common.ErrorResult(fmt.Sprintf("failed to set SPI speed %d Hz: %v", speed, errno))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fd, nil
|
return fd, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// transfer performs a full-duplex SPI transfer
|
// transfer performs a full-duplex SPI transfer
|
||||||
func (t *SPITool) transfer(args map[string]any) *ToolResult {
|
func (t *SPITool) transfer(args map[string]any) *common.ToolResult {
|
||||||
confirm, _ := args["confirm"].(bool)
|
confirm, _ := args["confirm"].(bool)
|
||||||
if !confirm {
|
if !confirm {
|
||||||
return ErrorResult(
|
return common.ErrorResult(
|
||||||
"transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.",
|
"transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
dev, speed, mode, bits, errMsg := parseSPIArgs(args)
|
dev, speed, mode, bits, errMsg := parseSPIArgs(args)
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
return ErrorResult(errMsg)
|
return common.ErrorResult(errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
dataRaw, ok := args["data"].([]any)
|
dataRaw, ok := args["data"].([]any)
|
||||||
if !ok || len(dataRaw) == 0 {
|
if !ok || len(dataRaw) == 0 {
|
||||||
return ErrorResult("data is required for transfer (array of byte values 0-255)")
|
return common.ErrorResult("data is required for transfer (array of byte values 0-255)")
|
||||||
}
|
}
|
||||||
if len(dataRaw) > 4096 {
|
if len(dataRaw) > 4096 {
|
||||||
return ErrorResult("data too long: maximum 4096 bytes per SPI transfer")
|
return common.ErrorResult("data too long: maximum 4096 bytes per SPI transfer")
|
||||||
}
|
}
|
||||||
|
|
||||||
txBuf := make([]byte, len(dataRaw))
|
txBuf := make([]byte, len(dataRaw))
|
||||||
for i, v := range dataRaw {
|
for i, v := range dataRaw {
|
||||||
f, ok := v.(float64)
|
f, ok := v.(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i))
|
return common.ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i))
|
||||||
}
|
}
|
||||||
b := int(f)
|
b := int(f)
|
||||||
if b < 0 || b > 255 {
|
if b < 0 || b > 255 {
|
||||||
return ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b))
|
return common.ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b))
|
||||||
}
|
}
|
||||||
txBuf[i] = byte(b)
|
txBuf[i] = byte(b)
|
||||||
}
|
}
|
||||||
|
|
@ -121,7 +123,7 @@ func (t *SPITool) transfer(args map[string]any) *ToolResult {
|
||||||
runtime.KeepAlive(txBuf)
|
runtime.KeepAlive(txBuf)
|
||||||
runtime.KeepAlive(rxBuf)
|
runtime.KeepAlive(rxBuf)
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return ErrorResult(fmt.Sprintf("SPI transfer failed: %v", errno))
|
return common.ErrorResult(fmt.Sprintf("SPI transfer failed: %v", errno))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format received bytes
|
// Format received bytes
|
||||||
|
|
@ -138,14 +140,14 @@ func (t *SPITool) transfer(args map[string]any) *ToolResult {
|
||||||
"received": intBytes,
|
"received": intBytes,
|
||||||
"hex": hexBytes,
|
"hex": hexBytes,
|
||||||
}, "", " ")
|
}, "", " ")
|
||||||
return SilentResult(string(result))
|
return common.SilentResult(string(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed)
|
// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed)
|
||||||
func (t *SPITool) readDevice(args map[string]any) *ToolResult {
|
func (t *SPITool) readDevice(args map[string]any) *common.ToolResult {
|
||||||
dev, speed, mode, bits, errMsg := parseSPIArgs(args)
|
dev, speed, mode, bits, errMsg := parseSPIArgs(args)
|
||||||
if errMsg != "" {
|
if errMsg != "" {
|
||||||
return ErrorResult(errMsg)
|
return common.ErrorResult(errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
length := 0
|
length := 0
|
||||||
|
|
@ -153,7 +155,7 @@ func (t *SPITool) readDevice(args map[string]any) *ToolResult {
|
||||||
length = int(l)
|
length = int(l)
|
||||||
}
|
}
|
||||||
if length < 1 || length > 4096 {
|
if length < 1 || length > 4096 {
|
||||||
return ErrorResult("length is required for read (1-4096)")
|
return common.ErrorResult("length is required for read (1-4096)")
|
||||||
}
|
}
|
||||||
|
|
||||||
devPath := fmt.Sprintf("/dev/spidev%s", dev)
|
devPath := fmt.Sprintf("/dev/spidev%s", dev)
|
||||||
|
|
@ -178,7 +180,7 @@ func (t *SPITool) readDevice(args map[string]any) *ToolResult {
|
||||||
runtime.KeepAlive(txBuf)
|
runtime.KeepAlive(txBuf)
|
||||||
runtime.KeepAlive(rxBuf)
|
runtime.KeepAlive(rxBuf)
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return ErrorResult(fmt.Sprintf("SPI read failed: %v", errno))
|
return common.ErrorResult(fmt.Sprintf("SPI read failed: %v", errno))
|
||||||
}
|
}
|
||||||
|
|
||||||
hexBytes := make([]string, len(rxBuf))
|
hexBytes := make([]string, len(rxBuf))
|
||||||
|
|
@ -194,5 +196,5 @@ func (t *SPITool) readDevice(args map[string]any) *ToolResult {
|
||||||
"hex": hexBytes,
|
"hex": hexBytes,
|
||||||
"length": len(rxBuf),
|
"length": len(rxBuf),
|
||||||
}, "", " ")
|
}, "", " ")
|
||||||
return SilentResult(string(result))
|
return common.SilentResult(string(result))
|
||||||
}
|
}
|
||||||
15
pkg/tools/spi/spi_other.go
Normal file
15
pkg/tools/spi/spi_other.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package spi
|
||||||
|
|
||||||
|
import "github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
|
|
||||||
|
// transfer is a stub for non-Linux platforms.
|
||||||
|
func (t *SPITool) transfer(args map[string]any) *common.ToolResult {
|
||||||
|
return common.ErrorResult("SPI is only supported on Linux")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readDevice is a stub for non-Linux platforms.
|
||||||
|
func (t *SPITool) readDevice(args map[string]any) *common.ToolResult {
|
||||||
|
return common.ErrorResult("SPI is only supported on Linux")
|
||||||
|
}
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
//go:build !linux
|
|
||||||
|
|
||||||
package tools
|
|
||||||
|
|
||||||
// transfer is a stub for non-Linux platforms.
|
|
||||||
func (t *SPITool) transfer(args map[string]any) *ToolResult {
|
|
||||||
return ErrorResult("SPI is only supported on Linux")
|
|
||||||
}
|
|
||||||
|
|
||||||
// readDevice is a stub for non-Linux platforms.
|
|
||||||
func (t *SPITool) readDevice(args map[string]any) *ToolResult {
|
|
||||||
return ErrorResult("SPI is only supported on Linux")
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package tools
|
package subagent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SpawnTool struct {
|
type SpawnTool struct {
|
||||||
|
|
@ -11,7 +13,7 @@ type SpawnTool struct {
|
||||||
originChannel string
|
originChannel string
|
||||||
originChatID string
|
originChatID string
|
||||||
allowlistCheck func(targetAgentID string) bool
|
allowlistCheck func(targetAgentID string) bool
|
||||||
callback AsyncCallback // For async completion notification
|
callback tools.AsyncCallback // For async completion notification
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSpawnTool(manager *SubagentManager) *SpawnTool {
|
func NewSpawnTool(manager *SubagentManager) *SpawnTool {
|
||||||
|
|
@ -23,7 +25,7 @@ func NewSpawnTool(manager *SubagentManager) *SpawnTool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCallback implements AsyncTool interface for async completion notification
|
// SetCallback implements AsyncTool interface for async completion notification
|
||||||
func (t *SpawnTool) SetCallback(cb AsyncCallback) {
|
func (t *SpawnTool) SetCallback(cb tools.AsyncCallback) {
|
||||||
t.callback = cb
|
t.callback = cb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,10 +67,10 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
|
||||||
t.allowlistCheck = check
|
t.allowlistCheck = check
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||||
task, ok := args["task"].(string)
|
task, ok := args["task"].(string)
|
||||||
if !ok || strings.TrimSpace(task) == "" {
|
if !ok || strings.TrimSpace(task) == "" {
|
||||||
return ErrorResult("task is required and must be a non-empty string")
|
return tools.ErrorResult("task is required and must be a non-empty string")
|
||||||
}
|
}
|
||||||
|
|
||||||
label, _ := args["label"].(string)
|
label, _ := args["label"].(string)
|
||||||
|
|
@ -77,20 +79,20 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul
|
||||||
// Check allowlist if targeting a specific agent
|
// Check allowlist if targeting a specific agent
|
||||||
if agentID != "" && t.allowlistCheck != nil {
|
if agentID != "" && t.allowlistCheck != nil {
|
||||||
if !t.allowlistCheck(agentID) {
|
if !t.allowlistCheck(agentID) {
|
||||||
return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", agentID))
|
return tools.ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", agentID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.manager == nil {
|
if t.manager == nil {
|
||||||
return ErrorResult("Subagent manager not configured")
|
return tools.ErrorResult("Subagent manager not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass callback to manager for async completion notification
|
// Pass callback to manager for async completion notification
|
||||||
result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, t.callback)
|
result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, t.callback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err))
|
return tools.ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return AsyncResult since the task runs in background
|
// Return AsyncResult since the task runs in background
|
||||||
return AsyncResult(result)
|
return tools.AsyncResult(result)
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package subagent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package subagent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SubagentTask struct {
|
type SubagentTask struct {
|
||||||
|
|
@ -29,7 +31,7 @@ type SubagentManager struct {
|
||||||
defaultModel string
|
defaultModel string
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
workspace string
|
workspace string
|
||||||
tools *ToolRegistry
|
tools *tools.ToolRegistry
|
||||||
maxIterations int
|
maxIterations int
|
||||||
maxTokens int
|
maxTokens int
|
||||||
temperature float64
|
temperature float64
|
||||||
|
|
@ -49,7 +51,7 @@ func NewSubagentManager(
|
||||||
defaultModel: defaultModel,
|
defaultModel: defaultModel,
|
||||||
bus: bus,
|
bus: bus,
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
tools: NewToolRegistry(),
|
tools: tools.NewToolRegistry(nil, "", false),
|
||||||
maxIterations: 10,
|
maxIterations: 10,
|
||||||
nextID: 1,
|
nextID: 1,
|
||||||
}
|
}
|
||||||
|
|
@ -67,14 +69,14 @@ func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
|
||||||
|
|
||||||
// SetTools sets the tool registry for subagent execution.
|
// SetTools sets the tool registry for subagent execution.
|
||||||
// If not set, subagent will have access to the provided tools.
|
// If not set, subagent will have access to the provided tools.
|
||||||
func (sm *SubagentManager) SetTools(tools *ToolRegistry) {
|
func (sm *SubagentManager) SetTools(tools *tools.ToolRegistry) {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
defer sm.mu.Unlock()
|
||||||
sm.tools = tools
|
sm.tools = tools
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterTool registers a tool for subagent execution.
|
// RegisterTool registers a tool for subagent execution.
|
||||||
func (sm *SubagentManager) RegisterTool(tool Tool) {
|
func (sm *SubagentManager) RegisterTool(tool tools.Tool) {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
defer sm.mu.Unlock()
|
||||||
sm.tools.Register(tool)
|
sm.tools.Register(tool)
|
||||||
|
|
@ -83,7 +85,7 @@ func (sm *SubagentManager) RegisterTool(tool Tool) {
|
||||||
func (sm *SubagentManager) Spawn(
|
func (sm *SubagentManager) Spawn(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
task, label, agentID, originChannel, originChatID string,
|
task, label, agentID, originChannel, originChatID string,
|
||||||
callback AsyncCallback,
|
callback common.AsyncCallback,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
defer sm.mu.Unlock()
|
||||||
|
|
@ -112,7 +114,7 @@ func (sm *SubagentManager) Spawn(
|
||||||
return fmt.Sprintf("Spawned subagent for task: %s", task), nil
|
return fmt.Sprintf("Spawned subagent for task: %s", task), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) {
|
func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback common.AsyncCallback) {
|
||||||
task.Status = "running"
|
task.Status = "running"
|
||||||
task.Created = time.Now().UnixMilli()
|
task.Created = time.Now().UnixMilli()
|
||||||
|
|
||||||
|
|
@ -145,7 +147,7 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
|
|
||||||
// Run tool loop with access to tools
|
// Run tool loop with access to tools
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
tools := sm.tools
|
sm_tools := sm.tools
|
||||||
maxIter := sm.maxIterations
|
maxIter := sm.maxIterations
|
||||||
maxTokens := sm.maxTokens
|
maxTokens := sm.maxTokens
|
||||||
temperature := sm.temperature
|
temperature := sm.temperature
|
||||||
|
|
@ -164,16 +166,16 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
loopResult, err := tools.RunToolLoop(ctx, tools.ToolLoopConfig{
|
||||||
Provider: sm.provider,
|
Provider: sm.provider,
|
||||||
Model: sm.defaultModel,
|
Model: sm.defaultModel,
|
||||||
Tools: tools,
|
Tools: sm_tools,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
LLMOptions: llmOptions,
|
LLMOptions: llmOptions,
|
||||||
}, messages, task.OriginChannel, task.OriginChatID)
|
}, messages, task.OriginChannel, task.OriginChatID)
|
||||||
|
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
var result *ToolResult
|
var result *common.ToolResult
|
||||||
defer func() {
|
defer func() {
|
||||||
sm.mu.Unlock()
|
sm.mu.Unlock()
|
||||||
// Call callback if provided and result is set
|
// Call callback if provided and result is set
|
||||||
|
|
@ -190,7 +192,7 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
task.Status = "canceled"
|
task.Status = "canceled"
|
||||||
task.Result = "Task canceled during execution"
|
task.Result = "Task canceled during execution"
|
||||||
}
|
}
|
||||||
result = &ToolResult{
|
result = &common.ToolResult{
|
||||||
ForLLM: task.Result,
|
ForLLM: task.Result,
|
||||||
ForUser: "",
|
ForUser: "",
|
||||||
Silent: false,
|
Silent: false,
|
||||||
|
|
@ -201,7 +203,7 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
} else {
|
} else {
|
||||||
task.Status = "completed"
|
task.Status = "completed"
|
||||||
task.Result = loopResult.Content
|
task.Result = loopResult.Content
|
||||||
result = &ToolResult{
|
result = &common.ToolResult{
|
||||||
ForLLM: fmt.Sprintf(
|
ForLLM: fmt.Sprintf(
|
||||||
"Subagent '%s' completed (iterations: %d): %s",
|
"Subagent '%s' completed (iterations: %d): %s",
|
||||||
task.Label,
|
task.Label,
|
||||||
|
|
@ -248,7 +250,7 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask {
|
||||||
|
|
||||||
// SubagentTool executes a subagent task synchronously and returns the result.
|
// SubagentTool executes a subagent task synchronously and returns the result.
|
||||||
// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion
|
// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion
|
||||||
// and returns the result directly in the ToolResult.
|
// and returns the result directly in the common.ToolResult.
|
||||||
type SubagentTool struct {
|
type SubagentTool struct {
|
||||||
manager *SubagentManager
|
manager *SubagentManager
|
||||||
originChannel string
|
originChannel string
|
||||||
|
|
@ -293,16 +295,16 @@ func (t *SubagentTool) SetContext(channel, chatID string) {
|
||||||
t.originChatID = chatID
|
t.originChatID = chatID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
task, ok := args["task"].(string)
|
task, ok := args["task"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("task is required").WithError(fmt.Errorf("task parameter is required"))
|
return common.ErrorResult("task is required").WithError(fmt.Errorf("task parameter is required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
label, _ := args["label"].(string)
|
label, _ := args["label"].(string)
|
||||||
|
|
||||||
if t.manager == nil {
|
if t.manager == nil {
|
||||||
return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil"))
|
return common.ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build messages for subagent
|
// Build messages for subagent
|
||||||
|
|
@ -317,10 +319,10 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use RunToolLoop to execute with tools (same as async SpawnTool)
|
// Use common.RunToolLoop to execute with tools (same as async SpawnTool)
|
||||||
sm := t.manager
|
sm := t.manager
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
tools := sm.tools
|
sm_tools := sm.tools
|
||||||
maxIter := sm.maxIterations
|
maxIter := sm.maxIterations
|
||||||
maxTokens := sm.maxTokens
|
maxTokens := sm.maxTokens
|
||||||
temperature := sm.temperature
|
temperature := sm.temperature
|
||||||
|
|
@ -339,15 +341,15 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
|
loopResult, err := tools.RunToolLoop(ctx, tools.ToolLoopConfig{
|
||||||
Provider: sm.provider,
|
Provider: sm.provider,
|
||||||
Model: sm.defaultModel,
|
Model: sm.defaultModel,
|
||||||
Tools: tools,
|
Tools: sm_tools,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
LLMOptions: llmOptions,
|
LLMOptions: llmOptions,
|
||||||
}, messages, t.originChannel, t.originChatID)
|
}, messages, t.originChannel, t.originChatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
|
return common.ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForUser: Brief summary for user (truncated if too long)
|
// ForUser: Brief summary for user (truncated if too long)
|
||||||
|
|
@ -365,7 +367,7 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nIterations: %d\nResult: %s",
|
llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nIterations: %d\nResult: %s",
|
||||||
labelStr, loopResult.Iterations, loopResult.Content)
|
labelStr, loopResult.Iterations, loopResult.Content)
|
||||||
|
|
||||||
return &ToolResult{
|
return &common.ToolResult{
|
||||||
ForLLM: llmContent,
|
ForLLM: llmContent,
|
||||||
ForUser: userContent,
|
ForUser: userContent,
|
||||||
Silent: false,
|
Silent: false,
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package subagent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
package tools
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
type Message struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolCall struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Function *FunctionCall `json:"function,omitempty"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Arguments map[string]any `json:"arguments,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type FunctionCall struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Arguments string `json:"arguments"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LLMResponse struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
|
||||||
FinishReason string `json:"finish_reason"`
|
|
||||||
Usage *UsageInfo `json:"usage,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UsageInfo struct {
|
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
|
||||||
TotalTokens int `json:"total_tokens"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LLMProvider interface {
|
|
||||||
Chat(
|
|
||||||
ctx context.Context,
|
|
||||||
messages []Message,
|
|
||||||
tools []ToolDefinition,
|
|
||||||
model string,
|
|
||||||
options map[string]any,
|
|
||||||
) (*LLMResponse, error)
|
|
||||||
GetDefaultModel() string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolDefinition struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Function ToolFunctionDefinition `json:"function"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolFunctionDefinition struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Parameters map[string]any `json:"parameters"`
|
|
||||||
}
|
|
||||||
38
pkg/tools/types_export.go
Normal file
38
pkg/tools/types_export.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tool = common.Tool
|
||||||
|
type ToolResult = common.ToolResult
|
||||||
|
type ContextualTool = common.ContextualTool
|
||||||
|
type AsyncTool = common.AsyncTool
|
||||||
|
type AsyncCallback = common.AsyncCallback
|
||||||
|
type FileSystem = common.FileSystem
|
||||||
|
type HostFs = common.HostFs
|
||||||
|
type SandboxFs = common.SandboxFs
|
||||||
|
|
||||||
|
func NewToolResult(forLLM string) *ToolResult {
|
||||||
|
return common.NewToolResult(forLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SilentResult(forLLM string) *ToolResult {
|
||||||
|
return common.SilentResult(forLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AsyncResult(forLLM string) *ToolResult {
|
||||||
|
return common.AsyncResult(forLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrorResult(message string) *ToolResult {
|
||||||
|
return common.ErrorResult(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserResult(content string) *ToolResult {
|
||||||
|
return common.UserResult(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidatePath(path, workspace string, restrict bool) (string, error) {
|
||||||
|
return common.ValidatePath(path, workspace, restrict)
|
||||||
|
}
|
||||||
194
pkg/tools/web_fetch/web_fetch.go
Normal file
194
pkg/tools/web_fetch/web_fetch.go
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
package web_fetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebFetchTool struct {
|
||||||
|
maxChars int
|
||||||
|
proxy string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebFetchTool(maxChars int) *WebFetchTool {
|
||||||
|
if maxChars <= 0 {
|
||||||
|
maxChars = 50000
|
||||||
|
}
|
||||||
|
return &WebFetchTool{
|
||||||
|
maxChars: maxChars,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebFetchToolWithProxy(maxChars int, proxy string) *WebFetchTool {
|
||||||
|
if maxChars <= 0 {
|
||||||
|
maxChars = 50000
|
||||||
|
}
|
||||||
|
return &WebFetchTool{
|
||||||
|
maxChars: maxChars,
|
||||||
|
proxy: proxy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *WebFetchTool) Name() string {
|
||||||
|
return "web_fetch"
|
||||||
|
}
|
||||||
|
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *WebFetchTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"url": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "URL to fetch",
|
||||||
|
},
|
||||||
|
"maxChars": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum characters to extract",
|
||||||
|
"minimum": 100.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
|
urlStr, ok := args["url"].(string)
|
||||||
|
if !ok {
|
||||||
|
return common.ErrorResult("url is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrorResult(fmt.Sprintf("invalid URL: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||||
|
return common.ErrorResult("only http/https URLs are allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsedURL.Host == "" {
|
||||||
|
return common.ErrorResult("missing domain in URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
maxChars := t.maxChars
|
||||||
|
if mc, ok := args["maxChars"].(float64); ok {
|
||||||
|
if int(mc) > 100 {
|
||||||
|
maxChars = int(mc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrorResult(fmt.Sprintf("failed to create request: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", common.UserAgent)
|
||||||
|
|
||||||
|
client, err := common.CreateHTTPClient(t.proxy, 60*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrorResult(fmt.Sprintf("failed to create HTTP client: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure redirect handling
|
||||||
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 5 {
|
||||||
|
return fmt.Errorf("stopped after 5 redirects")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrorResult(fmt.Sprintf("request failed: %v", err))
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrorResult(fmt.Sprintf("failed to read response: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
|
||||||
|
var text, extractor string
|
||||||
|
|
||||||
|
if strings.Contains(contentType, "application/json") {
|
||||||
|
var jsonData any
|
||||||
|
if err := json.Unmarshal(body, &jsonData); err == nil {
|
||||||
|
formatted, _ := json.MarshalIndent(jsonData, "", " ")
|
||||||
|
text = string(formatted)
|
||||||
|
extractor = "json"
|
||||||
|
} else {
|
||||||
|
text = string(body)
|
||||||
|
extractor = "raw"
|
||||||
|
}
|
||||||
|
} else if strings.Contains(contentType, "text/html") || len(body) > 0 &&
|
||||||
|
(strings.HasPrefix(string(body), "<!DOCTYPE") || strings.HasPrefix(strings.ToLower(string(body)), "<html")) {
|
||||||
|
text = t.extractText(string(body))
|
||||||
|
extractor = "text"
|
||||||
|
} else {
|
||||||
|
text = string(body)
|
||||||
|
extractor = "raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
truncated := len(text) > maxChars
|
||||||
|
if truncated {
|
||||||
|
text = text[:maxChars]
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]any{
|
||||||
|
"url": urlStr,
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"extractor": extractor,
|
||||||
|
"truncated": truncated,
|
||||||
|
"length": len(text),
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
|
|
||||||
|
resultJSON, _ := json.MarshalIndent(result, "", " ")
|
||||||
|
|
||||||
|
return &common.ToolResult{
|
||||||
|
ForLLM: fmt.Sprintf(
|
||||||
|
"Fetched %d bytes from %s (extractor: %s, truncated: %v)",
|
||||||
|
len(text),
|
||||||
|
urlStr,
|
||||||
|
extractor,
|
||||||
|
truncated,
|
||||||
|
),
|
||||||
|
ForUser: string(resultJSON),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *WebFetchTool) extractText(htmlContent string) string {
|
||||||
|
result := common.ReScript.ReplaceAllLiteralString(htmlContent, "")
|
||||||
|
result = common.ReStyle.ReplaceAllLiteralString(result, "")
|
||||||
|
result = common.ReTags.ReplaceAllLiteralString(result, "")
|
||||||
|
|
||||||
|
result = strings.TrimSpace(result)
|
||||||
|
|
||||||
|
result = common.ReWhitespace.ReplaceAllString(result, " ")
|
||||||
|
result = common.ReBlankLines.ReplaceAllString(result, "\n\n")
|
||||||
|
|
||||||
|
lines := strings.Split(result, "\n")
|
||||||
|
var cleanLines []string
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
cleanLines = append(cleanLines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(cleanLines, "\n")
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
package tools
|
package web_fetch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -174,34 +175,6 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing
|
|
||||||
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
|
|
||||||
tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""})
|
|
||||||
if tool != nil {
|
|
||||||
t.Errorf("Expected nil tool when Brave API key is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also nil when nothing is enabled
|
|
||||||
tool = NewWebSearchTool(WebSearchToolOptions{})
|
|
||||||
if tool != nil {
|
|
||||||
t.Errorf("Expected nil tool when no provider is enabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query
|
|
||||||
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
|
|
||||||
tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5})
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{}
|
|
||||||
|
|
||||||
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
|
// TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction
|
||||||
func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {
|
func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -336,9 +309,9 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateHTTPClient_ProxyConfigured(t *testing.T) {
|
func TestCreateHTTPClient_ProxyConfigured(t *testing.T) {
|
||||||
client, err := createHTTPClient("http://127.0.0.1:7890", 12*time.Second)
|
client, err := common.CreateHTTPClient("http://127.0.0.1:7890", 12*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("createHTTPClient() error: %v", err)
|
t.Fatalf("common.CreateHTTPClient() error: %v", err)
|
||||||
}
|
}
|
||||||
if client.Timeout != 12*time.Second {
|
if client.Timeout != 12*time.Second {
|
||||||
t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second)
|
t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second)
|
||||||
|
|
@ -366,16 +339,16 @@ func TestCreateHTTPClient_ProxyConfigured(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateHTTPClient_InvalidProxy(t *testing.T) {
|
func TestCreateHTTPClient_InvalidProxy(t *testing.T) {
|
||||||
_, err := createHTTPClient("://bad-proxy", 10*time.Second)
|
_, err := common.CreateHTTPClient("://bad-proxy", 10*time.Second)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("createHTTPClient() expected error for invalid proxy URL, got nil")
|
t.Fatal("common.CreateHTTPClient() expected error for invalid proxy URL, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) {
|
func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) {
|
||||||
client, err := createHTTPClient("socks5://127.0.0.1:1080", 8*time.Second)
|
client, err := common.CreateHTTPClient("socks5://127.0.0.1:1080", 8*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("createHTTPClient() error: %v", err)
|
t.Fatalf("common.CreateHTTPClient() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tr, ok := client.Transport.(*http.Transport)
|
tr, ok := client.Transport.(*http.Transport)
|
||||||
|
|
@ -396,9 +369,9 @@ func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) {
|
func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) {
|
||||||
_, err := createHTTPClient("ftp://127.0.0.1:21", 10*time.Second)
|
_, err := common.CreateHTTPClient("ftp://127.0.0.1:21", 10*time.Second)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("createHTTPClient() expected error for unsupported scheme, got nil")
|
t.Fatal("common.CreateHTTPClient() expected error for unsupported scheme, got nil")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "unsupported proxy scheme") {
|
if !strings.Contains(err.Error(), "unsupported proxy scheme") {
|
||||||
t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme")
|
t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme")
|
||||||
|
|
@ -415,9 +388,9 @@ func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) {
|
||||||
t.Setenv("NO_PROXY", "")
|
t.Setenv("NO_PROXY", "")
|
||||||
t.Setenv("no_proxy", "")
|
t.Setenv("no_proxy", "")
|
||||||
|
|
||||||
client, err := createHTTPClient("", 10*time.Second)
|
client, err := common.CreateHTTPClient("", 10*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("createHTTPClient() error: %v", err)
|
t.Fatalf("common.CreateHTTPClient() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tr, ok := client.Transport.(*http.Transport)
|
tr, ok := client.Transport.(*http.Transport)
|
||||||
|
|
@ -451,124 +424,3 @@ func TestNewWebFetchToolWithProxy(t *testing.T) {
|
||||||
t.Fatalf("default maxChars = %d, want %d", tool.maxChars, 50000)
|
t.Fatalf("default maxChars = %d, want %d", tool.maxChars, 50000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
|
||||||
t.Run("perplexity", func(t *testing.T) {
|
|
||||||
tool := NewWebSearchTool(WebSearchToolOptions{
|
|
||||||
PerplexityEnabled: true,
|
|
||||||
PerplexityAPIKey: "k",
|
|
||||||
PerplexityMaxResults: 3,
|
|
||||||
Proxy: "http://127.0.0.1:7890",
|
|
||||||
})
|
|
||||||
p, ok := tool.provider.(*PerplexitySearchProvider)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("provider type = %T, want *PerplexitySearchProvider", tool.provider)
|
|
||||||
}
|
|
||||||
if p.proxy != "http://127.0.0.1:7890" {
|
|
||||||
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("brave", func(t *testing.T) {
|
|
||||||
tool := NewWebSearchTool(WebSearchToolOptions{
|
|
||||||
BraveEnabled: true,
|
|
||||||
BraveAPIKey: "k",
|
|
||||||
BraveMaxResults: 3,
|
|
||||||
Proxy: "http://127.0.0.1:7890",
|
|
||||||
})
|
|
||||||
p, ok := tool.provider.(*BraveSearchProvider)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("provider type = %T, want *BraveSearchProvider", tool.provider)
|
|
||||||
}
|
|
||||||
if p.proxy != "http://127.0.0.1:7890" {
|
|
||||||
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("duckduckgo", func(t *testing.T) {
|
|
||||||
tool := NewWebSearchTool(WebSearchToolOptions{
|
|
||||||
DuckDuckGoEnabled: true,
|
|
||||||
DuckDuckGoMaxResults: 3,
|
|
||||||
Proxy: "http://127.0.0.1:7890",
|
|
||||||
})
|
|
||||||
p, ok := tool.provider.(*DuckDuckGoSearchProvider)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("provider type = %T, want *DuckDuckGoSearchProvider", tool.provider)
|
|
||||||
}
|
|
||||||
if p.proxy != "http://127.0.0.1:7890" {
|
|
||||||
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebTool_TavilySearch_Success verifies successful Tavily search
|
|
||||||
func TestWebTool_TavilySearch_Success(t *testing.T) {
|
|
||||||
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("Content-Type") != "application/json" {
|
|
||||||
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify payload
|
|
||||||
var payload map[string]any
|
|
||||||
json.NewDecoder(r.Body).Decode(&payload)
|
|
||||||
if payload["api_key"] != "test-key" {
|
|
||||||
t.Errorf("Expected api_key test-key, got %v", payload["api_key"])
|
|
||||||
}
|
|
||||||
if payload["query"] != "test query" {
|
|
||||||
t.Errorf("Expected query 'test query', got %v", payload["query"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return mock response
|
|
||||||
response := map[string]any{
|
|
||||||
"results": []map[string]any{
|
|
||||||
{
|
|
||||||
"title": "Test Result 1",
|
|
||||||
"url": "https://example.com/1",
|
|
||||||
"content": "Content for result 1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Test Result 2",
|
|
||||||
"url": "https://example.com/2",
|
|
||||||
"content": "Content for result 2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(response)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
tool := NewWebSearchTool(WebSearchToolOptions{
|
|
||||||
TavilyEnabled: true,
|
|
||||||
TavilyAPIKey: "test-key",
|
|
||||||
TavilyBaseURL: server.URL,
|
|
||||||
TavilyMaxResults: 5,
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]any{
|
|
||||||
"query": "test query",
|
|
||||||
}
|
|
||||||
|
|
||||||
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 result titles and URLs
|
|
||||||
if !strings.Contains(result.ForUser, "Test Result 1") ||
|
|
||||||
!strings.Contains(result.ForUser, "https://example.com/1") {
|
|
||||||
t.Errorf("Expected results in output, got: %s", result.ForUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should mention via Tavily
|
|
||||||
if !strings.Contains(result.ForUser, "via Tavily") {
|
|
||||||
t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package web_search
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -8,65 +8,12 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pre-compiled regexes for HTML text extraction
|
|
||||||
var (
|
|
||||||
reScript = regexp.MustCompile(`<script[\s\S]*?</script>`)
|
|
||||||
reStyle = regexp.MustCompile(`<style[\s\S]*?</style>`)
|
|
||||||
reTags = regexp.MustCompile(`<[^>]+>`)
|
|
||||||
reWhitespace = regexp.MustCompile(`[^\S\n]+`)
|
|
||||||
reBlankLines = regexp.MustCompile(`\n{3,}`)
|
|
||||||
|
|
||||||
// DuckDuckGo result extraction
|
|
||||||
reDDGLink = regexp.MustCompile(`<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`)
|
|
||||||
reDDGSnippet = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// createHTTPClient creates an HTTP client with optional proxy support
|
|
||||||
func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: timeout,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
MaxIdleConns: 10,
|
|
||||||
IdleConnTimeout: 30 * time.Second,
|
|
||||||
DisableCompression: false,
|
|
||||||
TLSHandshakeTimeout: 15 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if proxyURL != "" {
|
|
||||||
proxy, err := url.Parse(proxyURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid proxy URL: %w", err)
|
|
||||||
}
|
|
||||||
scheme := strings.ToLower(proxy.Scheme)
|
|
||||||
switch scheme {
|
|
||||||
case "http", "https", "socks5", "socks5h":
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"unsupported proxy scheme %q (supported: http, https, socks5, socks5h)",
|
|
||||||
proxy.Scheme,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if proxy.Host == "" {
|
|
||||||
return nil, fmt.Errorf("invalid proxy URL: missing host")
|
|
||||||
}
|
|
||||||
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
|
|
||||||
} else {
|
|
||||||
client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment
|
|
||||||
}
|
|
||||||
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type SearchProvider interface {
|
type SearchProvider interface {
|
||||||
Search(ctx context.Context, query string, count int) (string, error)
|
Search(ctx context.Context, query string, count int) (string, error)
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +35,7 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("X-Subscription-Token", p.apiKey)
|
req.Header.Set("X-Subscription-Token", p.apiKey)
|
||||||
|
|
||||||
client, err := createHTTPClient(p.proxy, 10*time.Second)
|
client, err := common.CreateHTTPClient(p.proxy, 10*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -172,9 +119,9 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", common.UserAgent)
|
||||||
|
|
||||||
client, err := createHTTPClient(p.proxy, 10*time.Second)
|
client, err := common.CreateHTTPClient(p.proxy, 10*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -237,9 +184,9 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", common.UserAgent)
|
||||||
|
|
||||||
client, err := createHTTPClient(p.proxy, 10*time.Second)
|
client, err := common.CreateHTTPClient(p.proxy, 10*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -264,7 +211,7 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
|
||||||
// Try finding the result links directly first, as they are the most critical
|
// Try finding the result links directly first, as they are the most critical
|
||||||
// Pattern: <a class="result__a" href="...">Title</a>
|
// 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
|
// The previous regex was a bit strict. Let's make it more flexible for attributes order/content
|
||||||
matches := reDDGLink.FindAllStringSubmatch(html, count+5)
|
matches := common.ReDDGLink.FindAllStringSubmatch(html, count+5)
|
||||||
|
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
return fmt.Sprintf("No results found or extraction failed. Query: %s", query), nil
|
return fmt.Sprintf("No results found or extraction failed. Query: %s", query), nil
|
||||||
|
|
@ -281,7 +228,7 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
|
||||||
|
|
||||||
// A better regex approach: iterate through text and find matches in order
|
// A better regex approach: iterate through text and find matches in order
|
||||||
// But for now, let's grab all snippets too
|
// But for now, let's grab all snippets too
|
||||||
snippetMatches := reDDGSnippet.FindAllStringSubmatch(html, count+5)
|
snippetMatches := common.ReDDGSnippet.FindAllStringSubmatch(html, count+5)
|
||||||
|
|
||||||
maxItems := min(len(matches), count)
|
maxItems := min(len(matches), count)
|
||||||
|
|
||||||
|
|
@ -316,7 +263,7 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripTags(content string) string {
|
func stripTags(content string) string {
|
||||||
return reTags.ReplaceAllString(content, "")
|
return common.ReTags.ReplaceAllString(content, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
type PerplexitySearchProvider struct {
|
type PerplexitySearchProvider struct {
|
||||||
|
|
@ -354,9 +301,9 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", common.UserAgent)
|
||||||
|
|
||||||
client, err := createHTTPClient(p.proxy, 30*time.Second)
|
client, err := common.CreateHTTPClient(p.proxy, 30*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -481,10 +428,10 @@ func (t *WebSearchTool) Parameters() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
query, ok := args["query"].(string)
|
query, ok := args["query"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrorResult("query is required")
|
return common.ErrorResult("query is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
count := t.maxResults
|
count := t.maxResults
|
||||||
|
|
@ -496,191 +443,11 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR
|
||||||
|
|
||||||
result, err := t.provider.Search(ctx, query, count)
|
result, err := t.provider.Search(ctx, query, count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("search failed: %v", err))
|
return common.ErrorResult(fmt.Sprintf("search failed: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ToolResult{
|
return &common.ToolResult{
|
||||||
ForLLM: result,
|
ForLLM: result,
|
||||||
ForUser: result,
|
ForUser: result,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebFetchTool struct {
|
|
||||||
maxChars int
|
|
||||||
proxy string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWebFetchTool(maxChars int) *WebFetchTool {
|
|
||||||
if maxChars <= 0 {
|
|
||||||
maxChars = 50000
|
|
||||||
}
|
|
||||||
return &WebFetchTool{
|
|
||||||
maxChars: maxChars,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWebFetchToolWithProxy(maxChars int, proxy string) *WebFetchTool {
|
|
||||||
if maxChars <= 0 {
|
|
||||||
maxChars = 50000
|
|
||||||
}
|
|
||||||
return &WebFetchTool{
|
|
||||||
maxChars: maxChars,
|
|
||||||
proxy: proxy,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *WebFetchTool) Name() string {
|
|
||||||
return "web_fetch"
|
|
||||||
}
|
|
||||||
|
|
||||||
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."
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *WebFetchTool) Parameters() map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{
|
|
||||||
"url": map[string]any{
|
|
||||||
"type": "string",
|
|
||||||
"description": "URL to fetch",
|
|
||||||
},
|
|
||||||
"maxChars": map[string]any{
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Maximum characters to extract",
|
|
||||||
"minimum": 100.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []string{"url"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
|
||||||
urlStr, ok := args["url"].(string)
|
|
||||||
if !ok {
|
|
||||||
return ErrorResult("url is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedURL, err := url.Parse(urlStr)
|
|
||||||
if err != nil {
|
|
||||||
return ErrorResult(fmt.Sprintf("invalid URL: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
|
||||||
return ErrorResult("only http/https URLs are allowed")
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsedURL.Host == "" {
|
|
||||||
return ErrorResult("missing domain in URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
maxChars := t.maxChars
|
|
||||||
if mc, ok := args["maxChars"].(float64); ok {
|
|
||||||
if int(mc) > 100 {
|
|
||||||
maxChars = int(mc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
|
|
||||||
if err != nil {
|
|
||||||
return ErrorResult(fmt.Sprintf("failed to create request: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
|
||||||
|
|
||||||
client, err := createHTTPClient(t.proxy, 60*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return ErrorResult(fmt.Sprintf("failed to create HTTP client: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure redirect handling
|
|
||||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
||||||
if len(via) >= 5 {
|
|
||||||
return fmt.Errorf("stopped after 5 redirects")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := resp.Header.Get("Content-Type")
|
|
||||||
|
|
||||||
var text, extractor string
|
|
||||||
|
|
||||||
if strings.Contains(contentType, "application/json") {
|
|
||||||
var jsonData any
|
|
||||||
if err := json.Unmarshal(body, &jsonData); err == nil {
|
|
||||||
formatted, _ := json.MarshalIndent(jsonData, "", " ")
|
|
||||||
text = string(formatted)
|
|
||||||
extractor = "json"
|
|
||||||
} else {
|
|
||||||
text = string(body)
|
|
||||||
extractor = "raw"
|
|
||||||
}
|
|
||||||
} else if strings.Contains(contentType, "text/html") || len(body) > 0 &&
|
|
||||||
(strings.HasPrefix(string(body), "<!DOCTYPE") || strings.HasPrefix(strings.ToLower(string(body)), "<html")) {
|
|
||||||
text = t.extractText(string(body))
|
|
||||||
extractor = "text"
|
|
||||||
} else {
|
|
||||||
text = string(body)
|
|
||||||
extractor = "raw"
|
|
||||||
}
|
|
||||||
|
|
||||||
truncated := len(text) > maxChars
|
|
||||||
if truncated {
|
|
||||||
text = text[:maxChars]
|
|
||||||
}
|
|
||||||
|
|
||||||
result := map[string]any{
|
|
||||||
"url": urlStr,
|
|
||||||
"status": resp.StatusCode,
|
|
||||||
"extractor": extractor,
|
|
||||||
"truncated": truncated,
|
|
||||||
"length": len(text),
|
|
||||||
"text": text,
|
|
||||||
}
|
|
||||||
|
|
||||||
resultJSON, _ := json.MarshalIndent(result, "", " ")
|
|
||||||
|
|
||||||
return &ToolResult{
|
|
||||||
ForLLM: fmt.Sprintf(
|
|
||||||
"Fetched %d bytes from %s (extractor: %s, truncated: %v)",
|
|
||||||
len(text),
|
|
||||||
urlStr,
|
|
||||||
extractor,
|
|
||||||
truncated,
|
|
||||||
),
|
|
||||||
ForUser: string(resultJSON),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *WebFetchTool) extractText(htmlContent string) string {
|
|
||||||
result := reScript.ReplaceAllLiteralString(htmlContent, "")
|
|
||||||
result = reStyle.ReplaceAllLiteralString(result, "")
|
|
||||||
result = reTags.ReplaceAllLiteralString(result, "")
|
|
||||||
|
|
||||||
result = strings.TrimSpace(result)
|
|
||||||
|
|
||||||
result = reWhitespace.ReplaceAllString(result, " ")
|
|
||||||
result = reBlankLines.ReplaceAllString(result, "\n\n")
|
|
||||||
|
|
||||||
lines := strings.Split(result, "\n")
|
|
||||||
var cleanLines []string
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line != "" {
|
|
||||||
cleanLines = append(cleanLines, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(cleanLines, "\n")
|
|
||||||
}
|
|
||||||
159
pkg/tools/web_search/web_search_test.go
Normal file
159
pkg/tools/web_search/web_search_test.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
package web_search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing
|
||||||
|
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
|
||||||
|
tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""})
|
||||||
|
if tool != nil {
|
||||||
|
t.Errorf("Expected nil tool when Brave API key is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also nil when nothing is enabled
|
||||||
|
tool = NewWebSearchTool(WebSearchToolOptions{})
|
||||||
|
if tool != nil {
|
||||||
|
t.Errorf("Expected nil tool when no provider is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query
|
||||||
|
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
|
||||||
|
tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5})
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Should return error result
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error when query is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
||||||
|
t.Run("perplexity", func(t *testing.T) {
|
||||||
|
tool := NewWebSearchTool(WebSearchToolOptions{
|
||||||
|
PerplexityEnabled: true,
|
||||||
|
PerplexityAPIKey: "k",
|
||||||
|
PerplexityMaxResults: 3,
|
||||||
|
Proxy: "http://127.0.0.1:7890",
|
||||||
|
})
|
||||||
|
p, ok := tool.provider.(*PerplexitySearchProvider)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("provider type = %T, want *PerplexitySearchProvider", tool.provider)
|
||||||
|
}
|
||||||
|
if p.proxy != "http://127.0.0.1:7890" {
|
||||||
|
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("brave", func(t *testing.T) {
|
||||||
|
tool := NewWebSearchTool(WebSearchToolOptions{
|
||||||
|
BraveEnabled: true,
|
||||||
|
BraveAPIKey: "k",
|
||||||
|
BraveMaxResults: 3,
|
||||||
|
Proxy: "http://127.0.0.1:7890",
|
||||||
|
})
|
||||||
|
p, ok := tool.provider.(*BraveSearchProvider)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("provider type = %T, want *BraveSearchProvider", tool.provider)
|
||||||
|
}
|
||||||
|
if p.proxy != "http://127.0.0.1:7890" {
|
||||||
|
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("duckduckgo", func(t *testing.T) {
|
||||||
|
tool := NewWebSearchTool(WebSearchToolOptions{
|
||||||
|
DuckDuckGoEnabled: true,
|
||||||
|
DuckDuckGoMaxResults: 3,
|
||||||
|
Proxy: "http://127.0.0.1:7890",
|
||||||
|
})
|
||||||
|
p, ok := tool.provider.(*DuckDuckGoSearchProvider)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("provider type = %T, want *DuckDuckGoSearchProvider", tool.provider)
|
||||||
|
}
|
||||||
|
if p.proxy != "http://127.0.0.1:7890" {
|
||||||
|
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebTool_TavilySearch_Success verifies successful Tavily search
|
||||||
|
func TestWebTool_TavilySearch_Success(t *testing.T) {
|
||||||
|
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("Content-Type") != "application/json" {
|
||||||
|
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify payload
|
||||||
|
var payload map[string]any
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["api_key"] != "test-key" {
|
||||||
|
t.Errorf("Expected api_key test-key, got %v", payload["api_key"])
|
||||||
|
}
|
||||||
|
if payload["query"] != "test query" {
|
||||||
|
t.Errorf("Expected query 'test query', got %v", payload["query"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return mock response
|
||||||
|
response := map[string]any{
|
||||||
|
"results": []map[string]any{
|
||||||
|
{
|
||||||
|
"title": "Test Result 1",
|
||||||
|
"url": "https://example.com/1",
|
||||||
|
"content": "Content for result 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Test Result 2",
|
||||||
|
"url": "https://example.com/2",
|
||||||
|
"content": "Content for result 2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tool := NewWebSearchTool(WebSearchToolOptions{
|
||||||
|
TavilyEnabled: true,
|
||||||
|
TavilyAPIKey: "test-key",
|
||||||
|
TavilyBaseURL: server.URL,
|
||||||
|
TavilyMaxResults: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"query": "test query",
|
||||||
|
}
|
||||||
|
|
||||||
|
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 result titles and URLs
|
||||||
|
if !strings.Contains(result.ForUser, "Test Result 1") ||
|
||||||
|
!strings.Contains(result.ForUser, "https://example.com/1") {
|
||||||
|
t.Errorf("Expected results in output, got: %s", result.ForUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should mention via Tavily
|
||||||
|
if !strings.Contains(result.ForUser, "via Tavily") {
|
||||||
|
t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
pkg/tools/write_file/write_file.go
Normal file
65
pkg/tools/write_file/write_file.go
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
package write_file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WriteFileTool struct {
|
||||||
|
fs common.FileSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool {
|
||||||
|
var fs common.FileSystem
|
||||||
|
if restrict {
|
||||||
|
fs = &common.SandboxFs{Workspace: workspace}
|
||||||
|
} else {
|
||||||
|
fs = &common.HostFs{}
|
||||||
|
}
|
||||||
|
return &WriteFileTool{fs: fs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *WriteFileTool) Name() string {
|
||||||
|
return "write_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *WriteFileTool) Description() string {
|
||||||
|
return "Write content to a file"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *WriteFileTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"path": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the file to write",
|
||||||
|
},
|
||||||
|
"content": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Content to write to the file",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"path", "content"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *common.ToolResult {
|
||||||
|
path, ok := args["path"].(string)
|
||||||
|
if !ok {
|
||||||
|
return common.ErrorResult("path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
content, ok := args["content"].(string)
|
||||||
|
if !ok {
|
||||||
|
return common.ErrorResult("content is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.fs.WriteFile(path, []byte(content)); err != nil {
|
||||||
|
return common.ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return common.SilentResult(fmt.Sprintf("File written: %s", path))
|
||||||
|
}
|
||||||
491
pkg/tools/write_file/write_file_test.go
Normal file
491
pkg/tools/write_file/write_file_test.go
Normal file
|
|
@ -0,0 +1,491 @@
|
||||||
|
package write_file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/list_dir"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/read_file"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools/common"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFilesystemTool_ReadFile_Success verifies successful file reading
|
||||||
|
func TestFilesystemTool_ReadFile_Success(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
|
os.WriteFile(testFile, []byte("test content"), 0o644)
|
||||||
|
|
||||||
|
tool := read_file.NewReadFileTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Success should not be an error
|
||||||
|
if result.IsError {
|
||||||
|
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForLLM should contain file content
|
||||||
|
if !strings.Contains(result.ForLLM, "test content") {
|
||||||
|
t.Errorf("Expected ForLLM to contain 'test content', got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile returns NewToolResult which only sets ForLLM, not ForUser
|
||||||
|
// This is the expected behavior - file content goes to LLM, not directly to user
|
||||||
|
if result.ForUser != "" {
|
||||||
|
t.Errorf("Expected ForUser to be empty for NewToolResult, got: %s", result.ForUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
|
||||||
|
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
||||||
|
tool := read_file.NewReadFileTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": "/nonexistent_file_12345.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Failure should be marked as error
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error for missing file, got IsError=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should contain error message
|
||||||
|
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
|
||||||
|
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path
|
||||||
|
func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) {
|
||||||
|
tool := &read_file.ReadFileTool{}
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Should return error result
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error when path is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should mention required parameter
|
||||||
|
if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") {
|
||||||
|
t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilesystemTool_WriteFile_Success verifies successful file writing
|
||||||
|
func TestFilesystemTool_WriteFile_Success(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "newfile.txt")
|
||||||
|
|
||||||
|
tool := NewWriteFileTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"content": "hello world",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Success should not be an error
|
||||||
|
if result.IsError {
|
||||||
|
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile returns SilentResult
|
||||||
|
if !result.Silent {
|
||||||
|
t.Errorf("Expected Silent=true for WriteFile, got false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForUser should be empty (silent result)
|
||||||
|
if result.ForUser != "" {
|
||||||
|
t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file was actually written
|
||||||
|
content, err := os.ReadFile(testFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read written file: %v", err)
|
||||||
|
}
|
||||||
|
if string(content) != "hello world" {
|
||||||
|
t.Errorf("Expected file content 'hello world', got: %s", string(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilesystemTool_WriteFile_CreateDir verifies directory creation
|
||||||
|
func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "subdir", "newfile.txt")
|
||||||
|
|
||||||
|
tool := NewWriteFileTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"content": "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Success should not be an error
|
||||||
|
if result.IsError {
|
||||||
|
t.Errorf("Expected success with directory creation, got IsError=true: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify directory was created and file written
|
||||||
|
content, err := os.ReadFile(testFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read written file: %v", err)
|
||||||
|
}
|
||||||
|
if string(content) != "test" {
|
||||||
|
t.Errorf("Expected file content 'test', got: %s", string(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path
|
||||||
|
func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
|
||||||
|
tool := NewWriteFileTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"content": "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Should return error result
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error when path is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content
|
||||||
|
func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
|
||||||
|
tool := NewWriteFileTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": "/tmp/test.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Should return error result
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error when content is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should mention required parameter
|
||||||
|
if !strings.Contains(result.ForLLM, "content is required") &&
|
||||||
|
!strings.Contains(result.ForUser, "content is required") {
|
||||||
|
t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilesystemTool_ListDir_Success verifies successful directory listing
|
||||||
|
func TestFilesystemTool_ListDir_Success(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644)
|
||||||
|
os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755)
|
||||||
|
|
||||||
|
tool := list_dir.NewListDirTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": tmpDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Success should not be an error
|
||||||
|
if result.IsError {
|
||||||
|
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should list files and directories
|
||||||
|
if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") {
|
||||||
|
t.Errorf("Expected files in listing, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "subdir") {
|
||||||
|
t.Errorf("Expected subdir in listing, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory
|
||||||
|
func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
|
||||||
|
tool := list_dir.NewListDirTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"path": "/nonexistent_directory_12345",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Failure should be marked as error
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error for non-existent directory, got IsError=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should contain error message
|
||||||
|
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
|
||||||
|
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory
|
||||||
|
func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
|
||||||
|
tool := list_dir.NewListDirTool("", false)
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
// Should use "." as default path
|
||||||
|
if result.IsError {
|
||||||
|
t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block paths that look inside workspace but point outside via symlink.
|
||||||
|
func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
workspace := filepath.Join(root, "workspace")
|
||||||
|
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create workspace: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := filepath.Join(root, "secret.txt")
|
||||||
|
if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write secret file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
link := filepath.Join(workspace, "leak.txt")
|
||||||
|
if err := os.Symlink(secret, link); err != nil {
|
||||||
|
t.Skipf("symlink not supported in this environment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tool := read_file.NewReadFileTool(workspace, true)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": link,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatalf("expected symlink escape to be blocked")
|
||||||
|
}
|
||||||
|
// os.Root might return different errors depending on platform/implementation
|
||||||
|
// but it definitely should error.
|
||||||
|
// Our wrapper returns "access denied or file not found"
|
||||||
|
if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") &&
|
||||||
|
!strings.Contains(result.ForLLM, "no such file") {
|
||||||
|
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
|
||||||
|
tool := read_file.NewReadFileTool("", true) // restrict=true but workspace=""
|
||||||
|
|
||||||
|
// Try to read a sensitive file (simulated by a temp file outside workspace)
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
secretFile := filepath.Join(tmpDir, "shadow")
|
||||||
|
os.WriteFile(secretFile, []byte("secret data"), 0o600)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": secretFile,
|
||||||
|
})
|
||||||
|
|
||||||
|
// We EXPECT IsError=true (access blocked due to empty workspace)
|
||||||
|
assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
|
||||||
|
|
||||||
|
// Verify it failed for the right reason
|
||||||
|
assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
|
||||||
|
// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path.
|
||||||
|
func TestRootMkdirAll(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
root, err := os.OpenRoot(workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open root: %v", err)
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
|
||||||
|
// Case 1: Single directory
|
||||||
|
err = root.MkdirAll("dir1", 0o755)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = os.Stat(filepath.Join(workspace, "dir1"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Case 2: Deeply nested directory
|
||||||
|
err = root.MkdirAll("a/b/c/d", 0o755)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = os.Stat(filepath.Join(workspace, "a/b/c/d"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Case 3: Already exists — must be idempotent
|
||||||
|
err = root.MkdirAll("a/b/c/d", 0o755)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Case 4: A regular file blocks directory creation — must error
|
||||||
|
err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = root.MkdirAll("file_exists", 0o755)
|
||||||
|
assert.Error(t, err, "expected error when a file exists at the directory path")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
tool := NewWriteFileTool(workspace, true)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
testFile := "deep/nested/path/to/file.txt"
|
||||||
|
content := "deep content"
|
||||||
|
args := map[string]any{
|
||||||
|
"path": testFile,
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
|
||||||
|
|
||||||
|
// Verify file content
|
||||||
|
actualPath := filepath.Join(workspace, testFile)
|
||||||
|
data, err := os.ReadFile(actualPath)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, content, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors.
|
||||||
|
func TestHostRW_Read_PermissionDenied(t *testing.T) {
|
||||||
|
if os.Getuid() == 0 {
|
||||||
|
t.Skip("skipping permission test: running as root")
|
||||||
|
}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
protected := filepath.Join(tmpDir, "protected.txt")
|
||||||
|
err := os.WriteFile(protected, []byte("secret"), 0o000)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.Chmod(protected, 0o644) // ensure cleanup
|
||||||
|
|
||||||
|
_, err = (&common.HostFs{}).ReadFile(protected)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "access denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path.
|
||||||
|
func TestHostRW_Read_Directory(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
_, err := (&common.HostFs{}).ReadFile(tmpDir)
|
||||||
|
assert.Error(t, err, "expected error when reading a directory as a file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory.
|
||||||
|
func TestRootRW_Read_Directory(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
root, err := os.OpenRoot(workspace)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer root.Close()
|
||||||
|
|
||||||
|
// Create a subdirectory
|
||||||
|
err = root.Mkdir("subdir", 0o755)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = (&common.SandboxFs{Workspace: workspace}).ReadFile("subdir")
|
||||||
|
assert.Error(t, err, "expected error when reading a directory as a file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically.
|
||||||
|
func TestHostRW_Write_ParentDirMissing(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
target := filepath.Join(tmpDir, "a", "b", "c", "file.txt")
|
||||||
|
|
||||||
|
err := (&common.HostFs{}).WriteFile(target, []byte("hello"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(target)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates
|
||||||
|
// nested parent directories automatically within the sandbox.
|
||||||
|
func TestRootRW_Write_ParentDirMissing(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
|
||||||
|
relPath := "x/y/z/file.txt"
|
||||||
|
err := (&common.SandboxFs{Workspace: workspace}).WriteFile(relPath, []byte("nested"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(workspace, relPath))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "nested", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostRW_Write verifies the hostRW.Write helper function
|
||||||
|
func TestHostRW_Write(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "atomic_test.txt")
|
||||||
|
testData := []byte("atomic test content")
|
||||||
|
|
||||||
|
err := (&common.HostFs{}).WriteFile(testFile, testData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
content, err := os.ReadFile(testFile)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, content)
|
||||||
|
|
||||||
|
// Verify it overwrites correctly
|
||||||
|
newData := []byte("new atomic content")
|
||||||
|
err = (&common.HostFs{}).WriteFile(testFile, newData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
content, err = os.ReadFile(testFile)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, newData, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRootRW_Write verifies the rootRW.Write helper function
|
||||||
|
func TestRootRW_Write(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
relPath := "atomic_root_test.txt"
|
||||||
|
testData := []byte("atomic root test content")
|
||||||
|
|
||||||
|
erw := &common.SandboxFs{Workspace: tmpDir}
|
||||||
|
err := erw.WriteFile(relPath, testData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
root, err := os.OpenRoot(tmpDir)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer root.Close()
|
||||||
|
|
||||||
|
f, err := root.Open(relPath)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
content, err := io.ReadAll(f)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, content)
|
||||||
|
|
||||||
|
// Verify it overwrites correctly
|
||||||
|
newData := []byte("new root atomic content")
|
||||||
|
err = erw.WriteFile(relPath, newData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
f2, err := root.Open(relPath)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer f2.Close()
|
||||||
|
|
||||||
|
content, err = io.ReadAll(f2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, newData, content)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue