Ryanhub - file viewer
filename: assistant/agent/human_tool_parse.go
branch: main
back to repo
package agent

import (
	"encoding/json"
	"fmt"
	"strconv"
	"strings"
	"unicode"
)

// ParseHumanToolInvocation parses the text after "/tool " into a tool name and JSON arguments.
// Supported forms:
//   <name>                          → {}
//   <name> { ... }                  → JSON object (legacy / advanced)
//   <name> key=value key2="a b"     → human key=value (values quoted if they contain spaces)
func ParseHumanToolInvocation(rest string) (name string, argsJSON string, err error) {
	rest = strings.TrimSpace(rest)
	if rest == "" {
		return "", "", fmt.Errorf("missing tool name")
	}
	i := 0
	for i < len(rest) && (unicode.IsLetter(rune(rest[i])) || unicode.IsDigit(rune(rest[i])) || rest[i] == '_') {
		i++
	}
	if i == 0 {
		return "", "", fmt.Errorf("missing tool name (use letters, digits, underscores)")
	}
	name = rest[:i]
	after := strings.TrimSpace(rest[i:])
	if after == "" {
		return name, "{}", nil
	}
	if after[0] == '{' {
		if !json.Valid([]byte(after)) {
			return "", "", fmt.Errorf("invalid JSON for tool arguments")
		}
		return name, after, nil
	}
	m, err := parseHumanKeyValues(after)
	if err != nil {
		return "", "", err
	}
	b, err := json.Marshal(m)
	if err != nil {
		return "", "", err
	}
	return name, string(b), nil
}

func parseHumanKeyValues(s string) (map[string]interface{}, error) {
	s = strings.TrimSpace(s)
	if s == "" {
		return map[string]interface{}{}, nil
	}
	r := kvScanner{s: s, i: 0}
	out := map[string]interface{}{}
	for r.i < len(r.s) {
		r.skipSpace()
		if r.i >= len(r.s) {
			break
		}
		key := r.readIdent()
		if key == "" {
			return nil, fmt.Errorf("expected argument name (letters, digits, underscore) near %q", r.rest())
		}
		r.skipSpace()
		if r.peek() != '=' {
			return nil, fmt.Errorf("expected = after %q", key)
		}
		r.i++
		r.skipSpace()
		raw, err := r.readValue()
		if err != nil {
			return nil, err
		}
		out[key] = coerceJSONScalar(raw)
	}
	return out, nil
}

type kvScanner struct {
	s string
	i int
}

func (r *kvScanner) rest() string {
	if r.i >= len(r.s) {
		return ""
	}
	return r.s[r.i:]
}

func (r *kvScanner) peek() byte {
	if r.i >= len(r.s) {
		return 0
	}
	return r.s[r.i]
}

func (r *kvScanner) skipSpace() {
	for r.i < len(r.s) && (r.s[r.i] == ' ' || r.s[r.i] == '\t' || r.s[r.i] == '\n' || r.s[r.i] == '\r') {
		r.i++
	}
}

func (r *kvScanner) readIdent() string {
	start := r.i
	for r.i < len(r.s) && (unicode.IsLetter(rune(r.s[r.i])) || unicode.IsDigit(rune(r.s[r.i])) || r.s[r.i] == '_') {
		r.i++
	}
	return r.s[start:r.i]
}

func (r *kvScanner) readValue() (string, error) {
	if r.i >= len(r.s) {
		return "", nil
	}
	switch r.s[r.i] {
	case '"':
		return r.readDoubleQuoted()
	case '\'':
		return r.readSingleQuoted()
	default:
		return r.readBare(), nil
	}
}

func (r *kvScanner) readDoubleQuoted() (string, error) {
	r.i++ // opening "
	var b strings.Builder
	for r.i < len(r.s) {
		c := r.s[r.i]
		if c == '\\' && r.i+1 < len(r.s) {
			r.i++
			b.WriteByte(r.s[r.i])
			r.i++
			continue
		}
		if c == '"' {
			r.i++
			return b.String(), nil
		}
		b.WriteByte(c)
		r.i++
	}
	return "", fmt.Errorf("unclosed \" in value")
}

func (r *kvScanner) readSingleQuoted() (string, error) {
	r.i++ // opening '
	var b strings.Builder
	for r.i < len(r.s) {
		c := r.s[r.i]
		if c == '\'' {
			r.i++
			return b.String(), nil
		}
		b.WriteByte(c)
		r.i++
	}
	return "", fmt.Errorf("unclosed ' in value")
}

func (r *kvScanner) readBare() string {
	start := r.i
	for r.i < len(r.s) && !unicode.IsSpace(rune(r.s[r.i])) {
		r.i++
	}
	return r.s[start:r.i]
}

func coerceJSONScalar(s string) interface{} {
	if s == "" {
		return ""
	}
	if s == "true" {
		return true
	}
	if s == "false" {
		return false
	}
	if s == "null" {
		return nil
	}
	if n, err := strconv.ParseInt(s, 10, 64); err == nil {
		return n
	}
	if f, err := strconv.ParseFloat(s, 64); err == nil {
		return f
	}
	return s
}