Add grep and server read/write/edit tools for agents

This commit is contained in:
Ian Gulliver
2026-10-20 20:22:54 -07:00
parent 35f59246f7
commit 6ec3cafde5
9 changed files with 717 additions and 66 deletions
+77 -63
View File
@@ -3,7 +3,6 @@ package agents
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -14,7 +13,6 @@ import (
"sort"
"strings"
"time"
"unicode/utf8"
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/clients"
@@ -29,6 +27,7 @@ const (
fileTimeout = 2 * time.Minute
handsTimeout = 2 * time.Minute
defaultReadLimit = 2000
clientByre = "/Applications/Byre.app/Contents/Helpers/byre"
)
func (m *Manager) clientTools(l *live) []mcp.Tool {
@@ -163,6 +162,25 @@ func (m *Manager) clientTools(l *live) []mcp.Tool {
return m.clientEdit(ctx, raw)
},
},
{
Name: "client_grep",
Description: grepDescription + " It searches a client Mac, with the byre in its installed Byre.app.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."},
"pattern": {"type": "string"},
"path": {"type": "string", "description": "The file or directory to search, absolute or starting with ~/."},
"glob": {"type": "string", "description": "Search only files whose names match this glob, e.g. *.go."},
"ignore_case": {"type": "boolean"},
"files_only": {"type": "boolean", "description": "List the matching files instead of their lines."}
},
"required": ["client", "pattern", "path"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
return m.clientGrep(ctx, raw)
},
},
{
Name: "client_hands",
Description: "Call one of your hands tools (screen_list, capture, ax_tree, act, browser, chrome) on a client Mac instead of the Mac byre runs on: " +
@@ -452,8 +470,6 @@ func (m *Manager) writeClientFile(ctx context.Context, client, path string, data
return err
}
var imageTypes = map[string]string{".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp"}
type clientReadArgs struct {
Client string `json:"client"`
Path string `json:"path"`
@@ -471,48 +487,7 @@ func (m *Manager) clientRead(ctx context.Context, raw json.RawMessage) mcp.Resul
if err != nil {
return mcp.TextResult(err.Error(), true)
}
mime, ok := imageTypes[strings.ToLower(filepath.Ext(args.Path))]
if ok {
return mcp.Result{Content: []mcp.Content{{Type: "image", Data: base64.StdEncoding.EncodeToString(data), MimeType: mime}}}
}
text, err := numberLines(args.Path, data, args.Offset, args.Limit)
if err != nil {
return mcp.TextResult(err.Error(), true)
}
return mcp.TextResult(text, false)
}
func numberLines(path string, data []byte, offset, limit *int) (string, error) {
if !utf8.Valid(data) {
return "", fmt.Errorf("%s isn't text; use client_download to copy it into your worktree", path)
}
if len(data) == 0 {
return fmt.Sprintf("%s is empty.", path), nil
}
start := 1
if offset != nil {
start = *offset
}
n := defaultReadLimit
if limit != nil {
n = *limit
}
if start < 1 || n < 1 {
return "", errors.New("offset and limit must be at least 1")
}
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
if start > len(lines) {
return "", fmt.Errorf("%s has %d lines; offset %d is past the end", path, len(lines), start)
}
end := min(start-1+n, len(lines))
b := &strings.Builder{}
for i := start - 1; i < end; i++ {
fmt.Fprintf(b, "%6d\t%s\n", i+1, lines[i])
}
if end < len(lines) {
fmt.Fprintf(b, "(%d more lines; read on with offset %d)\n", len(lines)-end, end+1)
}
return b.String(), nil
return readResult(args.Path, data, args.Offset, args.Limit)
}
type clientWriteArgs struct {
@@ -547,34 +522,73 @@ func (m *Manager) clientEdit(ctx context.Context, raw json.RawMessage) (string,
if err != nil {
return "", fmt.Errorf("malformed client_edit call: %w", err)
}
if args.OldString == "" {
return "", errors.New("old_string is empty")
}
if args.OldString == args.NewString {
return "", errors.New("old_string and new_string are the same")
}
data, err := m.readClientFile(ctx, args.Client, args.Path)
if err != nil {
return "", err
}
if !utf8.Valid(data) {
return "", fmt.Errorf("%s isn't text", args.Path)
edited, err := replaceOnce(args.Path, data, args.OldString, args.NewString)
if err != nil {
return "", err
}
text := string(data)
n := strings.Count(text, args.OldString)
if n == 0 {
return "", fmt.Errorf("old_string isn't in %s", args.Path)
}
if n > 1 {
return "", fmt.Errorf("old_string appears %d times in %s; include more of the text around it so it appears once", n, args.Path)
}
err = m.writeClientFile(ctx, args.Client, args.Path, []byte(strings.Replace(text, args.OldString, args.NewString, 1)))
err = m.writeClientFile(ctx, args.Client, args.Path, edited)
if err != nil {
return "", err
}
return fmt.Sprintf("Edited %s on %s.", args.Path, args.Client), nil
}
type clientGrepArgs struct {
Client string `json:"client"`
grepArgs
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func (m *Manager) clientGrep(ctx context.Context, raw json.RawMessage) (string, error) {
args := clientGrepArgs{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed client_grep call: %w", err)
}
err = clientPath("path", args.Path)
if err != nil {
return "", err
}
path := shellQuote(args.Path)
rest, ok := strings.CutPrefix(args.Path, "~/")
if ok {
path = "~/" + shellQuote(rest)
}
command := clientByre + " grep"
if args.Glob != "" {
command += " --glob " + shellQuote(args.Glob)
}
if args.IgnoreCase {
command += " --ignore-case"
}
if args.FilesOnly {
command += " --files-only"
}
command += " -- " + shellQuote(args.Pattern) + " " + path
ms := int(fileTimeout.Milliseconds())
cctx, cancel := context.WithTimeout(ctx, fileTimeout+replyGrace)
defer cancel()
reply, err := m.clients.Call(cctx, args.Client, api.ClientRequest{Op: api.Bash, Command: &command, TimeoutMs: &ms})
if err != nil {
return "", err
}
if reply.ExitCode == nil || reply.Output == nil {
return "", fmt.Errorf("%s's reply has no exit_code or output", args.Client)
}
if *reply.ExitCode != 0 {
return "", fmt.Errorf("byre grep on %s exited %d: %s", args.Client, *reply.ExitCode, *reply.Output)
}
return *reply.Output, nil
}
type clientHandsArgs struct {
Client string `json:"client"`
Tool string `json:"tool"`