返回 DeepSeek-Reasonix
refs_resolution.go
根目录 / internal / control / refs_resolution.go
1 package control
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "html"
8 "os"
9 "path/filepath"
10 "strings"
11
12 "reasonix/internal/attachment"
13 )
14
15 // ResolveRefs resolves the @references in a line into a single tagged context
16 // block (file/dir contents, MCP resource bodies), plus per-reference errors.
17 func (c *Controller) ResolveRefs(ctx context.Context, line string) (block string, errs []string) {
18 resolved := c.resolveRefsForTurn(ctx, line, false)
19 return resolved.block, resolved.errs
20 }
21
22 // ResolveScopedRefs is the HTTP/frontend variant: file references are honored
23 // only when they can be resolved under the controller workspace root.
24 func (c *Controller) ResolveScopedRefs(ctx context.Context, line string) (block string, errs []string) {
25 resolved := c.resolveRefsForTurn(ctx, line, true)
26 return resolved.block, resolved.errs
27 }
28
29 type resolvedReferences struct {
30 block string
31 errs []string
32 images []string
33 imageErrs []ImageReferenceFailure
34 }
35
36 type preparedImageReferences struct {
37 byPath map[string]string
38 ordered []string
39 inputs []attachment.ImageInput
40 requiresImageUnderstanding bool
41 }
42
43 type preparedImageReferencesContextKey struct{}
44
45 func isAttachmentRef(token string) bool {
46 return strings.HasPrefix(filepath.ToSlash(token), ".reasonix/attachments/")
47 }
48
49 func isImageAttachmentRef(token string) bool {
50 switch strings.ToLower(filepath.Ext(token)) {
51 case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".tif", ".tiff":
52 return true
53 }
54 return false
55 }
56
57 func statAttachmentImage(root, path string) error {
58 if strings.TrimSpace(root) == "" {
59 root = "."
60 }
61 absRoot, err := filepath.Abs(root)
62 if err != nil {
63 return err
64 }
65 joined := filepath.Join(absRoot, filepath.FromSlash(path))
66 confine := filepath.Join(absRoot, ".reasonix", "attachments")
67 rel, err := filepath.Rel(confine, joined)
68 if err != nil || strings.HasPrefix(rel, "..") {
69 return fmt.Errorf("image path is outside .reasonix/attachments")
70 }
71 info, err := os.Lstat(joined)
72 if err != nil {
73 return err
74 }
75 if info.Mode()&os.ModeSymlink != 0 {
76 return fmt.Errorf("image path must not be a symlink")
77 }
78 if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxImageAttachmentBytes {
79 return fmt.Errorf("pasted image must be between 1 byte and 64 MB")
80 }
81 return nil
82 }
83
84 func normalizedImageReferencePath(path string) string {
85 return filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
86 }
87
88 func contextWithPreparedImageReferences(ctx context.Context, prepared preparedImageReferences) context.Context {
89 if len(prepared.byPath) == 0 && len(prepared.inputs) == 0 {
90 return ctx
91 }
92 return context.WithValue(ctx, preparedImageReferencesContextKey{}, prepared)
93 }
94
95 func preparedImageReference(ctx context.Context, path string) (string, bool) {
96 prepared, _ := ctx.Value(preparedImageReferencesContextKey{}).(preparedImageReferences)
97 value, ok := prepared.byPath[normalizedImageReferencePath(path)]
98 return value, ok
99 }
100
101 func (c *Controller) prepareExplicitImageReferences(line string) (preparedImageReferences, []ImageReferenceFailure) {
102 return c.prepareExplicitImageReferencesContext(c.attachmentContext(), line)
103 }
104
105 func (c *Controller) prepareExplicitImageReferencesContext(ctx context.Context, line string) (preparedImageReferences, []ImageReferenceFailure) {
106 return c.prepareSubmissionImagesContext(ctx, SubmissionRequest{Input: line})
107 }
108
109 func (c *Controller) explicitImageSources(line string) []attachment.Source {
110 var sources []attachment.Source
111 seen := map[string]bool{}
112 for _, token := range parseRefTokens(line) {
113 if !isAttachmentRef(token) || !isImageAttachmentRef(token) || strings.HasPrefix(token, "draft:") {
114 continue
115 }
116 key := normalizedImageReferencePath(token)
117 if seen[key] {
118 continue
119 }
120 seen[key] = true
121 sources = append(sources, attachment.Source{DisplayName: filepath.Base(filepath.FromSlash(token)), Path: token, WorkspaceRoot: c.workspaceRoot, Confine: ".reasonix/attachments"})
122 }
123 return sources
124 }
125
126 func imageFailuresFromAttachment(err error) []ImageReferenceFailure {
127 var batch attachment.BatchError
128 if errors.As(err, &batch) {
129 out := make([]ImageReferenceFailure, 0, len(batch))
130 for _, item := range batch {
131 out = append(out, ImageReferenceFailure{Code: mapAttachmentCode(item.Code), Name: attachment.NormalizeDisplayName(item.Name), Index: item.Index, Cause: item})
132 }
133 return out
134 }
135 var item attachment.Error
136 if errors.As(err, &item) {
137 return []ImageReferenceFailure{{Code: mapAttachmentCode(item.Code), Name: attachment.NormalizeDisplayName(item.Name), Index: item.Index, Cause: item}}
138 }
139 return []ImageReferenceFailure{imageReferenceFailure("image", err)}
140 }
141
142 func mapAttachmentCode(code attachment.Code) ImageReferenceFailureCode {
143 switch code {
144 case attachment.CodeMissing:
145 return ImageReferenceMissing
146 case attachment.CodeUnreadable:
147 return ImageReferenceUnreadable
148 case attachment.CodeUnsafe:
149 return ImageReferenceUnsafe
150 case attachment.CodeUnsupported:
151 return ImageReferenceUnsupported
152 case attachment.CodeCorrupt:
153 return ImageReferenceCorrupt
154 case attachment.CodeSize, attachment.CodeTooMany, attachment.CodeBatchSize:
155 return ImageReferenceTooLarge
156 case attachment.CodeChanged:
157 return ImageReferenceChanged
158 case attachment.CodeCanceled:
159 return ImageReferenceCanceled
160 default:
161 return ImageReferenceUnreadable
162 }
163 }
164
165 type ImageReferenceFailureCode string
166
167 const (
168 ImageReferenceMissing ImageReferenceFailureCode = "missing"
169 ImageReferenceUnreadable ImageReferenceFailureCode = "unreadable"
170 ImageReferenceUnsafe ImageReferenceFailureCode = "unsafe_path"
171 ImageReferenceUnsupported ImageReferenceFailureCode = "unsupported_format"
172 ImageReferenceTooLarge ImageReferenceFailureCode = "size_limit"
173 ImageReferenceChanged ImageReferenceFailureCode = "changed"
174 ImageReferenceCorrupt ImageReferenceFailureCode = "corrupt"
175 ImageReferenceCanceled ImageReferenceFailureCode = "canceled"
176 )
177
178 // ImageReferenceFailure is safe to return across UI/RPC boundaries: Name is
179 // only the attachment basename and Cause is available to local diagnostics.
180 type ImageReferenceFailure struct {
181 Code ImageReferenceFailureCode
182 Name string
183 Index int
184 Cause error
185 }
186
187 func (e ImageReferenceFailure) Error() string {
188 name := strings.TrimSpace(e.Name)
189 if name == "" {
190 name = "image"
191 }
192 detail := "could not be read"
193 switch e.Code {
194 case ImageReferenceMissing:
195 detail = "does not exist"
196 case ImageReferenceUnsafe:
197 detail = "has an unsafe path"
198 case ImageReferenceUnsupported:
199 detail = "is not an image or uses an unsupported format"
200 case ImageReferenceTooLarge:
201 detail = "must be between 1 byte and 64 MB"
202 case ImageReferenceChanged:
203 detail = "changed while it was being read"
204 case ImageReferenceCorrupt:
205 detail = "is damaged"
206 case ImageReferenceCanceled:
207 detail = "was canceled"
208 }
209 return fmt.Sprintf("image attachment %q %s; remove and re-add it, then retry (%s)", name, detail, e.Code)
210 }
211
212 func (e ImageReferenceFailure) Unwrap() error { return e.Cause }
213
214 type ImageReferenceFailures []ImageReferenceFailure
215
216 func (e ImageReferenceFailures) Error() string {
217 if len(e) == 0 {
218 return "image attachments could not be read"
219 }
220 parts := make([]string, 0, len(e))
221 for _, failure := range e {
222 parts = append(parts, failure.Error())
223 }
224 return strings.Join(parts, "; ")
225 }
226
227 func imageReferenceFailure(path string, err error) ImageReferenceFailure {
228 code := ImageReferenceUnreadable
229 message := strings.ToLower(err.Error())
230 switch {
231 case errors.Is(err, os.ErrNotExist):
232 code = ImageReferenceMissing
233 case errors.Is(err, os.ErrPermission):
234 code = ImageReferenceUnreadable
235 case strings.Contains(message, "outside .reasonix/attachments") || strings.Contains(message, "symlink") || strings.Contains(message, "must be relative"):
236 code = ImageReferenceUnsafe
237 case strings.Contains(message, "between 1 byte") || strings.Contains(message, "too large"):
238 code = ImageReferenceTooLarge
239 case strings.Contains(message, "not an image") || strings.Contains(message, "unsupported"):
240 code = ImageReferenceUnsupported
241 case strings.Contains(message, "changed while"):
242 code = ImageReferenceChanged
243 }
244 return ImageReferenceFailure{Code: code, Name: filepath.Base(filepath.FromSlash(path)), Cause: err}
245 }
246
247 // ImageReferenceFailureForPath converts a local read failure into the safe,
248 // display-name-only error shape used at submission boundaries.
249 func ImageReferenceFailureForPath(path string, err error) ImageReferenceFailure {
250 return imageReferenceFailure(path, err)
251 }
252
253 func (c *Controller) resolveUnscopedRefsForTurn(ctx context.Context, line string) resolvedReferences {
254 return c.resolveRefsForTurn(ctx, line, false)
255 }
256
257 func (c *Controller) resolveScopedRefsForTurn(ctx context.Context, line string) resolvedReferences {
258 return c.resolveRefsForTurn(ctx, line, true)
259 }
260
261 func (c *Controller) resolveRefsForTurn(ctx context.Context, line string, scopedOnly bool) resolvedReferences {
262 refs := resolveBareNames(c.detectRefsMode(line, scopedOnly), c.workspaceRoot)
263 var b strings.Builder
264 var errs, images []string
265 var imageErrs []ImageReferenceFailure
266 seenImages := map[string]bool{}
267 addImage := func(r ref) (string, bool) {
268 if r.kind == refImage && isAttachmentRef(r.path) {
269 if _, frozen := preparedImageReference(ctx, r.path); frozen {
270 return r.path, true
271 }
272 root := c.workspaceRoot
273 if r.baseDir != "" {
274 root = r.baseDir
275 }
276 if err := statAttachmentImage(root, r.path); err != nil {
277 failure := imageReferenceFailure(r.path, err)
278 imageErrs = append(imageErrs, failure)
279 errs = append(errs, "@"+r.raw+" — "+failure.Error())
280 return "", false
281 }
282 return r.path, true
283 }
284 value, frozen := preparedImageReference(ctx, r.path)
285 var err error
286 if !frozen {
287 value, err = c.resolveReferenceImage(r)
288 }
289 if err != nil {
290 if r.kind == refImage {
291 failure := imageReferenceFailure(r.path, err)
292 imageErrs = append(imageErrs, failure)
293 errs = append(errs, "@"+r.raw+" — "+failure.Error())
294 } else {
295 errs = append(errs, "@"+r.raw+" — "+err.Error())
296 }
297 return "", false
298 }
299 if value == "" {
300 errs = append(errs, "@"+r.raw+" — image reference resolved to an empty input")
301 return "", false
302 }
303 if !seenImages[value] {
304 seenImages[value] = true
305 images = append(images, value)
306 }
307 return value, true
308 }
309 includedInstructionPaths := map[string]bool{}
310 includedInstructionBodies := map[string]bool{}
311 if current := c.memory.current(); current != nil {
312 for _, doc := range current.Docs {
313 includedInstructionPaths[cleanAbsPath(doc.Path)] = true
314 includedInstructionBodies[doc.Body] = true
315 }
316 }
317 for _, r := range refs {
318 switch r.kind {
319 case refResource:
320 text, err := c.mcp.readResource(ctx, r.server, r.uri)
321 if err != nil {
322 errs = append(errs, "@"+r.raw+" — "+err.Error())
323 continue
324 }
325 appendRefBlock(&b, "resource", `ref="@`+r.raw+`"`, text)
326 case refFile:
327 baseDir := c.workspaceRoot
328 if r.baseDir != "" {
329 baseDir = r.baseDir
330 }
331 attached := false
332 if isImageAttachmentRef(r.path) {
333 _, attached = addImage(r)
334 }
335 text, isDir, err := readFileRefWithVision(r.path, baseDir, attached && c.imageInputEnabled())
336 if err != nil {
337 errs = append(errs, "@"+r.raw+" — "+err.Error())
338 continue
339 }
340 pathInstructions, diagnostics := c.resolveReferencedInstructions(r, baseDir, includedInstructionPaths, includedInstructionBodies)
341 if pathInstructions != "" {
342 appendRefBlock(&b, "path-instructions", `target="`+html.EscapeString(displayPathForRef(r))+`"`, pathInstructions)
343 }
344 for _, diagnostic := range diagnostics {
345 errs = append(errs, "@"+r.raw+" — "+diagnostic.Message)
346 }
347 tag := "file"
348 if isDir {
349 tag = "dir"
350 }
351 displayPath := r.path
352 if r.displayPath != "" {
353 displayPath = r.displayPath
354 }
355 appendRefBlock(&b, tag, `path="`+displayPath+`"`, text)
356 case refImage, refRemoteImage, refFileID:
357 if _, attached := addImage(r); attached {
358 appendRefBlock(&b, "image", `path="`+r.path+`"`, imageAttachmentNote(r.path, c.imageInputEnabled()))
359 }
360 }
361 }
362 for _, r := range bareVisionRefs(line) {
363 addImage(r)
364 }
365 return resolvedReferences{block: b.String(), errs: errs, images: images, imageErrs: imageErrs}
366 }
367
367 lines GO