Files
GoClaw/gateway/internal/tools/executor.go
¨NW¨ 0f23dffc26 feat(agents): restore agent-worker container architecture + fix chat scroll and parallel chats
- Restore agent-worker from commit 153399f: autonomous HTTP server per agent
  (main.go 597 lines, main_test.go 438 lines, Dockerfile.agent-worker)
- Add container fields to agents table (serviceName, servicePort, containerImage, containerStatus)
- Update executor.go: real delegateToAgent() with HTTP POST to agent containers
- Update db.go: GetAgentByID, UpdateContainerStatus, GetAgentHistory, SaveHistory
- Update orchestrator.go: inject DB into executor for container address resolution
- Add tRPC endpoints: agents.deployContainer, agents.stopContainer, agents.containerStatus
- Add Docker Swarm deploy/stop logic in server/agents.ts
- Add Start/Stop container buttons to Agents.tsx with status badges
- Fix chat auto-scroll: replace ScrollArea with overflow-y-auto for direct scrollTop control
- Fix parallel chats: make isThinking per-conversation (thinkingConvId) instead of global
  so switching between chats works while one is processing
2026-04-10 15:43:33 +01:00

551 lines
16 KiB
Go

// Package tools implements the GoClaw Tool Executor.
// Each tool corresponds to a function the LLM can call via OpenAI function calling.
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/db"
)
// ─── Types ────────────────────────────────────────────────────────────────────
type ToolResult struct {
Success bool `json:"success"`
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
DurationMs int64 `json:"durationMs"`
}
// ─── Tool Definitions (OpenAI function calling schema) ────────────────────────
type ToolDef struct {
Type string `json:"type"`
Function FuncDef `json:"function"`
}
type FuncDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
}
// OrchestratorTools returns the full list of tools available to the orchestrator.
func OrchestratorTools() []ToolDef {
return []ToolDef{
{
Type: "function",
Function: FuncDef{
Name: "shell_exec",
Description: "Execute a bash command on the host system. Returns stdout and stderr.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{"type": "string", "description": "Bash command to execute"},
"timeout": map[string]any{"type": "number", "description": "Timeout in seconds (default: 30)"},
},
"required": []string{"command"},
"additionalProperties": false,
},
},
},
{
Type: "function",
Function: FuncDef{
Name: "file_read",
Description: "Read a file from the filesystem. Returns file content as text.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Absolute or relative file path"},
},
"required": []string{"path"},
"additionalProperties": false,
},
},
},
{
Type: "function",
Function: FuncDef{
Name: "file_write",
Description: "Write content to a file. Creates parent directories if needed.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "File path to write"},
"content": map[string]any{"type": "string", "description": "Content to write"},
"append": map[string]any{"type": "boolean", "description": "Append instead of overwrite"},
},
"required": []string{"path", "content"},
"additionalProperties": false,
},
},
},
{
Type: "function",
Function: FuncDef{
Name: "file_list",
Description: "List files and directories at a given path.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Directory path"},
"recursive": map[string]any{"type": "boolean", "description": "List recursively"},
},
"required": []string{"path"},
"additionalProperties": false,
},
},
},
{
Type: "function",
Function: FuncDef{
Name: "http_request",
Description: "Make an HTTP request (GET, POST, PUT, DELETE) to any URL.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{"type": "string", "description": "Target URL"},
"method": map[string]any{"type": "string", "description": "HTTP method (default: GET)"},
"headers": map[string]any{"type": "object", "description": "Request headers"},
"body": map[string]any{"type": "string", "description": "Request body for POST/PUT"},
},
"required": []string{"url"},
"additionalProperties": false,
},
},
},
{
Type: "function",
Function: FuncDef{
Name: "docker_exec",
Description: "Execute a Docker CLI command (docker ps, docker logs, docker exec, etc.).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{"type": "string", "description": "Docker command without 'docker' prefix (e.g. 'ps -a', 'logs mycontainer')"},
},
"required": []string{"command"},
"additionalProperties": false,
},
},
},
{
Type: "function",
Function: FuncDef{
Name: "list_agents",
Description: "List all available specialized agents with their capabilities.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{},
"additionalProperties": false,
},
},
},
{
Type: "function",
Function: FuncDef{
Name: "delegate_to_agent",
Description: "Delegate a task to a specialized agent (Browser Agent, Tool Builder, Agent Compiler).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"agentId": map[string]any{"type": "number", "description": "Agent ID to delegate to"},
"message": map[string]any{"type": "string", "description": "Task description for the agent"},
},
"required": []string{"agentId", "message"},
"additionalProperties": false,
},
},
},
}
}
// ─── Executor ─────────────────────────────────────────────────────────────────
type Executor struct {
projectRoot string
httpClient *http.Client
// agentListFn is injected to avoid circular dependency with orchestrator
agentListFn func() ([]map[string]any, error)
// database is used for delegate_to_agent to look up service address
database *db.DB
}
func NewExecutor(projectRoot string, agentListFn func() ([]map[string]any, error)) *Executor {
return &Executor{
projectRoot: projectRoot,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
agentListFn: agentListFn,
}
}
// SetDatabase injects the DB reference so delegate_to_agent can resolve agent addresses.
func (e *Executor) SetDatabase(database *db.DB) {
e.database = database
}
// Execute dispatches a tool call by name.
func (e *Executor) Execute(ctx context.Context, toolName string, argsJSON string) ToolResult {
start := time.Now()
var args map[string]any
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return ToolResult{Success: false, Error: "invalid args JSON: " + err.Error(), DurationMs: ms(start)}
}
var result any
var execErr error
switch toolName {
case "shell_exec":
result, execErr = e.shellExec(ctx, args)
case "file_read":
result, execErr = e.fileRead(args)
case "file_write":
result, execErr = e.fileWrite(args)
case "file_list":
result, execErr = e.fileList(args)
case "http_request":
result, execErr = e.httpRequest(ctx, args)
case "docker_exec":
result, execErr = e.dockerExec(ctx, args)
case "list_agents":
result, execErr = e.listAgents()
case "delegate_to_agent":
result, execErr = e.delegateToAgent(ctx, args)
default:
return ToolResult{Success: false, Error: fmt.Sprintf("unknown tool: %s", toolName), DurationMs: ms(start)}
}
if execErr != nil {
return ToolResult{Success: false, Error: execErr.Error(), DurationMs: ms(start)}
}
return ToolResult{Success: true, Result: result, DurationMs: ms(start)}
}
// ─── Tool Implementations ─────────────────────────────────────────────────────
func (e *Executor) shellExec(ctx context.Context, args map[string]any) (any, error) {
command, _ := args["command"].(string)
if command == "" {
return nil, fmt.Errorf("command is required")
}
// Safety: block destructive patterns
blocked := []string{"rm -rf /", "mkfs", "dd if=/dev/zero", ":(){ :|:& };:"}
for _, b := range blocked {
if strings.Contains(command, b) {
return nil, fmt.Errorf("command blocked for safety: contains '%s'", b)
}
}
timeoutSec := 30
if t, ok := args["timeout"].(float64); ok && t > 0 {
timeoutSec = int(t)
}
ctx2, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx2, "bash", "-c", command)
cmd.Dir = e.projectRoot
out, err := cmd.CombinedOutput()
stdout := string(out)
if len(stdout) > 20000 {
stdout = stdout[:20000] + "\n...[truncated]"
}
if err != nil {
// Return partial output even on error
return map[string]any{"stdout": stdout, "stderr": err.Error(), "exitCode": cmd.ProcessState.ExitCode()}, nil
}
return map[string]any{"stdout": stdout, "stderr": "", "exitCode": 0}, nil
}
func (e *Executor) fileRead(args map[string]any) (any, error) {
path, _ := args["path"].(string)
if path == "" {
return nil, fmt.Errorf("path is required")
}
path = e.resolvePath(path)
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
content := string(data)
if len(content) > 50000 {
content = content[:50000] + "\n...[truncated]"
}
return map[string]any{"content": content, "size": len(data), "path": path}, nil
}
func (e *Executor) fileWrite(args map[string]any) (any, error) {
path, _ := args["path"].(string)
content, _ := args["content"].(string)
appendMode, _ := args["append"].(bool)
if path == "" {
return nil, fmt.Errorf("path is required")
}
path = e.resolvePath(path)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, err
}
flag := os.O_WRONLY | os.O_CREATE | os.O_TRUNC
if appendMode {
flag = os.O_WRONLY | os.O_CREATE | os.O_APPEND
}
f, err := os.OpenFile(path, flag, 0644)
if err != nil {
return nil, err
}
defer f.Close()
n, err := f.WriteString(content)
if err != nil {
return nil, err
}
return map[string]any{"written": n, "path": path, "append": appendMode}, nil
}
func (e *Executor) fileList(args map[string]any) (any, error) {
path, _ := args["path"].(string)
if path == "" {
path = "."
}
path = e.resolvePath(path)
recursive, _ := args["recursive"].(bool)
var entries []map[string]any
if recursive {
err := filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(path, p)
entries = append(entries, map[string]any{
"name": rel,
"isDir": info.IsDir(),
"size": info.Size(),
})
return nil
})
if err != nil {
return nil, err
}
} else {
dirEntries, err := os.ReadDir(path)
if err != nil {
return nil, err
}
for _, de := range dirEntries {
info, _ := de.Info()
size := int64(0)
if info != nil {
size = info.Size()
}
entries = append(entries, map[string]any{
"name": de.Name(),
"isDir": de.IsDir(),
"size": size,
})
}
}
return map[string]any{"path": path, "entries": entries, "count": len(entries)}, nil
}
func (e *Executor) httpRequest(ctx context.Context, args map[string]any) (any, error) {
url, _ := args["url"].(string)
if url == "" {
return nil, fmt.Errorf("url is required")
}
method := "GET"
if m, ok := args["method"].(string); ok && m != "" {
method = strings.ToUpper(m)
}
var bodyReader io.Reader
if body, ok := args["body"].(string); ok && body != "" {
bodyReader = strings.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "GoClaw-Gateway/1.0")
if headers, ok := args["headers"].(map[string]any); ok {
for k, v := range headers {
req.Header.Set(k, fmt.Sprintf("%v", v))
}
}
if bodyReader != nil && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := e.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
text := string(respBody)
if len(text) > 10000 {
text = text[:10000] + "\n...[truncated]"
}
return map[string]any{
"status": resp.StatusCode,
"statusText": resp.Status,
"body": text,
}, nil
}
func (e *Executor) dockerExec(ctx context.Context, args map[string]any) (any, error) {
command, _ := args["command"].(string)
if command == "" {
return nil, fmt.Errorf("command is required")
}
ctx2, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
parts := strings.Fields("docker " + command)
cmd := exec.CommandContext(ctx2, parts[0], parts[1:]...)
out, err := cmd.CombinedOutput()
output := string(out)
if len(output) > 10000 {
output = output[:10000] + "\n...[truncated]"
}
if err != nil {
return map[string]any{"output": output, "error": err.Error()}, nil
}
return map[string]any{"output": output}, nil
}
func (e *Executor) listAgents() (any, error) {
if e.agentListFn == nil {
return map[string]any{"agents": []any{}, "note": "DB not connected"}, nil
}
agents, err := e.agentListFn()
if err != nil {
return nil, err
}
return map[string]any{"agents": agents, "count": len(agents)}, nil
}
func (e *Executor) delegateToAgent(ctx context.Context, args map[string]any) (any, error) {
agentIDf, _ := args["agentId"].(float64)
agentID := int(agentIDf)
task, _ := args["task"].(string)
if task == "" {
task, _ = args["message"].(string) // backward compat
}
if task == "" {
return nil, fmt.Errorf("task (or message) is required")
}
callbackURL, _ := args["callbackUrl"].(string)
async, _ := args["async"].(bool)
// Resolve agent container address from DB
if e.database != nil {
cfg, err := e.database.GetAgentByID(agentID)
if err == nil && cfg != nil && cfg.ServicePort > 0 && cfg.ContainerStatus == "running" {
// Agent is deployed — call its container via overlay DNS
// Docker Swarm DNS: service name resolves inside overlay network
agentURL := fmt.Sprintf("http://%s:%d", cfg.ServiceName, cfg.ServicePort)
if async {
return e.postAgentTask(ctx, agentURL, agentID, task, callbackURL)
}
return e.postAgentChat(ctx, agentURL, agentID, task)
}
}
// Fallback: agent not deployed yet — return informational response
return map[string]any{
"delegated": false,
"agentId": agentID,
"task": task,
"note": fmt.Sprintf("Agent %d is not running (containerStatus != running). Deploy it first via Web Panel.", agentID),
}, nil
}
// postAgentTask POSTs to agent's /task endpoint (async, returns task_id).
func (e *Executor) postAgentTask(ctx context.Context, agentURL string, fromAgentID int, task, callbackURL string) (any, error) {
payload, _ := json.Marshal(map[string]any{
"input": task,
"from_agent_id": fromAgentID,
"callback_url": callbackURL,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, agentURL+"/task", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("delegate build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := e.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("delegate HTTP error: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]any
_ = json.Unmarshal(body, &result)
return result, nil
}
// postAgentChat POSTs to agent's /chat endpoint (sync, waits for response).
func (e *Executor) postAgentChat(ctx context.Context, agentURL string, _ int, task string) (any, error) {
payload, _ := json.Marshal(map[string]any{
"messages": []map[string]string{{"role": "user", "content": task}},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, agentURL+"/chat", bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("delegate build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := e.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("delegate HTTP error: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]any
_ = json.Unmarshal(body, &result)
return result, nil
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
func (e *Executor) resolvePath(path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(e.projectRoot, path)
}
func ms(start time.Time) int64 {
return time.Since(start).Milliseconds()
}