Download
222 lines 6.6 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
package tools

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"slices"
	"strconv"
	"strings"
	"time"
	"unicode"

	"blinker/internal/llm"
	"blinker/internal/policy"
	"blinker/internal/tooldef"
)

// userTools returns the policy's tools the model may call.
func (t *Toolbox) userTools() []tooldef.Def {
	var out []tooldef.Def
	for _, tool := range t.SB.Tools() {
		if tool.Access != policy.Deny {
			out = append(out, tool)
		}
	}
	return out
}

func (t *Toolbox) userTool(name string) (tooldef.Def, bool) {
	for _, tool := range t.userTools() {
		if tool.Name == name {
			return tool, true
		}
	}
	return tooldef.Def{}, false
}

// userSpec describes a tool for the model.
func userSpec(tool tooldef.Def) llm.Tool {
	props := map[string]any{}
	var required []string
	for _, a := range tool.Args {
		p := map[string]any{"type": "string", "description": a.Description}
		switch a.Type {
		case "integer":
			p["type"] = "integer"
		case "enum":
			p["enum"] = a.Values
		case "boolean":
			p["type"] = "boolean"
		case "path":
			p["description"] = a.Description + " (a path, absolute or relative to the project directory)"
		}
		if a.Variadic {
			p = map[string]any{"type": "array", "items": p, "description": a.Description}
		} else {
			required = append(required, a.Name)
		}
		props[a.Name] = p
	}
	desc := strings.TrimSpace(tool.Description)
	if tool.Access == policy.Ask {
		desc += " The user is asked to approve each call."
	}
	return llm.Tool{Name: tool.Name, Description: desc, Parameters: schema(props, required...)}
}

// timeout is how long a call of tool may run.
func (t *Toolbox) timeout(tool tooldef.Def) time.Duration {
	if tool.Timeout > 0 {
		return tool.Timeout
	}
	return t.CommandTimeout
}

// toolArgs returns the policy a tool runs under, and checks the model's
// arguments for it against their types, returning them as the script's
// positional parameters, with the name of the argument each is.
func (t *Toolbox) toolArgs(tool tooldef.Def, raw string) (pol *policy.Compiled, params, names []string, err error) {
	c, _ := t.SB.Policy()
	if pol, err = tool.Policy(c); err != nil {
		return nil, nil, nil, err
	}
	var given map[string]json.RawMessage
	if err := json.Unmarshal([]byte(raw), &given); err != nil {
		return nil, nil, nil, fmt.Errorf("the arguments are not a JSON object: %v", err)
	}
	known := map[string]bool{}
	for _, a := range tool.Args {
		known[a.Name] = true
	}
	for name := range given {
		if !known[name] {
			return nil, nil, nil, fmt.Errorf("%s has no argument %q", tool.Name, name)
		}
	}
	for _, a := range tool.Args {
		value, ok := given[a.Name]
		var values []json.RawMessage
		switch {
		case !ok && a.Variadic:
		case !ok:
			return nil, nil, nil, fmt.Errorf("missing argument %q", a.Name)
		case a.Variadic:
			if err := json.Unmarshal(value, &values); err != nil {
				return nil, nil, nil, fmt.Errorf("argument %q must be a list", a.Name)
			}
		default:
			values = []json.RawMessage{value}
		}
		for _, v := range values {
			p, err := t.toolArg(a, v, pol)
			if err != nil {
				return nil, nil, nil, fmt.Errorf("argument %q: %w", a.Name, err)
			}
			params = append(params, p)
			names = append(names, a.Name)
		}
	}
	return pol, params, names, nil
}

// toolArg checks one value. A path must be readable by the tool, and is
// passed absolute, so it can never be taken for an option.
func (t *Toolbox) toolArg(a tooldef.Arg, raw json.RawMessage, pol *policy.Compiled) (string, error) {
	if string(bytes.TrimSpace(raw)) == "null" {
		return "", errors.New("must not be null") // Unmarshal would leave the zero value
	}
	switch a.Type {
	case "integer":
		var n int64
		if err := json.Unmarshal(raw, &n); err != nil {
			return "", errors.New("must be an integer")
		}
		return strconv.FormatInt(n, 10), nil
	case "boolean":
		var b bool
		if err := json.Unmarshal(raw, &b); err != nil {
			return "", errors.New("must be true or false")
		}
		return strconv.FormatBool(b), nil
	}
	var s string
	if err := json.Unmarshal(raw, &s); err != nil {
		return "", errors.New("must be a string")
	}
	if strings.ContainsRune(s, 0) {
		return "", errors.New("contains a NUL byte")
	}
	switch a.Type {
	case "enum":
		if slices.Contains(a.Values, s) {
			return s, nil
		}
		return "", fmt.Errorf("must be one of %s", strings.Join(a.Values, ", "))
	case "path":
		shown, real, err := t.resolve(s)
		if err != nil {
			return "", err
		}
		if !pol.Access(real).Read {
			return "", fmt.Errorf("the tool cannot read %s (%s)", shown, pol.Explain(real, false))
		}
		return t.SB.CommandPath(real), nil
	}
	return s, nil
}

// runUserTool runs a call of a user's tool.
func (t *Toolbox) runUserTool(ctx context.Context, tool tooldef.Def, args string) (Result, error) {
	r := Result{Summary: tool.Name}
	mode := tool.Mode()
	pol, params, names, err := t.toolArgs(tool, args)
	if err != nil {
		return r, err
	}
	r.Summary = fmt.Sprintf("%s(%s)", tool.Name, firstLine(strings.Join(params, ", ")))
	if ok, err := t.approveCall(ctx, tool, names, params, &r); !ok {
		return r, err
	}
	timeout := t.timeout(tool)
	// The script gets its arguments as $1, $2, ...; $0 is the tool's name.
	argv := append([]string{t.SB.Shell(), "-c", tool.Script, tool.Name}, params...)
	return t.run(ctx, r, mode, argv, timeout, pol)
}

// approveCall asks the user about a call of a tool whose policy says to
// ask. If they deny it, it returns false, with r set to tell the model.
func (t *Toolbox) approveCall(ctx context.Context, tool tooldef.Def, names, params []string, r *Result) (bool, error) {
	if tool.Access != policy.Ask {
		return true, nil
	}
	_, ok, err := t.ask(ctx, Question{
		Tool: tool.Name, Title: "The " + tool.Name + " tool wants to run",
		Body: argTable(names, params), Opts: runOpts,
	}, r, "The user denied this call; it did not run.")
	return ok, err
}

// argTable shows a tool call's arguments for approval, one per line with
// the names aligned. They are all the model chose, so all the user decides
// on: the script is blinker's or the user's (the floor keeps the model out
// of blinker's config), and the tool's access is the policy's. A value is
// quoted if it could otherwise be misread: empty, with space at an end, or
// with a line break (which could fake another argument's line) or any
// other character that does not show.
func argTable(names, values []string) string {
	width := 0
	for _, n := range names {
		width = max(width, len(n))
	}
	var b strings.Builder
	for i, v := range values {
		if v == "" || strings.TrimSpace(v) != v || strings.ContainsFunc(v, func(r rune) bool { return !unicode.IsPrint(r) && r != ' ' }) {
			v = strconv.Quote(v)
		}
		fmt.Fprintf(&b, "%-*s  %s\n", width, names[i], v)
	}
	return strings.TrimSuffix(b.String(), "\n")
}