返回 DeepSeek-Reasonix
present.go
根目录 / internal / tool / builtin / present.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "reasonix/internal/tool"
12 )
13
14 type presentFile struct {
15 Path string `json:"path"`
16 Description string `json:"description,omitempty"`
17 }
18
19 type presentParams struct {
20 Files []presentFile `json:"files"`
21 }
22
23 // present declares existing files as user-facing deliverables. It reads only
24 // file metadata; the desktop/remote host resolves the recorded resource again
25 // when the user opens it.
26 type present struct {
27 workDir string
28 paths *PathResolver
29 forbidRoots []string
30 }
31
32 func init() { tool.RegisterBuiltin(present{}) }
33
34 func (present) Name() string { return "present" }
35
36 func (present) Description() string {
37 return "Present files that were generated or updated as user-facing deliverables. Call this after writing the files and before the final answer, including files created through bash or code execution. Mentioning a path only in the answer does not replace this call. In the final answer, refer to each presented file by its exact path or unique basename in inline code so the host can attach its open action. Each path must identify an existing regular file accessible to this session; accepts 1 to 8 files."
38 }
39
40 func (present) Schema() json.RawMessage {
41 return json.RawMessage(`{"type":"object","properties":{"files":{"type":"array","minItems":1,"maxItems":8,"items":{"type":"object","properties":{"path":{"type":"string","minLength":1,"description":"Existing file path, relative to the session workspace or an authorized absolute path"},"description":{"type":"string","description":"Short user-facing description of the file"}},"required":["path"],"additionalProperties":false}}},"required":["files"],"additionalProperties":false}`)
42 }
43
44 func (present) ReadOnly() bool { return true }
45 func (present) PlanModeSafe() bool { return false }
46
47 func (present) SnipHint() tool.SnipHint {
48 return tool.SnipHint{Head: 16, Tail: 2, HeadChars: 4000, TailChars: 500}
49 }
50
51 func (p present) Execute(ctx context.Context, args json.RawMessage) (string, error) {
52 var params presentParams
53 if err := json.Unmarshal(args, &params); err != nil {
54 return "", fmt.Errorf("invalid args: %w", err)
55 }
56 if len(params.Files) < 1 || len(params.Files) > 8 {
57 return "", fmt.Errorf("files must contain between 1 and 8 entries")
58 }
59 if err := ctx.Err(); err != nil {
60 return "", err
61 }
62
63 validated := make([]tool.PresentedFile, 0, len(params.Files))
64 for index, file := range params.Files {
65 path := strings.TrimSpace(file.Path)
66 if path == "" {
67 return "", fmt.Errorf("files[%d].path is required", index)
68 }
69 rp := resolveReadablePath(p.workDir, path, p.paths)
70 // Resolve the deny roots and the candidate at the same boundary. This is
71 // required on macOS where /var and /private/var may name the same bytes,
72 // and prevents a symlinked parent from bypassing a configured deny root.
73 if ReadPathForbidden(p.forbidRoots, rp.Path) {
74 return "", fmt.Errorf("present %s: file not found", rp.DisplayPath)
75 }
76 info, err := os.Lstat(rp.Path)
77 if err != nil {
78 return "", fmt.Errorf("present %s: %s", rp.DisplayPath, rp.ErrorText(err))
79 }
80 if info.Mode()&os.ModeSymlink != 0 {
81 return "", fmt.Errorf("present %s: final path must not be a symbolic link", rp.DisplayPath)
82 }
83 if !info.Mode().IsRegular() {
84 return "", fmt.Errorf("present %s: path is not a regular file", rp.DisplayPath)
85 }
86 var recordedPath string
87 if filepath.IsAbs(path) {
88 recordedPath = filepath.Clean(path)
89 } else {
90 recordedPath = filepath.ToSlash(filepath.Clean(path))
91 }
92 validated = append(validated, tool.PresentedFile{
93 Path: recordedPath, Description: strings.TrimSpace(file.Description),
94 })
95 }
96
97 tool.RecordPresentedFiles(ctx, validated)
98 var result strings.Builder
99 for _, file := range validated {
100 fmt.Fprintf(&result, "Presented %s\n", file.Path)
101 }
102 return strings.TrimSuffix(result.String(), "\n"), nil
103 }
104
104 lines GO