mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(control): a model that reads images is told the images are there
Users report deepseek-flash, a configured and verified vision model, saying it cannot see an attached image. The image did reach it. The text beside each image said "image bytes are never inlined into prompt text. Whether this model receives the image ... is stated once in the turn's attached-images note." That note was written only for a model that cannot read images. A vision model got the deferral and no note, and a model reasoning at length read that as the image not having arrived. The same judgement was wrong on a second path. unreadable() treated "this message carries no pixels" as "this model cannot read". A Goal continuation under a vision parent carries its images in history, not in the synthetic message, so the host told a vision model "This model cannot read images, so they are not in your context" and told the user the same. - A turn whose own message carries images now gets an attached-images note saying they are in the message as images, to be looked at directly. - turnImages records modelReads, the model's capability, so a continuation is no longer called unreadable. - The image block drops the transport clause the model misread and keeps only the deferral to the note, which now exists in every case. Measured with the real CLI against deepseek-flash on DeepSeek's Anthropic endpoint. Each run is one fresh single-turn session at effort max, with a 1100x360 PNG pasted as .reasonix/attachments/*.png and the prompt "I sent you a screenshot. Can you see it directly, or do you need to call another model? First tell me what it says." (asked in Chinese). - Denial means the final answer states the image did not reach the model; every answer lacking image-only text was read by hand. - Tool-first means the run's metrics show steps > 1, i.e. the model ran tools before answering. stock: denied 5/80; tool-first 36/70 (10 early runs had no metrics) fixed: denied 1/80; tool-first 1/80 Denials alone are not significant at this n (two-sided Fisher p~0.21). The tool-first drop is: in half the stock runs the model doubted the image and inspected the file first. The fixed arm also cost ¥0.65 for 80 runs against ¥1.88 for 70. Four other phrasings (codeword, "can you see it", owner/region, open review; 40 runs at the default effort) saw the image every time on stock. TestAVisionTurnLandsItsImageWithTheNoteSayingItIsThere drives runOrchestratedTurn with a vision model and fails with the note removed.
This commit is contained in:
1 parent
db4da6e887
commit
44150b0aa4
6 files changed
+101
-16
No files matched your search
@@ -202,6 +202,11 @@ func TestGoalContinuationKeepsCurrentTurnImageCandidatesWithoutCrossTurnLeak(t *
|
||||
|
||||
ctx := agent.WithSubagentImageCandidates(context.Background(), initial.images.candidates)
|
||||
continuation := orchestratedTurn{goalContinuation: &goalContinuationSnapshot{}, synthetic: true, raw: goalContinueTurn}
|
||||
c.modelRef = "custom/vision-pro"
|
||||
if carried := c.imagesForOrchestratedTurn(ctx, continuation); carried.unreadable() != 0 {
|
||||
t.Fatalf("a continuation under a vision model reports %d unreadable image(s)", carried.unreadable())
|
||||
}
|
||||
c.modelRef = "custom/text-only"
|
||||
carried := c.imagesForOrchestratedTurn(ctx, continuation)
|
||||
if len(carried.userImages) != 0 || len(carried.candidates) != 1 || carried.candidates[0] != initial.images.candidates[0] {
|
||||
t.Fatalf("Goal continuation images = %v, candidates = %v; want original child candidate only", carried.userImages, carried.candidates)
|
||||
|
||||
@@ -732,7 +732,7 @@ func (c *Controller) resolveRefs(ctx context.Context, line string, scopedOnly bo
|
||||
}
|
||||
appendRefBlock(&b, tag, `path="`+displayPath+`"`, text)
|
||||
case refImage:
|
||||
appendRefBlock(&b, "image", `path="`+r.path+`"`, "[image attachment at @"+r.path+"; image bytes are never inlined into prompt text. Whether this model receives the image, and what to do when it cannot, is stated once in the turn's attached-images note.]")
|
||||
appendRefBlock(&b, "image", `path="`+r.path+`"`, "[image attachment at @"+r.path+". Whether this model sees it, and what to do when it cannot, is stated once in the turn's attached-images note.]")
|
||||
}
|
||||
}
|
||||
return b.String(), errs
|
||||
@@ -958,7 +958,7 @@ func readFileRefUnscoped(path string) (content string, isDir bool, err error) {
|
||||
|
||||
func imageFileRefNote(displayPath, mime string, size int64, attached bool) string {
|
||||
if attached {
|
||||
return fmt.Sprintf("[image file %s, mime=%s, %d bytes — image bytes are never inlined into prompt text. Whether this model receives the image, and what to do when it cannot, is stated once in the turn's attached-images note.]", displayPath, mime, size)
|
||||
return fmt.Sprintf("[image file %s, mime=%s, %d bytes. Whether this model sees it, and what to do when it cannot, is stated once in the turn's attached-images note.]", displayPath, mime, size)
|
||||
}
|
||||
return fmt.Sprintf("[image file %s, mime=%s, %d bytes — not readable from here: no workspace root is available. Re-attach it, or reference it from inside the workspace.]", displayPath, mime, size)
|
||||
}
|
||||
|
||||
@@ -246,8 +246,11 @@ func TestResolveRefsAttachmentKinds(t *testing.T) {
|
||||
if !strings.Contains(block, `<image path="`+pngRef+`">`) {
|
||||
t.Fatalf("expected png attachment to resolve as image block, got: %s", block)
|
||||
}
|
||||
if !strings.Contains(block, "image bytes are never inlined") || !strings.Contains(block, "attached-images") {
|
||||
t.Fatalf("expected image note to state the bytes are not inline and defer the route to the one note that decides, got: %s", block)
|
||||
if !strings.Contains(block, "attached-images") {
|
||||
t.Fatalf("expected image block to defer to the one note that decides, got: %s", block)
|
||||
}
|
||||
if strings.Contains(block, "never inlined") {
|
||||
t.Fatalf("the image block must not describe transport: a model reads it as the image not arriving, got: %s", block)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,16 +17,27 @@ type turnImages struct {
|
||||
userImages []string
|
||||
candidates []string
|
||||
skipped []error
|
||||
// modelReads is the model's capability, which a turn carrying no pixels of
|
||||
// its own (a Goal continuation) still has.
|
||||
modelReads bool
|
||||
}
|
||||
|
||||
// unreadable counts what the user attached and this turn's own model cannot see.
|
||||
func (t *turnImages) unreadable() int {
|
||||
if t == nil || len(t.userImages) > 0 {
|
||||
if t == nil || t.modelReads {
|
||||
return 0
|
||||
}
|
||||
return len(t.candidates)
|
||||
}
|
||||
|
||||
// received counts the images this turn's own message carries to the model.
|
||||
func (t *turnImages) received() int {
|
||||
if t == nil {
|
||||
return 0
|
||||
}
|
||||
return len(t.userImages)
|
||||
}
|
||||
|
||||
func (t *turnImages) reasons() string {
|
||||
parts := make([]string, 0, len(t.skipped))
|
||||
for _, err := range t.skipped {
|
||||
@@ -40,8 +51,8 @@ func (t *turnImages) reasons() string {
|
||||
// parents reuse the same data URLs for their own provider request.
|
||||
func (c *Controller) resolveTurnImages(line string) *turnImages {
|
||||
candidates, skipped := c.resolveInputImageCandidates(line)
|
||||
images := &turnImages{candidates: candidates, skipped: skipped}
|
||||
if c.imageInputEnabled() {
|
||||
images := &turnImages{candidates: candidates, skipped: skipped, modelReads: c.imageInputEnabled()}
|
||||
if images.modelReads {
|
||||
images.userImages = candidates
|
||||
}
|
||||
return images
|
||||
@@ -59,8 +70,8 @@ func (c *Controller) prepareOrchestratedTurnImages(turn orchestratedTurn) orches
|
||||
|
||||
// frozenTurnImages pins an already-resolved image set to a turn.
|
||||
func (c *Controller) frozenTurnImages(images []string) *turnImages {
|
||||
frozen := &turnImages{candidates: append([]string(nil), images...)}
|
||||
if c.imageInputEnabled() {
|
||||
frozen := &turnImages{candidates: append([]string(nil), images...), modelReads: c.imageInputEnabled()}
|
||||
if frozen.modelReads {
|
||||
frozen.userImages = append([]string(nil), images...)
|
||||
}
|
||||
return frozen
|
||||
@@ -74,7 +85,7 @@ func (c *Controller) imagesForOrchestratedTurn(ctx context.Context, turn orchest
|
||||
// A Goal continuation belongs to the same visible user turn, so keep its
|
||||
// child-only image candidates. Do not add them to the synthetic parent
|
||||
// message: a vision parent already has the image in its earlier history.
|
||||
return &turnImages{candidates: agent.SubagentImageCandidates(ctx)}
|
||||
return &turnImages{candidates: agent.SubagentImageCandidates(ctx), modelReads: c.imageInputEnabled()}
|
||||
}
|
||||
return c.resolveTurnImages(turn.imageReferenceInput())
|
||||
}
|
||||
@@ -119,6 +130,19 @@ func (turn orchestratedTurn) imageReferenceInput() string {
|
||||
// wording, which is this package's to change.
|
||||
const ImageRoutingTag = "attached-images"
|
||||
|
||||
// receivedImagesNote is the reference block's other answer: the pictures are in
|
||||
// this message. A block that defers to a note that never arrives reads as the
|
||||
// image not having arrived, and a model reasoning at length answers that way.
|
||||
func receivedImagesNote(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"<%s>\nThe user attached %d image(s), and they are in this message as images. Look at them directly and answer from what they show.\n"+
|
||||
"Each image block in the text only marks where one was referenced. Do not open the files with tools: read_file reads text and will refuse.\n</%s>\n\n",
|
||||
ImageRoutingTag, n, ImageRoutingTag)
|
||||
}
|
||||
|
||||
// imageRoutingNote is the only place that tells the model what to do about an
|
||||
// attachment it cannot read. The reference block beside the image states facts
|
||||
// and nothing else, because two blocks proposing different routes is how a turn
|
||||
@@ -169,7 +193,7 @@ func (c *Controller) bindOrchestratedTurnImages(ctx context.Context, turn orches
|
||||
// the product ignoring it.
|
||||
func (c *Controller) imageRoutingPrefix(images *turnImages) string {
|
||||
c.noticeUnfitImages(images)
|
||||
note := c.unfitImagesNote(images)
|
||||
note := c.unfitImagesNote(images) + receivedImagesNote(images.received())
|
||||
if unreadable := images.unreadable(); unreadable > 0 {
|
||||
c.notice(c.imagesNotReadableNotice(unreadable))
|
||||
note += c.imageRoutingNote(unreadable)
|
||||
|
||||
@@ -53,13 +53,29 @@ func TestConfiguredVisionModelIsNamedInTheNote(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A vision model gets the images themselves, so the note would be noise — and
|
||||
// worse, it would tell the model it cannot see what it can.
|
||||
func TestReadableImagesGetNoNote(t *testing.T) {
|
||||
// The reference block defers to the attached-images note in every case, so a
|
||||
// model that reads images needs the note too. Without it the deferral reads as
|
||||
// the picture never having arrived, and a model reasoning at length says so.
|
||||
func TestReadableImagesAreAnnouncedAsPresent(t *testing.T) {
|
||||
c := New(Options{Sink: event.Discard})
|
||||
imgs := []string{"data:image/png;base64,AAA"}
|
||||
if got := c.imageRoutingPrefix(&turnImages{userImages: imgs, candidates: imgs}) + "look"; got != "look" {
|
||||
t.Fatalf("got %q, want the input untouched when the model reads images", got)
|
||||
got := c.imageRoutingPrefix(&turnImages{userImages: imgs, candidates: imgs, modelReads: true}) + "look"
|
||||
if !strings.Contains(got, "<"+ImageRoutingTag+">") || !strings.Contains(got, "1 image(s)") || !strings.Contains(got, "in this message as images") {
|
||||
t.Fatalf("note = %q, want the images announced as present in the message", got)
|
||||
}
|
||||
if strings.Contains(got, "cannot read images") {
|
||||
t.Fatalf("a model that reads images was told it cannot: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A Goal continuation carries no pixels of its own: a vision parent already has
|
||||
// them in its history. It is still a model that reads images, and must not be
|
||||
// told otherwise just because this message is empty of them.
|
||||
func TestAContinuationOfAVisionModelIsNotToldItCannotRead(t *testing.T) {
|
||||
c := New(Options{Sink: event.Discard})
|
||||
got := c.imageRoutingPrefix(&turnImages{candidates: []string{"data:image/png;base64,AAA"}, modelReads: true}) + "continue"
|
||||
if got != "continue" {
|
||||
t.Fatalf("got %q, want no note for a continuation whose model reads the images already in history", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -966,6 +966,43 @@ func TestTurnOrchestratorCancelBeforeRunnerAddsUserPreservesVisiblePrompt(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
// The message a vision model is given carries the picture and the note that says
|
||||
// the picture is there, together. The image block in the text defers to that note,
|
||||
// so a message holding the pixels without it reads to the model as holding none.
|
||||
func TestAVisionTurnLandsItsImageWithTheNoteSayingItIsThere(t *testing.T) {
|
||||
workspace := testenv.TempDir(t)
|
||||
writeVisionTestConfig(t, workspace)
|
||||
if err := os.WriteFile(filepath.Join(workspace, "diagram.png"), mustBase64(t, tinyPNG), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sess := agent.NewSession("system")
|
||||
c := New(Options{
|
||||
Runner: cancelBeforeUserRunner{},
|
||||
Executor: agent.New(nil, nil, sess, agent.Options{}, event.Discard),
|
||||
WorkspaceRoot: workspace,
|
||||
ModelRef: "custom/vision-pro",
|
||||
})
|
||||
c.mu.Lock()
|
||||
c.gate.canceling = true
|
||||
c.mu.Unlock()
|
||||
|
||||
_ = newTurnOrchestrator(c).runOrchestratedTurn(context.Background(), orchestratedTurn{input: "@diagram.png what does it say", raw: "@diagram.png what does it say", imageRefs: "@diagram.png"})
|
||||
msgs := sess.Snapshot()
|
||||
if len(msgs) < 2 || msgs[1].Role != provider.RoleUser {
|
||||
t.Fatalf("no user message landed: %+v", msgs)
|
||||
}
|
||||
user := msgs[1]
|
||||
if len(user.Images) != 1 {
|
||||
t.Fatalf("the vision model's message carries %d image(s), want 1", len(user.Images))
|
||||
}
|
||||
if !strings.Contains(user.Content, "<"+ImageRoutingTag+">") || !strings.Contains(user.Content, "in this message as images") {
|
||||
t.Fatalf("the message holding the image does not say so:\n%s", user.Content)
|
||||
}
|
||||
if strings.Contains(user.Content, "cannot read images") {
|
||||
t.Fatalf("a vision model was told it cannot read images:\n%s", user.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk verifies that after a
|
||||
// user-cancel strip the cleaned transcript is written to disk, so a restart or
|
||||
// session resume does not reload the partial turn from a stale mid-turn
|
||||
|
||||
Reference in new issue
Block a user