feat: add ActionBook browser automation tools

Integrate ActionBook CLI as three native browser automation tools:
- browser_search: find action manuals for websites
- browser_get: retrieve CSS selectors and page structure by ID
- browser: execute browser commands (open, click, fill, text, etc.)

Zero new dependencies — thin CLI wrapper using os/exec.
Config-gated via tools.browser.enabled (default: true).
18 unit tests + 1 integration test, all passing.

Closes: browser automation support for agent loop
This commit is contained in:
Danieldd28 2026-02-16 19:42:45 +07:00
parent 13e4028d42
commit 62d2c5eeed
4 changed files with 712 additions and 1 deletions

View file

@ -84,6 +84,13 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
}
registry.Register(tools.NewWebFetchTool(50000))
// Browser automation tools (ActionBook)
if cfg.Tools.Browser.Enabled {
registry.Register(tools.NewBrowserSearchTool(cfg.Tools.Browser.Headless))
registry.Register(tools.NewBrowserGetTool())
registry.Register(tools.NewBrowserTool(cfg.Tools.Browser.Headless))
}
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
registry.Register(tools.NewI2CTool())
registry.Register(tools.NewSPITool())

View file

@ -211,8 +211,14 @@ type WebToolsConfig struct {
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
}
type BrowserConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_BROWSER_ENABLED"`
Headless bool `json:"headless" env:"PICOCLAW_TOOLS_BROWSER_HEADLESS"`
}
type ToolsConfig struct {
Web WebToolsConfig `json:"web"`
Browser BrowserConfig `json:"browser"`
}
func DefaultConfig() *Config {
@ -322,6 +328,10 @@ func DefaultConfig() *Config {
MaxResults: 5,
},
},
Browser: BrowserConfig{
Enabled: true,
Headless: true,
},
},
Heartbeat: HeartbeatConfig{
Enabled: true,

411
pkg/tools/browser.go Normal file
View file

@ -0,0 +1,411 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package tools
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"time"
)
// actionbookBinary caches the resolved path to the actionbook CLI.
var actionbookBinary string
func resolveActionbook() (string, error) {
if actionbookBinary != "" {
return actionbookBinary, nil
}
path, err := exec.LookPath("actionbook")
if err != nil {
return "", fmt.Errorf("actionbook CLI not found in PATH: %w", err)
}
actionbookBinary = path
return path, nil
}
// runActionbook executes an actionbook CLI command with a timeout.
func runActionbook(ctx context.Context, timeout time.Duration, args ...string) (string, error) {
bin, err := resolveActionbook()
if err != nil {
return "", err
}
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, bin, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if cmdCtx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("actionbook command timed out after %v", timeout)
}
// Many actionbook commands write useful output even on non-zero exit.
// Return stdout+stderr as output with the error.
out := stdout.String()
if stderr.Len() > 0 {
out += "\nSTDERR: " + stderr.String()
}
if out != "" {
return out, nil // treat as non-fatal; LLM can interpret
}
return "", fmt.Errorf("actionbook failed: %w — %s", err, stderr.String())
}
return stdout.String(), nil
}
// truncateOutput limits output length to avoid blowing up context.
func truncateOutput(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + fmt.Sprintf("\n... (truncated, %d more chars)", len(s)-max)
}
// ---------------------------------------------------------------------------
// BrowserSearchTool — actionbook search
// ---------------------------------------------------------------------------
// BrowserSearchTool wraps `actionbook search` to find action manuals.
type BrowserSearchTool struct {
headless bool
}
func NewBrowserSearchTool(headless bool) *BrowserSearchTool {
return &BrowserSearchTool{headless: headless}
}
func (t *BrowserSearchTool) Name() string { return "browser_search" }
func (t *BrowserSearchTool) Description() string {
return "Search ActionBook for browser action manuals matching a task. Returns action IDs, descriptions, URLs, and health scores. Use the returned ID with browser_get to retrieve selectors."
}
func (t *BrowserSearchTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{
"type": "string",
"description": "Search query describing the task (e.g. 'airbnb search listings Tokyo')",
},
"domain": map[string]interface{}{
"type": "string",
"description": "Optional domain to filter results (e.g. 'airbnb.com')",
},
},
"required": []string{"query"},
}
}
func (t *BrowserSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
query, ok := args["query"].(string)
if !ok || query == "" {
return ErrorResult("query is required")
}
cmdArgs := []string{"search", query, "--json"}
if domain, ok := args["domain"].(string); ok && domain != "" {
cmdArgs = append(cmdArgs, "--domain", domain)
}
output, err := runActionbook(ctx, 30*time.Second, cmdArgs...)
if err != nil {
return ErrorResult(fmt.Sprintf("browser_search failed: %v", err))
}
output = truncateOutput(output, 10000)
return &ToolResult{
ForLLM: output,
ForUser: output,
}
}
// ---------------------------------------------------------------------------
// BrowserGetTool — actionbook get
// ---------------------------------------------------------------------------
// BrowserGetTool wraps `actionbook get` to retrieve action details.
type BrowserGetTool struct{}
func NewBrowserGetTool() *BrowserGetTool { return &BrowserGetTool{} }
func (t *BrowserGetTool) Name() string { return "browser_get" }
func (t *BrowserGetTool) Description() string {
return "Retrieve a specific ActionBook action manual by its ID. Returns page structure with CSS selectors, element types, and allowed methods. Use the ID from browser_search results."
}
func (t *BrowserGetTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action_id": map[string]interface{}{
"type": "string",
"description": "Action manual ID from browser_search (e.g. 'airbnb.com:/:default')",
},
},
"required": []string{"action_id"},
}
}
func (t *BrowserGetTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
actionID, ok := args["action_id"].(string)
if !ok || actionID == "" {
return ErrorResult("action_id is required")
}
output, err := runActionbook(ctx, 30*time.Second, "get", actionID, "--json")
if err != nil {
return ErrorResult(fmt.Sprintf("browser_get failed: %v", err))
}
output = truncateOutput(output, 15000)
return &ToolResult{
ForLLM: output,
ForUser: output,
}
}
// ---------------------------------------------------------------------------
// BrowserTool — actionbook browser *
// ---------------------------------------------------------------------------
// allowedBrowserActions is the set of browser subcommands the LLM may invoke.
var allowedBrowserActions = map[string]bool{
"open": true,
"goto": true,
"click": true,
"fill": true,
"type": true,
"select": true,
"hover": true,
"focus": true,
"press": true,
"text": true,
"snapshot": true,
"screenshot": true,
"wait": true,
"wait-nav": true,
"back": true,
"forward": true,
"reload": true,
"close": true,
"pages": true,
"switch": true,
"eval": true,
"html": true,
"pdf": true,
"cookies": true,
"status": true,
"viewport": true,
}
// BrowserTool wraps `actionbook browser` for browser automation.
type BrowserTool struct {
headless bool
}
func NewBrowserTool(headless bool) *BrowserTool {
return &BrowserTool{headless: headless}
}
func (t *BrowserTool) Name() string { return "browser" }
func (t *BrowserTool) Description() string {
return `Execute browser automation commands via ActionBook. Supported actions: open, goto, click, fill, type, select, hover, focus, press, text, snapshot, screenshot, wait, wait-nav, back, forward, reload, close, pages, switch, eval, html, pdf, cookies, status, viewport. Typical workflow: browser_search → browser_get → browser (open → interact → close).`
}
func (t *BrowserTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"description": "Browser action to perform (e.g. 'open', 'click', 'fill', 'text', 'snapshot', 'close')",
},
"url": map[string]interface{}{
"type": "string",
"description": "URL for open/goto actions",
},
"selector": map[string]interface{}{
"type": "string",
"description": "CSS selector for click/fill/type/select/hover/focus/wait/text/html actions",
},
"value": map[string]interface{}{
"type": "string",
"description": "Value for fill/type/select/press/eval/switch actions",
},
"timeout": map[string]interface{}{
"type": "integer",
"description": "Timeout in milliseconds for wait/wait-nav actions (default: 30000)",
},
"full_page": map[string]interface{}{
"type": "boolean",
"description": "For screenshot: capture full page instead of viewport",
},
},
"required": []string{"action"},
}
}
func (t *BrowserTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
action, ok := args["action"].(string)
if !ok || action == "" {
return ErrorResult("action is required")
}
action = strings.TrimSpace(strings.ToLower(action))
if !allowedBrowserActions[action] {
return ErrorResult(fmt.Sprintf("unknown browser action %q — allowed: open, goto, click, fill, type, select, hover, focus, press, text, snapshot, screenshot, wait, wait-nav, back, forward, reload, close, pages, switch, eval, html, pdf, cookies, status, viewport", action))
}
// Build argument list
cmdArgs := []string{}
// Global flags
if t.headless {
cmdArgs = append(cmdArgs, "--headless")
}
cmdArgs = append(cmdArgs, "browser", action)
switch action {
case "open", "goto":
if u, ok := args["url"].(string); ok && u != "" {
cmdArgs = append(cmdArgs, u)
} else {
return ErrorResult(fmt.Sprintf("url is required for %s action", action))
}
if timeout, ok := getIntArg(args, "timeout"); ok {
cmdArgs = append(cmdArgs, "--timeout", fmt.Sprintf("%d", timeout))
}
case "click", "hover", "focus":
sel, ok := args["selector"].(string)
if !ok || sel == "" {
return ErrorResult(fmt.Sprintf("selector is required for %s action", action))
}
cmdArgs = append(cmdArgs, sel)
if timeout, ok := getIntArg(args, "timeout"); ok {
cmdArgs = append(cmdArgs, "--wait", fmt.Sprintf("%d", timeout))
}
case "fill", "type":
sel, ok := args["selector"].(string)
if !ok || sel == "" {
return ErrorResult(fmt.Sprintf("selector is required for %s action", action))
}
val, ok := args["value"].(string)
if !ok {
return ErrorResult(fmt.Sprintf("value is required for %s action", action))
}
cmdArgs = append(cmdArgs, sel, val)
if timeout, ok := getIntArg(args, "timeout"); ok {
cmdArgs = append(cmdArgs, "--wait", fmt.Sprintf("%d", timeout))
}
case "select":
sel, ok := args["selector"].(string)
if !ok || sel == "" {
return ErrorResult("selector is required for select action")
}
val, ok := args["value"].(string)
if !ok || val == "" {
return ErrorResult("value is required for select action")
}
cmdArgs = append(cmdArgs, sel, val)
case "press":
key, ok := args["value"].(string)
if !ok || key == "" {
return ErrorResult("value is required for press action (e.g. 'Enter', 'Tab')")
}
cmdArgs = append(cmdArgs, key)
case "wait":
sel, ok := args["selector"].(string)
if !ok || sel == "" {
return ErrorResult("selector is required for wait action")
}
cmdArgs = append(cmdArgs, sel)
if timeout, ok := getIntArg(args, "timeout"); ok {
cmdArgs = append(cmdArgs, "--timeout", fmt.Sprintf("%d", timeout))
}
case "wait-nav":
if timeout, ok := getIntArg(args, "timeout"); ok {
cmdArgs = append(cmdArgs, "--timeout", fmt.Sprintf("%d", timeout))
}
case "switch":
pageID, ok := args["value"].(string)
if !ok || pageID == "" {
return ErrorResult("value (page_id) is required for switch action")
}
cmdArgs = append(cmdArgs, pageID)
case "eval":
script, ok := args["value"].(string)
if !ok || script == "" {
return ErrorResult("value (javascript expression) is required for eval action")
}
cmdArgs = append(cmdArgs, script)
case "text", "html":
if sel, ok := args["selector"].(string); ok && sel != "" {
cmdArgs = append(cmdArgs, sel)
}
case "screenshot":
if fullPage, ok := args["full_page"].(bool); ok && fullPage {
cmdArgs = append(cmdArgs, "--full-page")
}
case "cookies":
// cookies sub-actions via value: list, get <name>, set <name> <val>, delete <name>, clear
if val, ok := args["value"].(string); ok && val != "" {
cmdArgs = append(cmdArgs, strings.Fields(val)...)
}
// For actions with no extra args: back, forward, reload, close, pages, snapshot, status, viewport, pdf
// — nothing extra needed, cmdArgs already has "browser" and action.
}
// Determine timeout
cmdTimeout := 30 * time.Second
if action == "screenshot" || action == "pdf" {
cmdTimeout = 60 * time.Second
}
output, err := runActionbook(ctx, cmdTimeout, cmdArgs...)
if err != nil {
return ErrorResult(fmt.Sprintf("browser %s failed: %v", action, err))
}
if output == "" {
output = "(no output)"
}
output = truncateOutput(output, 15000)
return &ToolResult{
ForLLM: output,
ForUser: output,
}
}
// getIntArg extracts an integer argument from the args map.
// JSON numbers arrive as float64.
func getIntArg(args map[string]interface{}, key string) (int, bool) {
if v, ok := args[key].(float64); ok {
return int(v), true
}
return 0, false
}

283
pkg/tools/browser_test.go Normal file
View file

@ -0,0 +1,283 @@
package tools
import (
"context"
"os/exec"
"strings"
"testing"
)
// TestBrowserSearchTool_Name verifies tool name
func TestBrowserSearchTool_Name(t *testing.T) {
tool := NewBrowserSearchTool(true)
if tool.Name() != "browser_search" {
t.Errorf("expected name 'browser_search', got '%s'", tool.Name())
}
}
// TestBrowserSearchTool_Description verifies description is non-empty
func TestBrowserSearchTool_Description(t *testing.T) {
tool := NewBrowserSearchTool(true)
if tool.Description() == "" {
t.Error("expected non-empty description")
}
}
// TestBrowserSearchTool_Parameters verifies parameter schema
func TestBrowserSearchTool_Parameters(t *testing.T) {
tool := NewBrowserSearchTool(true)
params := tool.Parameters()
props, ok := params["properties"].(map[string]interface{})
if !ok {
t.Fatal("expected properties map")
}
if _, ok := props["query"]; !ok {
t.Error("expected 'query' parameter")
}
if _, ok := props["domain"]; !ok {
t.Error("expected 'domain' parameter")
}
required, ok := params["required"].([]string)
if !ok || len(required) != 1 || required[0] != "query" {
t.Error("expected 'query' in required list")
}
}
// TestBrowserSearchTool_MissingQuery verifies error when query is missing
func TestBrowserSearchTool_MissingQuery(t *testing.T) {
tool := NewBrowserSearchTool(true)
result := tool.Execute(context.Background(), map[string]interface{}{})
if !result.IsError {
t.Error("expected error when query is missing")
}
if !strings.Contains(result.ForLLM, "query is required") {
t.Errorf("expected 'query is required' in error, got: %s", result.ForLLM)
}
}
// TestBrowserGetTool_Name verifies tool name
func TestBrowserGetTool_Name(t *testing.T) {
tool := NewBrowserGetTool()
if tool.Name() != "browser_get" {
t.Errorf("expected name 'browser_get', got '%s'", tool.Name())
}
}
// TestBrowserGetTool_MissingID verifies error when action_id is missing
func TestBrowserGetTool_MissingID(t *testing.T) {
tool := NewBrowserGetTool()
result := tool.Execute(context.Background(), map[string]interface{}{})
if !result.IsError {
t.Error("expected error when action_id is missing")
}
if !strings.Contains(result.ForLLM, "action_id is required") {
t.Errorf("expected 'action_id is required' in error, got: %s", result.ForLLM)
}
}
// TestBrowserTool_Name verifies tool name
func TestBrowserTool_Name(t *testing.T) {
tool := NewBrowserTool(true)
if tool.Name() != "browser" {
t.Errorf("expected name 'browser', got '%s'", tool.Name())
}
}
// TestBrowserTool_MissingAction verifies error when action is missing
func TestBrowserTool_MissingAction(t *testing.T) {
tool := NewBrowserTool(true)
result := tool.Execute(context.Background(), map[string]interface{}{})
if !result.IsError {
t.Error("expected error when action is missing")
}
if !strings.Contains(result.ForLLM, "action is required") {
t.Errorf("expected 'action is required' in error, got: %s", result.ForLLM)
}
}
// TestBrowserTool_InvalidAction verifies rejection of unknown actions
func TestBrowserTool_InvalidAction(t *testing.T) {
tool := NewBrowserTool(true)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "malicious_command",
})
if !result.IsError {
t.Error("expected error for invalid action")
}
if !strings.Contains(result.ForLLM, "unknown browser action") {
t.Errorf("expected 'unknown browser action' in error, got: %s", result.ForLLM)
}
}
// TestBrowserTool_ActionAllowlist verifies all valid actions pass validation
func TestBrowserTool_ActionAllowlist(t *testing.T) {
// Only test the allowlist check, not actual execution
for action := range allowedBrowserActions {
if !allowedBrowserActions[action] {
t.Errorf("action %q should be in allowlist", action)
}
}
invalidActions := []string{"rm", "exec", "sudo", "rm -rf", "shell", ""}
for _, action := range invalidActions {
if allowedBrowserActions[action] {
t.Errorf("action %q should NOT be in allowlist", action)
}
}
}
// TestBrowserTool_OpenRequiresURL verifies open action needs url parameter
func TestBrowserTool_OpenRequiresURL(t *testing.T) {
tool := NewBrowserTool(true)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "open",
})
if !result.IsError {
t.Error("expected error when url is missing for open")
}
if !strings.Contains(result.ForLLM, "url is required") {
t.Errorf("expected 'url is required' in error, got: %s", result.ForLLM)
}
}
// TestBrowserTool_ClickRequiresSelector verifies click action needs selector
func TestBrowserTool_ClickRequiresSelector(t *testing.T) {
tool := NewBrowserTool(true)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "click",
})
if !result.IsError {
t.Error("expected error when selector is missing for click")
}
if !strings.Contains(result.ForLLM, "selector is required") {
t.Errorf("expected 'selector is required' in error, got: %s", result.ForLLM)
}
}
// TestBrowserTool_FillRequiresSelectorAndValue verifies fill needs both params
func TestBrowserTool_FillRequiresSelectorAndValue(t *testing.T) {
tool := NewBrowserTool(true)
// Missing selector
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "fill",
"value": "text",
})
if !result.IsError {
t.Error("expected error when selector is missing for fill")
}
// Missing value
result = tool.Execute(context.Background(), map[string]interface{}{
"action": "fill",
"selector": "#input",
})
if !result.IsError {
t.Error("expected error when value is missing for fill")
}
}
// TestBrowserTool_PressRequiresValue verifies press needs key value
func TestBrowserTool_PressRequiresValue(t *testing.T) {
tool := NewBrowserTool(true)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "press",
})
if !result.IsError {
t.Error("expected error when value is missing for press")
}
}
// TestBrowserTool_EvalRequiresValue verifies eval needs script value
func TestBrowserTool_EvalRequiresValue(t *testing.T) {
tool := NewBrowserTool(true)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "eval",
})
if !result.IsError {
t.Error("expected error when value is missing for eval")
}
}
// TestBrowserTool_WaitRequiresSelector verifies wait needs selector
func TestBrowserTool_WaitRequiresSelector(t *testing.T) {
tool := NewBrowserTool(true)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "wait",
})
if !result.IsError {
t.Error("expected error when selector is missing for wait")
}
}
// TestBrowserTool_CaseInsensitive verifies that action matching is case-insensitive
func TestBrowserTool_CaseInsensitive(t *testing.T) {
tool := NewBrowserTool(true)
// "OPEN" should normalize to "open" and then require url
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "OPEN",
})
// It should not error with "unknown action" — it should error with "url is required"
if !result.IsError {
t.Error("expected error")
}
if strings.Contains(result.ForLLM, "unknown browser action") {
t.Error("action should be case-insensitive")
}
if !strings.Contains(result.ForLLM, "url is required") {
t.Errorf("expected 'url is required' for 'OPEN' action, got: %s", result.ForLLM)
}
}
// TestGetIntArg verifies integer argument extraction from float64
func TestGetIntArg(t *testing.T) {
args := map[string]interface{}{
"timeout": float64(5000),
}
v, ok := getIntArg(args, "timeout")
if !ok || v != 5000 {
t.Errorf("expected 5000, got %d (ok=%v)", v, ok)
}
_, ok = getIntArg(args, "nonexistent")
if ok {
t.Error("expected false for missing key")
}
}
// TestBrowserSearchTool_Integration performs a real search if actionbook is available
func TestBrowserSearchTool_Integration(t *testing.T) {
if _, err := exec.LookPath("actionbook"); err != nil {
t.Skip("actionbook not in PATH, skipping integration test")
}
tool := NewBrowserSearchTool(true)
result := tool.Execute(context.Background(), map[string]interface{}{
"query": "google search",
})
if result.IsError {
t.Errorf("expected success, got error: %s", result.ForLLM)
}
if result.ForLLM == "" {
t.Error("expected non-empty output")
}
}
// TestTruncateOutput verifies output truncation
func TestTruncateOutput(t *testing.T) {
short := "hello"
if truncateOutput(short, 100) != short {
t.Error("short string should not be truncated")
}
long := strings.Repeat("x", 200)
truncated := truncateOutput(long, 100)
if len(truncated) >= 200 {
t.Errorf("expected truncation, got length %d", len(truncated))
}
if !strings.Contains(truncated, "truncated") {
t.Error("expected truncation marker")
}
}