| 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, ¶ms); 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 |