-
Notifications
You must be signed in to change notification settings - Fork 13
feat(llm): ChatMessage.Parts multimodal scaffolding + history references (#255 Phase 1) #528
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| package llm | ||
|
|
||
| import ( | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| // TestChatMessage_TextOnlyWireIsUnchanged pins the back-compat invariant: a | ||
| // text-only ChatMessage (no Parts) marshals byte-identically to the pre-#255 | ||
| // shape — no "parts" key — so existing sessions, prompt-cache prefixes, and | ||
| // provider requests are untouched. | ||
| func TestChatMessage_TextOnlyWireIsUnchanged(t *testing.T) { | ||
| b, err := json.Marshal(ChatMessage{Role: RoleUser, Content: "hello"}) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| got := string(b) | ||
| if want := `{"role":"user","content":"hello"}`; got != want { | ||
| t.Errorf("text-only wire = %s, want %s", got, want) | ||
| } | ||
| if strings.Contains(got, "parts") { | ||
| t.Errorf("empty Parts must be omitted from the wire; got %s", got) | ||
| } | ||
| } | ||
|
|
||
| // TestMediaRef_BytesNeverSerialized is the #255 history-bloat guard: MediaRef.Bytes | ||
| // is json:"-", so marshaling a message with media never writes the inline bytes — | ||
| // only the URI reference survives. This is what keeps session history from | ||
| // re-persisting (and re-sending) base64 media every turn. | ||
| func TestMediaRef_BytesNeverSerialized(t *testing.T) { | ||
| msg := ChatMessage{ | ||
| Role: RoleUser, | ||
| Content: "look at this", | ||
| Parts: []ContentPart{ | ||
| NewTextContentPart("look at this"), | ||
| NewMediaContentPart(ContentPartImage, MediaRef{ | ||
| MimeType: "image/png", | ||
| URI: ".forge/files/inbound/photo.png", | ||
| Bytes: []byte("SUPER-SECRET-RAW-IMAGE-BYTES"), | ||
| }), | ||
| }, | ||
| } | ||
| b, err := json.Marshal(msg) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| got := string(b) | ||
| if strings.Contains(got, "SUPER-SECRET-RAW-IMAGE-BYTES") { | ||
| t.Errorf("inline Bytes leaked into the serialized message:\n%s", got) | ||
| } | ||
| if b64 := base64.StdEncoding.EncodeToString([]byte("SUPER-SECRET-RAW-IMAGE-BYTES")); strings.Contains(got, b64) { | ||
| t.Errorf("inline Bytes leaked as base64 into the serialized message:\n%s", got) | ||
| } | ||
| if !strings.Contains(got, ".forge/files/inbound/photo.png") { | ||
| t.Errorf("URI reference should survive serialization; got %s", got) | ||
| } | ||
|
|
||
| // Round-trip: unmarshal keeps the URI, drops the bytes (rehydration is a | ||
| // separate step). | ||
| var back ChatMessage | ||
| if err := json.Unmarshal(b, &back); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if !back.HasMedia() { | ||
| t.Fatal("round-tripped message should still report HasMedia") | ||
| } | ||
| if got := back.Parts[1].Media.URI; got != ".forge/files/inbound/photo.png" { | ||
| t.Errorf("round-tripped URI = %q", got) | ||
| } | ||
| if len(back.Parts[1].Media.Bytes) != 0 { | ||
| t.Errorf("round-tripped Bytes must be empty (json:\"-\"); got %d bytes", len(back.Parts[1].Media.Bytes)) | ||
| } | ||
| } | ||
|
|
||
| // TestChatMessage_HasMedia covers the media predicate. | ||
| func TestChatMessage_HasMedia(t *testing.T) { | ||
| if (ChatMessage{Content: "x"}).HasMedia() { | ||
| t.Error("text-only message must not report media") | ||
| } | ||
| textParts := ChatMessage{Parts: []ContentPart{NewTextContentPart("x")}} | ||
| if textParts.HasMedia() { | ||
| t.Error("text-only parts must not report media") | ||
| } | ||
| withImage := ChatMessage{Parts: []ContentPart{NewMediaContentPart(ContentPartImage, MediaRef{MimeType: "image/png"})}} | ||
| if !withImage.HasMedia() { | ||
| t.Error("message with an image part must report media") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| package runtime | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
|
|
||
| "github.com/initializ/forge/forge-core/llm" | ||
| ) | ||
|
|
||
| // RehydrateMedia fills the inline Bytes of every image/document ContentPart in | ||
| // msgs from its persisted URI reference, reading each file confined to the | ||
| // context's files dir (WithFilesDir). It is the read side of the #255 | ||
| // history-reference design: history persists only URIs (llm.MediaRef.Bytes is | ||
| // json:"-" so it never bloats the session file or re-sends on replay), so the | ||
| // executor calls RehydrateMedia to reload the bytes before building a provider | ||
| // ChatRequest. | ||
| // | ||
| // Parts whose Bytes are already populated are left untouched (freshly ingested | ||
| // media not yet round-tripped through history). A URI that escapes the files | ||
| // dir, or that cannot be read, yields an error naming the offending part — | ||
| // callers decide whether that is fatal or degrades to a text reference. | ||
| func RehydrateMedia(ctx context.Context, msgs []llm.ChatMessage) error { | ||
| for mi := range msgs { | ||
| for pi := range msgs[mi].Parts { | ||
| media := msgs[mi].Parts[pi].Media | ||
| if media == nil || len(media.Bytes) > 0 || media.URI == "" { | ||
| continue | ||
| } | ||
| path, ok := confinedFilesPath(ctx, media.URI) | ||
| if !ok { | ||
| return fmt.Errorf("rehydrate media: uri %q escapes the files dir", media.URI) | ||
| } | ||
| // confinedFilesPath is lexical (Abs+Rel) — it stops ../ and | ||
| // absolute traversal but does not resolve symlinks, so a symlink | ||
| // INSIDE the files dir pointing outward would otherwise be followed | ||
| // by the read below. Resolve symlinks on both the target and the | ||
| // files dir (the dir itself may sit under a symlink, e.g. macOS | ||
| // /tmp -> /private/tmp) and re-confine before reading. This matters | ||
| // once inbound (untrusted) files are persisted here (#255 Phase 4). | ||
| realPath, err := resolveWithinFilesDir(ctx, path) | ||
| if err != nil { | ||
| return fmt.Errorf("rehydrate media %q: %w", media.URI, err) | ||
| } | ||
| b, err := os.ReadFile(realPath) | ||
| if err != nil { | ||
| return fmt.Errorf("rehydrate media %q: %w", media.URI, err) | ||
| } | ||
| media.Bytes = b | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // resolveWithinFilesDir resolves symlinks in path and confirms the real target | ||
| // still lives inside the (symlink-resolved) files dir. It complements the | ||
| // lexical confinedFilesPath: a symlink placed inside the files dir that points | ||
| // outward passes the lexical check but is caught here. Returns the resolved | ||
| // path to read, or an error if the target escapes the dir (or cannot be | ||
| // resolved — e.g. it does not exist). | ||
| func resolveWithinFilesDir(ctx context.Context, path string) (string, error) { | ||
| realPath, err := filepath.EvalSymlinks(path) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| realDir, err := filepath.EvalSymlinks(FilesDirFromContext(ctx)) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| rel, err := filepath.Rel(realDir, realPath) | ||
| if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { | ||
| return "", fmt.Errorf("resolved path escapes the files dir") | ||
| } | ||
| return realPath, nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| package runtime | ||
|
|
||
| import ( | ||
| "context" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/initializ/forge/forge-core/llm" | ||
| ) | ||
|
|
||
| // TestRehydrateMedia_LoadsBytesFromURI is the read side of the #255 | ||
| // history-reference design: a persisted message carries only a URI (Bytes | ||
| // dropped by json:"-"); RehydrateMedia reloads the bytes from the files dir | ||
| // before the message is handed to a provider. | ||
| func TestRehydrateMedia_LoadsBytesFromURI(t *testing.T) { | ||
| dir := t.TempDir() | ||
| want := []byte("the-real-image-bytes") | ||
| uri := filepath.Join(dir, "photo.png") | ||
| if err := os.WriteFile(uri, want, 0o600); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| ctx := WithFilesDir(context.Background(), dir) | ||
|
|
||
| msgs := []llm.ChatMessage{{ | ||
| Role: llm.RoleUser, | ||
| Parts: []llm.ContentPart{llm.NewMediaContentPart(llm.ContentPartImage, llm.MediaRef{MimeType: "image/png", URI: uri})}, | ||
| }} | ||
| if err := RehydrateMedia(ctx, msgs); err != nil { | ||
| t.Fatalf("RehydrateMedia: %v", err) | ||
| } | ||
| if got := msgs[0].Parts[0].Media.Bytes; string(got) != string(want) { | ||
| t.Errorf("rehydrated bytes = %q, want %q", got, want) | ||
| } | ||
| } | ||
|
|
||
| // TestRehydrateMedia_AlreadyPopulatedIsUntouched: freshly ingested media that | ||
| // hasn't round-tripped through history keeps its bytes and is not re-read. | ||
| func TestRehydrateMedia_AlreadyPopulatedIsUntouched(t *testing.T) { | ||
| ctx := WithFilesDir(context.Background(), t.TempDir()) | ||
| msgs := []llm.ChatMessage{{ | ||
| Role: llm.RoleUser, | ||
| Parts: []llm.ContentPart{llm.NewMediaContentPart(llm.ContentPartImage, llm.MediaRef{MimeType: "image/png", URI: "/does/not/exist.png", Bytes: []byte("inline")})}, | ||
| }} | ||
| if err := RehydrateMedia(ctx, msgs); err != nil { | ||
| t.Fatalf("RehydrateMedia should skip already-populated bytes, got %v", err) | ||
| } | ||
| if got := string(msgs[0].Parts[0].Media.Bytes); got != "inline" { | ||
| t.Errorf("bytes = %q, want inline (unchanged)", got) | ||
| } | ||
| } | ||
|
|
||
| // TestRehydrateMedia_RejectsPathEscape: a URI outside the files dir must be | ||
| // refused, not read — the same confinement fileArtifactFromToolResult uses. | ||
| func TestRehydrateMedia_RejectsPathEscape(t *testing.T) { | ||
| dir := t.TempDir() | ||
| // A secret sitting outside the files dir. | ||
| secret := filepath.Join(t.TempDir(), "secret.txt") | ||
| if err := os.WriteFile(secret, []byte("nope"), 0o600); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| ctx := WithFilesDir(context.Background(), dir) | ||
| msgs := []llm.ChatMessage{{ | ||
| Role: llm.RoleUser, | ||
| Parts: []llm.ContentPart{llm.NewMediaContentPart(llm.ContentPartImage, llm.MediaRef{URI: secret})}, | ||
| }} | ||
| if err := RehydrateMedia(ctx, msgs); err == nil { | ||
| t.Fatal("a URI escaping the files dir must be rejected") | ||
| } | ||
| if len(msgs[0].Parts[0].Media.Bytes) != 0 { | ||
| t.Error("bytes must not be populated from an out-of-confinement path") | ||
| } | ||
| } | ||
|
|
||
| // TestRehydrateMedia_RejectsSymlinkEscape: a symlink placed INSIDE the files | ||
| // dir that points outward passes the lexical confinement but must be caught by | ||
| // the symlink resolution — the Phase-4 hardening from the #528 review. | ||
| func TestRehydrateMedia_RejectsSymlinkEscape(t *testing.T) { | ||
| dir := t.TempDir() | ||
| // A secret outside the files dir. | ||
| outside := filepath.Join(t.TempDir(), "secret.txt") | ||
| if err := os.WriteFile(outside, []byte("nope"), 0o600); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| // A symlink INSIDE the files dir pointing at it — lexically confined | ||
| // (the link path is under dir), but resolves outward. | ||
| link := filepath.Join(dir, "link.png") | ||
| if err := os.Symlink(outside, link); err != nil { | ||
| t.Skipf("symlinks unsupported on this platform: %v", err) | ||
| } | ||
| ctx := WithFilesDir(context.Background(), dir) | ||
| msgs := []llm.ChatMessage{{ | ||
| Role: llm.RoleUser, | ||
| Parts: []llm.ContentPart{llm.NewMediaContentPart(llm.ContentPartImage, llm.MediaRef{URI: link})}, | ||
| }} | ||
| if err := RehydrateMedia(ctx, msgs); err == nil { | ||
| t.Fatal("a symlink inside the files dir pointing outward must be rejected") | ||
| } | ||
| if len(msgs[0].Parts[0].Media.Bytes) != 0 { | ||
| t.Error("bytes must not be populated from a symlink escaping the files dir") | ||
| } | ||
| } | ||
|
|
||
| // TestMemory_MediaCountsTowardBudget: media parts carry no Content chars but | ||
| // must charge the budget, so an image-heavy history still trims (#255). | ||
| func TestMemory_MediaCountsTowardBudget(t *testing.T) { | ||
| imagePart := llm.NewMediaContentPart(llm.ContentPartImage, llm.MediaRef{MimeType: "image/png", URI: "a.png"}) | ||
|
|
||
| withMedia := NewMemory("", 0, "") | ||
| withMedia.Append(llm.ChatMessage{Role: llm.RoleUser, Content: "hi", Parts: []llm.ContentPart{imagePart}}) | ||
| textOnly := NewMemory("", 0, "") | ||
| textOnly.Append(llm.ChatMessage{Role: llm.RoleUser, Content: "hi"}) | ||
|
|
||
| if withMedia.totalChars() <= textOnly.totalChars() { | ||
| t.Errorf("a message with a media part must charge more budget than text-only: media=%d text=%d", | ||
| withMedia.totalChars(), textOnly.totalChars()) | ||
| } | ||
| if got := withMedia.totalChars() - textOnly.totalChars(); got != mediaCharWeight { | ||
| t.Errorf("media budget delta = %d, want mediaCharWeight (%d)", got, mediaCharWeight) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Forward-looking note — LOW (Phase 4, not this PR):
confinedFilesPathis lexical (no symlink resolution). It's sound against..//absolute traversal (Abs+Rel), but it doesn'tEvalSymlinks— so a symlink inside the files dir pointing outward would be followed by theos.ReadFilebelow. Not exploitable here (Phase 1 populates no URIs from untrusted input, and this is the pre-existing shared file-tool confinement), but when Phase 4 persists inbound (untrusted) files to.forge/files, write them with generated names that can't be symlinks — or addEvalSymlinksto the confinement. Added to the #255 checklist as a Phase-4 item.