Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 45 additions & 33 deletions arkruntime/toolset/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,34 @@ type ReadTool struct {
limits Limits
}

type filePathRequest struct {
FilePath string `json:"file_path"`
Path string `json:"path"`
File string `json:"file"`
}

func (r filePathRequest) resolvedPath() (string, error) {
path := ""
for _, candidate := range []string{r.FilePath, r.Path, r.File} {
if candidate == "" {
continue
}
if path != "" && path != candidate {
return "", errors.New("file_path, path, and file must not conflict")
}
path = candidate
}
if path == "" {
return "", errors.New("file_path is required")
}
return path, nil
}

type readRequest struct {
FilePath string `json:"file_path"`
Path string `json:"path"`
File string `json:"file"`
ViewRange []int `json:"view_range,omitempty"`
Offset *int `json:"offset,omitempty"`
Limit *int `json:"limit,omitempty"`
filePathRequest
ViewRange []int `json:"view_range,omitempty"`
Offset *int `json:"offset,omitempty"`
Limit *int `json:"limit,omitempty"`
}

// NewReadTool 创建 read 工具。
Expand Down Expand Up @@ -193,23 +214,6 @@ func (t *ReadTool) Execute(ctx context.Context, input json.RawMessage) Result {
return TextResult(b.String())
}

func (r readRequest) resolvedPath() (string, error) {
path := ""
for _, candidate := range []string{r.FilePath, r.Path, r.File} {
if candidate == "" {
continue
}
if path != "" && path != candidate {
return "", errors.New("file_path, path, and file must not conflict")
}
path = candidate
}
if path == "" {
return "", errors.New("file_path is required")
}
return path, nil
}

func detectReadMedia(path string) (string, string, error) {
f, err := os.Open(path)
if err != nil {
Expand Down Expand Up @@ -266,23 +270,27 @@ func (t *WriteTool) Name() string { return "write" }
// Execute 执行 write。
func (t *WriteTool) Execute(_ context.Context, input json.RawMessage) Result {
var req struct {
FilePath string `json:"file_path"`
Content string `json:"content"`
filePathRequest
Content string `json:"content"`
}
if err := decodeInput(input, &req); err != nil {
return ErrorResult(err.Error())
}
if t.limits.MaxInputFileBytes > 0 && int64(len(req.Content)) > t.limits.MaxInputFileBytes {
return ErrorResult(fmt.Sprintf("content too large: %d bytes", len(req.Content)))
}
host, err := t.resolver.ResolveForWrite(req.FilePath)
path, err := req.resolvedPath()
if err != nil {
return ErrorResult(err.Error())
}
host, err := t.resolver.ResolveForWrite(path)
if err != nil {
return ErrorResult(err.Error())
}
if err := os.MkdirAll(filepath.Dir(host), 0o755); err != nil {
return ErrorResult(err.Error())
}
verified, err := t.resolver.ResolveForWrite(req.FilePath)
verified, err := t.resolver.ResolveForWrite(path)
if err != nil {
return ErrorResult(err.Error())
}
Expand All @@ -292,7 +300,7 @@ func (t *WriteTool) Execute(_ context.Context, input json.RawMessage) Result {
if err := writeFileAtomically(host, []byte(req.Content), 0o600); err != nil {
return ErrorResult(err.Error())
}
return TextResult(fmt.Sprintf("wrote %d bytes to %s", len(req.Content), req.FilePath))
return TextResult(fmt.Sprintf("wrote %d bytes to %s", len(req.Content), path))
}

// EditTool 实现 edit 工具。
Expand All @@ -312,7 +320,7 @@ func (t *EditTool) Name() string { return "edit" }
// Execute 执行 edit。
func (t *EditTool) Execute(_ context.Context, input json.RawMessage) Result {
var req struct {
FilePath string `json:"file_path"`
filePathRequest
OldString string `json:"old_string"`
NewString string `json:"new_string"`
ReplaceAll bool `json:"replace_all,omitempty"`
Expand All @@ -323,11 +331,15 @@ func (t *EditTool) Execute(_ context.Context, input json.RawMessage) Result {
if req.OldString == "" {
return ErrorResult("old_string must not be empty")
}
host, err := t.resolver.ResolveExisting(req.FilePath)
path, err := req.resolvedPath()
if err != nil {
return ErrorResult(err.Error())
}
host, err := t.resolver.ResolveExisting(path)
if err != nil {
return ErrorResult(err.Error())
}
writable, err := t.resolver.ResolveForWrite(req.FilePath)
writable, err := t.resolver.ResolveForWrite(path)
if err != nil {
return ErrorResult(err.Error())
}
Expand Down Expand Up @@ -364,7 +376,7 @@ func (t *EditTool) Execute(_ context.Context, input json.RawMessage) Result {
if int64(len(next)) > t.limits.MaxInputFileBytes && t.limits.MaxInputFileBytes > 0 {
return ErrorResult(fmt.Sprintf("edited file too large: %d bytes", len(next)))
}
verified, err := t.resolver.ResolveForWrite(req.FilePath)
verified, err := t.resolver.ResolveForWrite(path)
if err != nil {
return ErrorResult(err.Error())
}
Expand All @@ -374,7 +386,7 @@ func (t *EditTool) Execute(_ context.Context, input json.RawMessage) Result {
if err := writeFileAtomically(host, []byte(next), info.Mode().Perm()); err != nil {
return ErrorResult(err.Error())
}
return TextResult(fmt.Sprintf("replaced %d occurrence(s) in %s", count, req.FilePath))
return TextResult(fmt.Sprintf("replaced %d occurrence(s) in %s", count, path))
}

func writeFileAtomically(target string, data []byte, mode os.FileMode) error {
Expand Down
67 changes: 67 additions & 0 deletions arkruntime/toolset/toolset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package toolset
import (
"context"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -277,6 +278,72 @@ func TestReadKeepsLegacyPathAliases(t *testing.T) {
}
}

func TestFileToolsAcceptPathAliases(t *testing.T) {
for _, key := range []string{"file_path", "path", "file"} {
t.Run(key, func(t *testing.T) {
root := t.TempDir()
set, err := NewDefault(Options{Workdir: root})
if err != nil {
t.Fatal(err)
}
defer set.Close()

writeInput := fmt.Sprintf(`{%q:"example.txt","content":"old"}`, key)
if res := set.Execute(context.Background(), "write", []byte(writeInput)); res.IsError {
t.Fatalf("write(%s) result = %+v", writeInput, res)
}
readInput := fmt.Sprintf(`{%q:"example.txt"}`, key)
if res := set.Execute(context.Background(), "read", []byte(readInput)); res.IsError {
t.Fatalf("read(%s) result = %+v", readInput, res)
}
editInput := fmt.Sprintf(
`{%q:"example.txt","old_string":"old","new_string":"new"}`,
key,
)
if res := set.Execute(context.Background(), "edit", []byte(editInput)); res.IsError {
t.Fatalf("edit(%s) result = %+v", editInput, res)
}
data, err := os.ReadFile(filepath.Join(root, "example.txt"))
if err != nil || string(data) != "new" {
t.Fatalf("edited data=%q err=%v", data, err)
}
})
}
}

func TestFileToolsValidatePathAliases(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "example.txt"), []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
set, err := NewDefault(Options{Workdir: root})
if err != nil {
t.Fatal(err)
}
defer set.Close()

for _, tool := range []string{"read", "write", "edit"} {
same := set.Execute(context.Background(), tool, []byte(
`{"file_path":"example.txt","path":"example.txt","file":"example.txt","content":"old","old_string":"old","new_string":"new"}`,
))
if same.IsError {
t.Fatalf("%s with matching aliases result = %+v", tool, same)
}
conflict := set.Execute(context.Background(), tool, []byte(
`{"file_path":"example.txt","path":"other.txt","content":"new","old_string":"old","new_string":"new"}`,
))
if !conflict.IsError || conflict.Content[0].Text != "file_path, path, and file must not conflict" {
t.Fatalf("%s conflicting aliases result = %+v", tool, conflict)
}
missing := set.Execute(context.Background(), tool, []byte(
`{"content":"new","old_string":"old","new_string":"new"}`,
))
if !missing.IsError || missing.Content[0].Text != "file_path is required" {
t.Fatalf("%s missing path result = %+v", tool, missing)
}
}
}

func TestReadReturnsImageAsBase64ContentBlock(t *testing.T) {
root := t.TempDir()
data := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 300<<10)...)
Expand Down
Loading