Files
synapbus/internal/jsruntime/runtime.go
T
Algis DumbrisandClaude Opus 4.6 fef84ed538 refactor: consolidate 30 MCP tools into 4 hybrid tools
Replace 5 separate tool registrars (messaging, channels, swarm,
attachments, webhooks) with a single HybridToolRegistrar exposing
4 tools: my_status, send_message, search, and execute.

New foundation packages:
- internal/actions: action registry (22 actions) + BM25 search index
- internal/jsruntime: lightweight call() expression parser with
  concurrency-limited execution pool

The `execute` tool dispatches call() expressions through a
ServiceBridge that maps action names to existing service methods,
preserving all original handler logic. The `search` tool enables
agents to discover available actions by keyword. The `send_message`
tool merges DM and channel sending with mutual exclusion.

All unit tests, integration tests, build, and vet pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 08:16:41 +02:00

314 lines
8.3 KiB
Go

// Package jsruntime provides a lightweight code execution environment for the
// execute MCP tool. It parses simple call() expressions and dispatches them to
// a ToolCaller bridge, which maps action names to service methods.
//
// This is NOT a full JavaScript runtime. It supports:
// - call(actionName, argsObject) — calls an action via the bridge
// - Multiple sequential call() invocations
// - JSON-like argument objects
//
// For the zero-CGO constraint, we avoid embedding a full JS engine and instead
// provide a purpose-built mini-interpreter that covers the execute tool's needs.
package jsruntime
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
)
// ToolCaller is the interface that bridges action names to service method calls.
type ToolCaller interface {
Call(ctx context.Context, actionName string, args map[string]any) (any, error)
}
// ExecuteResult holds the result of code execution.
type ExecuteResult struct {
Value any `json:"value"`
Calls int `json:"calls"`
Duration time.Duration `json:"duration"`
}
// Pool manages a set of execution contexts. In the current implementation,
// each Execute call runs synchronously with timeout enforcement.
type Pool struct {
maxConcurrent int
sem chan struct{}
}
// NewPool creates a new execution pool with the given concurrency limit.
func NewPool(maxConcurrent int) *Pool {
if maxConcurrent <= 0 {
maxConcurrent = 10
}
return &Pool{
maxConcurrent: maxConcurrent,
sem: make(chan struct{}, maxConcurrent),
}
}
// Close releases pool resources.
func (p *Pool) Close() {
// Nothing to clean up in the current implementation.
}
// MaxCalls is the maximum number of call() invocations per execute request.
const MaxCalls = 50
// Execute runs code with the provided ToolCaller bridge and timeout.
// The code is parsed for call(action, args) expressions and each is dispatched.
func (p *Pool) Execute(ctx context.Context, code string, caller ToolCaller, timeout time.Duration) (*ExecuteResult, error) {
if timeout <= 0 {
timeout = 120 * time.Second
}
// Acquire semaphore slot.
select {
case p.sem <- struct{}{}:
defer func() { <-p.sem }()
case <-ctx.Done():
return nil, ctx.Err()
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
start := time.Now()
calls, err := parseCalls(code)
if err != nil {
return nil, fmt.Errorf("syntax error: %w", err)
}
if len(calls) == 0 {
return nil, fmt.Errorf("no call() expressions found in code")
}
if len(calls) > MaxCalls {
return nil, fmt.Errorf("too many call() expressions: %d (max %d)", len(calls), MaxCalls)
}
var lastResult any
callCount := 0
for _, c := range calls {
select {
case <-ctx.Done():
return nil, fmt.Errorf("execution timeout after %d calls", callCount)
default:
}
result, err := caller.Call(ctx, c.Action, c.Args)
if err != nil {
return nil, fmt.Errorf("call %q failed: %w", c.Action, err)
}
lastResult = result
callCount++
}
return &ExecuteResult{
Value: lastResult,
Calls: callCount,
Duration: time.Since(start),
}, nil
}
// callExpr represents a parsed call(action, args) expression.
type callExpr struct {
Action string
Args map[string]any
}
// callPattern matches call("action_name", { ... }) or call('action_name', { ... })
var callPattern = regexp.MustCompile(`call\s*\(\s*["']([^"']+)["']\s*(?:,\s*(` + jsonObjectPattern + `))?\s*\)`)
// jsonObjectPattern is a rough pattern for JSON-like objects. It's not perfect
// but works for typical usage. Deep nesting may need the fallback parser.
const jsonObjectPattern = `\{[^}]*\}`
// parseCalls extracts all call() expressions from the code string.
func parseCalls(code string) ([]callExpr, error) {
// Strip single-line comments.
lines := strings.Split(code, "\n")
var cleaned []string
for _, line := range lines {
// Remove // comments (but not inside strings -- good enough for typical usage).
if idx := strings.Index(line, "//"); idx >= 0 {
line = line[:idx]
}
cleaned = append(cleaned, line)
}
code = strings.Join(cleaned, "\n")
// Try regex-based extraction first.
matches := callPattern.FindAllStringSubmatch(code, -1)
if len(matches) == 0 {
// Try a more lenient parse for nested objects.
return parseCallsLenient(code)
}
var calls []callExpr
for _, m := range matches {
actionName := m[1]
argsStr := "{}"
if len(m) > 2 && m[2] != "" {
argsStr = m[2]
}
args, err := parseArgsJSON(argsStr)
if err != nil {
return nil, fmt.Errorf("invalid arguments for call(%q): %w", actionName, err)
}
calls = append(calls, callExpr{Action: actionName, Args: args})
}
return calls, nil
}
// parseCallsLenient handles cases where the regex fails (nested braces, etc.)
func parseCallsLenient(code string) ([]callExpr, error) {
var calls []callExpr
remaining := code
for {
// Find next call(
idx := strings.Index(remaining, "call(")
if idx == -1 {
break
}
remaining = remaining[idx+5:] // skip "call("
// Extract action name.
remaining = strings.TrimSpace(remaining)
if len(remaining) == 0 {
return nil, fmt.Errorf("unexpected end after call(")
}
quote := remaining[0]
if quote != '"' && quote != '\'' {
return nil, fmt.Errorf("expected quoted action name after call(")
}
remaining = remaining[1:]
endQuote := strings.IndexByte(remaining, quote)
if endQuote == -1 {
return nil, fmt.Errorf("unterminated action name string")
}
actionName := remaining[:endQuote]
remaining = remaining[endQuote+1:]
// Skip whitespace and comma.
remaining = strings.TrimSpace(remaining)
args := make(map[string]any)
if len(remaining) > 0 && remaining[0] == ',' {
remaining = strings.TrimSpace(remaining[1:])
// Find matching brace for args object.
if len(remaining) > 0 && remaining[0] == '{' {
braceEnd := findMatchingBrace(remaining)
if braceEnd == -1 {
return nil, fmt.Errorf("unmatched brace in arguments")
}
argsStr := remaining[:braceEnd+1]
var err error
args, err = parseArgsJSON(argsStr)
if err != nil {
return nil, fmt.Errorf("invalid arguments for call(%q): %w", actionName, err)
}
remaining = remaining[braceEnd+1:]
}
}
// Skip closing paren.
remaining = strings.TrimSpace(remaining)
if len(remaining) > 0 && remaining[0] == ')' {
remaining = remaining[1:]
}
calls = append(calls, callExpr{Action: actionName, Args: args})
}
return calls, nil
}
// findMatchingBrace finds the index of the closing brace matching the opening brace at position 0.
func findMatchingBrace(s string) int {
if len(s) == 0 || s[0] != '{' {
return -1
}
depth := 0
inString := false
var stringChar byte
for i := 0; i < len(s); i++ {
ch := s[i]
if inString {
if ch == '\\' {
i++ // skip escaped char
continue
}
if ch == stringChar {
inString = false
}
continue
}
if ch == '"' || ch == '\'' {
inString = true
stringChar = ch
continue
}
if ch == '{' {
depth++
} else if ch == '}' {
depth--
if depth == 0 {
return i
}
}
}
return -1
}
// parseArgsJSON converts a JavaScript-like object literal to a Go map.
// It handles unquoted keys by converting to valid JSON first.
func parseArgsJSON(s string) (map[string]any, error) {
s = strings.TrimSpace(s)
if s == "" || s == "{}" {
return make(map[string]any), nil
}
// Try standard JSON first.
var result map[string]any
if err := json.Unmarshal([]byte(s), &result); err == nil {
return result, nil
}
// Convert JS-style object to JSON (unquoted keys, trailing commas, single quotes).
jsonStr := jsObjectToJSON(s)
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
return nil, fmt.Errorf("cannot parse args: %s", err)
}
return result, nil
}
// jsObjectToJSON converts a JavaScript-style object literal to valid JSON.
func jsObjectToJSON(s string) string {
// Replace single quotes with double quotes (simple approach).
s = strings.ReplaceAll(s, "'", "\"")
// Add quotes around unquoted keys.
// Match: word characters (possibly with underscores) followed by colon.
keyPattern := regexp.MustCompile(`(?m)([\{\,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:`)
s = keyPattern.ReplaceAllString(s, `$1"$2":`)
// Remove trailing commas before closing braces/brackets.
trailingComma := regexp.MustCompile(`,\s*([}\]])`)
s = trailingComma.ReplaceAllString(s, `$1`)
// Handle true/false/null (already valid JSON, but just in case).
return s
}