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")
}
|