Files
vessel/backend/internal/api/tools.go
vikingowl 862f47c46e
Some checks failed
Create Release / release (push) Has been cancelled
feat(tools): enhanced custom tool creation with CodeMirror, Python support, and testing
- Add CodeMirror editor with syntax highlighting for JavaScript and Python
- Add 8 starter templates (4 JS, 4 Python) for common tool patterns
- Add inline documentation panel with language-specific guidance
- Add tool testing UI to run tools with sample inputs before saving
- Add Python tool execution via backend API with 30s timeout
- Add POST /api/v1/tools/execute endpoint for backend tool execution
- Update Dockerfile to include Python 3 for tool execution
- Bump version to 0.4.0
2026-01-02 20:15:40 +01:00

175 lines
4.2 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"os"
"os/exec"
"time"
"github.com/gin-gonic/gin"
)
// ExecuteToolRequest represents a tool execution request
type ExecuteToolRequest struct {
Language string `json:"language" binding:"required,oneof=python javascript"`
Code string `json:"code" binding:"required"`
Args map[string]interface{} `json:"args"`
Timeout int `json:"timeout"` // seconds, default 30
}
// ExecuteToolResponse represents the tool execution response
type ExecuteToolResponse struct {
Success bool `json:"success"`
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
}
// MaxOutputSize is the maximum size of tool output (100KB)
const MaxOutputSize = 100 * 1024
// ExecuteToolHandler handles tool execution requests
func ExecuteToolHandler() gin.HandlerFunc {
return func(c *gin.Context) {
var req ExecuteToolRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ExecuteToolResponse{
Success: false,
Error: "Invalid request: " + err.Error(),
})
return
}
// Default timeout
timeout := req.Timeout
if timeout <= 0 || timeout > 60 {
timeout = 30
}
var resp ExecuteToolResponse
switch req.Language {
case "python":
resp = executePython(req.Code, req.Args, timeout)
case "javascript":
// JavaScript execution not supported on backend (runs in browser)
resp = ExecuteToolResponse{
Success: false,
Error: "JavaScript tools should be executed in the browser",
}
default:
resp = ExecuteToolResponse{
Success: false,
Error: "Unsupported language: " + req.Language,
}
}
c.JSON(http.StatusOK, resp)
}
}
// executePython executes Python code with the given arguments
func executePython(code string, args map[string]interface{}, timeout int) ExecuteToolResponse {
// Create a wrapper script that reads args from stdin
wrapperScript := `
import json
import sys
# Read args from stdin
args = json.loads(sys.stdin.read())
# Execute user code
` + code
// Create temp file for the script
tmpFile, err := os.CreateTemp("", "tool-*.py")
if err != nil {
return ExecuteToolResponse{
Success: false,
Error: "Failed to create temp file: " + err.Error(),
}
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(wrapperScript); err != nil {
return ExecuteToolResponse{
Success: false,
Error: "Failed to write script: " + err.Error(),
}
}
tmpFile.Close()
// Marshal args to JSON for stdin
argsJSON, err := json.Marshal(args)
if err != nil {
return ExecuteToolResponse{
Success: false,
Error: "Failed to serialize args: " + err.Error(),
}
}
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
// Execute Python (using exec.Command, not shell)
cmd := exec.CommandContext(ctx, "python3", tmpFile.Name())
cmd.Stdin = bytes.NewReader(argsJSON)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
// Check for timeout
if ctx.Err() == context.DeadlineExceeded {
return ExecuteToolResponse{
Success: false,
Error: "Execution timed out after " + string(rune(timeout)) + " seconds",
Stderr: truncateOutput(stderr.String()),
}
}
// Truncate output if needed
stdoutStr := truncateOutput(stdout.String())
stderrStr := truncateOutput(stderr.String())
if err != nil {
return ExecuteToolResponse{
Success: false,
Error: "Execution failed: " + err.Error(),
Stdout: stdoutStr,
Stderr: stderrStr,
}
}
// Try to parse stdout as JSON
var result interface{}
if stdoutStr != "" {
if err := json.Unmarshal([]byte(stdoutStr), &result); err != nil {
// If not valid JSON, return as string
result = stdoutStr
}
}
return ExecuteToolResponse{
Success: true,
Result: result,
Stdout: stdoutStr,
Stderr: stderrStr,
}
}
// truncateOutput truncates output to MaxOutputSize
func truncateOutput(s string) string {
if len(s) > MaxOutputSize {
return s[:MaxOutputSize] + "\n... (output truncated)"
}
return s
}