Skip to content

Commit caa056f

Browse files
authored
feat: move serialization out of the payload package (#32)
Previously, the user message string was built in the payload package. This created a tight coupling between the calling code and the specific prompt format expected by the LLM. With this change, the responsibility of serializing the request data into a user message is moved to the individual LLM provider implementations. This is achieved by introducing new request structs in the package, which are then passed to the providers. This refactoring improves the separation of concerns, making the code more modular and easier to maintain and extend.
1 parent beb759e commit caa056f

File tree

12 files changed

+864
-522
lines changed

12 files changed

+864
-522
lines changed

cmd/template/template.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ func execute(cmd *cobra.Command, args []string, def *Definition) error {
199199
}
200200
}
201201

202-
userMsg, err := buildExtendedUserMessage(rootFS, meta, ec, files)
202+
userRequest, err := buildWorkspaceChangeRequest(rootFS, meta, ec, files)
203203
if err != nil {
204204
return err
205205
}
@@ -217,7 +217,7 @@ func execute(cmd *cobra.Command, args []string, def *Definition) error {
217217

218218
systemMessage := rendered
219219

220-
proposal, err := llm.GetWorkspaceChangeProposals(cfg, def.Model.Family, def.Model.Size, systemMessage, userMsg)
220+
proposal, err := llm.GetWorkspaceChangeProposals(cfg, def.Model.Family, def.Model.Size, systemMessage, userRequest)
221221
if err != nil {
222222
return err
223223
}

cmd/template/user_msg_builder.go

Lines changed: 62 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,21 @@ import (
1111
"github.com/vybdev/vyb/workspace/project"
1212
)
1313

14-
// buildExtendedUserMessage composes the user-message payload that will be
14+
// buildWorkspaceChangeRequest composes a payload.WorkspaceChangeRequest that will be
1515
// sent to the LLM. It prepends module context information — as dictated
16-
// by the specification — before the raw file contents. When metadata is
17-
// nil or when any contextual information is missing the function falls
18-
// back gracefully, emitting only what is available.
19-
func buildExtendedUserMessage(rootFS fs.FS, meta *project.Metadata, ec *context.ExecutionContext, filePaths []string) (string, error) {
20-
// If metadata is missing we revert to the original behaviour – emit
21-
// just the files.
22-
if meta == nil || meta.Modules == nil {
23-
return payload.BuildUserMessage(rootFS, filePaths)
16+
// by the specification — before the raw file contents. Both meta and
17+
// meta.Modules must be non-nil.
18+
func buildWorkspaceChangeRequest(rootFS fs.FS, meta *project.Metadata, ec *context.ExecutionContext, filePaths []string) (*payload.WorkspaceChangeRequest, error) {
19+
if meta == nil {
20+
return nil, fmt.Errorf("metadata cannot be nil")
2421
}
22+
if meta.Modules == nil {
23+
return nil, fmt.Errorf("metadata.Modules cannot be nil")
24+
}
25+
26+
request := &payload.WorkspaceChangeRequest{}
2527

26-
// Helper to clean/normalise relative paths.
28+
// Helper to clean/normalise relative paths
2729
rel := func(abs string) string {
2830
if abs == "" {
2931
return ""
@@ -35,42 +37,43 @@ func buildExtendedUserMessage(rootFS fs.FS, meta *project.Metadata, ec *context.
3537
workingRel := rel(ec.WorkingDir)
3638
targetRel := rel(ec.TargetDir)
3739

40+
request.TargetDirectory = targetRel
41+
42+
// Find modules (metadata is guaranteed to be valid)
3843
workingMod := project.FindModule(meta.Modules, workingRel)
3944
targetMod := project.FindModule(meta.Modules, targetRel)
4045

4146
if workingMod == nil || targetMod == nil {
42-
return "", fmt.Errorf("failed to locate working and target modules")
47+
return nil, fmt.Errorf("failed to locate working and target modules")
4348
}
4449

45-
var sb strings.Builder
50+
// Set target module information
51+
request.TargetModule = targetMod.Name
4652

47-
// ------------------------------------------------------------
48-
// 1. External context of working module.
49-
// ------------------------------------------------------------
50-
if ann := workingMod.Annotation; ann != nil && ann.ExternalContext != "" {
51-
sb.WriteString(fmt.Sprintf("# Module: `%s`\n", workingMod.Name))
52-
sb.WriteString("## External Context\n")
53-
sb.WriteString(ann.ExternalContext + "\n")
53+
// Set target module context (combined internal and external context)
54+
var targetContext strings.Builder
55+
if ann := targetMod.Annotation; ann != nil {
56+
if ann.ExternalContext != "" {
57+
targetContext.WriteString("External Context: ")
58+
targetContext.WriteString(ann.ExternalContext)
59+
targetContext.WriteString("\n\n")
60+
}
61+
if ann.InternalContext != "" {
62+
targetContext.WriteString("Internal Context: ")
63+
targetContext.WriteString(ann.InternalContext)
64+
}
5465
}
5566

56-
// ------------------------------------------------------------
57-
// 2. Internal context of modules between working and target.
58-
// ------------------------------------------------------------
59-
for m := targetMod.Parent; m != nil && m != workingMod; m = m.Parent {
60-
if ann := m.Annotation; ann != nil && ann.InternalContext != "" {
61-
sb.WriteString(fmt.Sprintf("# Module: `%s`\n", m.Name))
62-
sb.WriteString("## Internal Context\n")
63-
sb.WriteString(ann.InternalContext + "\n")
64-
}
67+
// Ensure TargetModuleContext is never empty
68+
if targetContext.Len() == 0 {
69+
targetContext.WriteString("No specific context available for this module.")
6570
}
71+
request.TargetModuleContext = targetContext.String()
6672

67-
// ------------------------------------------------------------
68-
// 3. Public context of sibling modules along the path from the
69-
// parent of the target module up to (and including) the working
70-
// module. This replaces the previous logic that only considered
71-
// direct children of the working module.
72-
// ------------------------------------------------------------
73+
var parentModuleContexts []payload.ModuleContext
74+
var subModuleContexts []payload.ModuleContext
7375

76+
// Collect parent and sibling module contexts
7477
isAncestor := func(a, b string) bool {
7578
return a == b || (a != "." && strings.HasPrefix(b, a+"/"))
7679
}
@@ -82,36 +85,43 @@ func buildExtendedUserMessage(rootFS fs.FS, meta *project.Metadata, ec *context.
8285
continue
8386
}
8487
if ann := child.Annotation; ann != nil && ann.PublicContext != "" {
85-
sb.WriteString(fmt.Sprintf("# Module: `%s`\n", child.Name))
86-
sb.WriteString("## Public Context\n")
87-
sb.WriteString(ann.PublicContext + "\n")
88+
parentModuleContexts = append(parentModuleContexts, payload.ModuleContext{
89+
Name: child.Name,
90+
Content: ann.PublicContext,
91+
})
8892
}
8993
}
9094
if ancestor == workingMod {
9195
break
9296
}
9397
}
9498

95-
// ------------------------------------------------------------
96-
// 4. Public context of immediate sub-modules of target module.
97-
// ------------------------------------------------------------
99+
// Collect immediate sub-modules of target module
98100
for _, child := range targetMod.Modules {
99101
if ann := child.Annotation; ann != nil && ann.PublicContext != "" {
100-
sb.WriteString(fmt.Sprintf("# Module: `%s`\n", child.Name))
101-
sb.WriteString("## Public Context\n")
102-
sb.WriteString(ann.PublicContext + "\n")
102+
subModuleContexts = append(subModuleContexts, payload.ModuleContext{
103+
Name: child.Name,
104+
Content: ann.PublicContext,
105+
})
103106
}
104107
}
105108

106-
// ------------------------------------------------------------
107-
// 5. Append file contents (only files from target module were
108-
// selected by selector.Select).
109-
// ------------------------------------------------------------
110-
filesMsg, err := payload.BuildUserMessage(rootFS, filePaths)
111-
if err != nil {
112-
return "", err
109+
request.ParentModuleContexts = parentModuleContexts
110+
request.SubModuleContexts = subModuleContexts
111+
112+
// Append file contents
113+
var files []payload.FileContent
114+
for _, path := range filePaths {
115+
content, err := fs.ReadFile(rootFS, path)
116+
if err != nil {
117+
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
118+
}
119+
files = append(files, payload.FileContent{
120+
Path: path,
121+
Content: string(content),
122+
})
113123
}
114-
sb.WriteString(filesMsg)
124+
request.Files = files
115125

116-
return sb.String(), nil
126+
return request, nil
117127
}

cmd/template/user_msg_builder_test.go

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ package template
22

33
import (
44
"fmt"
5-
"strings"
5+
"reflect"
66
"testing"
77
"testing/fstest"
88

9+
"github.com/vybdev/vyb/llm/payload"
910
"github.com/vybdev/vyb/workspace/context"
1011
"github.com/vybdev/vyb/workspace/project"
1112
)
@@ -18,7 +19,7 @@ func Test_buildExtendedUserMessage(t *testing.T) {
1819
InternalContext: fmt.Sprintf("%s internal", s),
1920
}
2021
}
21-
// Build minimal module tree: root -> work (w) -> tgt (w/child)
22+
// Build minimal module tree: root -> work (w) -> mid -> tgt (w/child)
2223
root := &project.Module{Name: "."}
2324
work := &project.Module{Name: "w", Parent: root, Annotation: ann("W")}
2425
mid := &project.Module{Name: "w/mid", Parent: work, Annotation: ann("Mid")}
@@ -49,31 +50,64 @@ func Test_buildExtendedUserMessage(t *testing.T) {
4950
TargetDir: "w/mid/child",
5051
}
5152

52-
msg, err := buildExtendedUserMessage(mfs, meta, ec, []string{"w/mid/child/file.txt"})
53+
req, err := buildWorkspaceChangeRequest(mfs, meta, ec, []string{"w/mid/child/file.txt"})
5354
if err != nil {
5455
t.Fatalf("unexpected error: %v", err)
5556
}
5657

5758
// Basic assertions – ensure expected contexts are present.
58-
mustContain := []string{"W external", "Mid internal", "Sibling public", "Cousin public", "hello"}
59-
for _, s := range mustContain {
60-
if !strings.Contains(msg, s) {
61-
t.Fatalf("expected message to contain %q", s)
62-
}
59+
expectedFiles := []payload.FileContent{
60+
{Path: "w/mid/child/file.txt", Content: "hello"},
61+
}
62+
if !reflect.DeepEqual(req.Files, expectedFiles) {
63+
t.Errorf("Files mismatch: got %+v, want %+v", req.Files, expectedFiles)
6364
}
6465

65-
mustNotContain := []string{
66-
"W public", "W internal",
67-
"Mid public", "Mid external",
68-
"Sibling internal", "Sibling external",
69-
"Cousin internal", "Cousin external",
70-
"Out public", "Out internal", "Out external",
71-
"mid content", "sibling content", "w content", "cousin content", "out content",
66+
// Verify target module information
67+
if req.TargetModule != "w/mid/child" {
68+
t.Errorf("TargetModule mismatch: got %q, want %q", req.TargetModule, "w/mid/child")
7269
}
7370

74-
for _, s := range mustNotContain {
75-
if strings.Contains(msg, s) {
76-
t.Fatalf("should not include contexts for target module itself, got message:\n%s", msg)
77-
}
71+
if req.TargetDirectory != "w/mid/child" {
72+
t.Errorf("TargetDirectory mismatch: got %q, want %q", req.TargetDirectory, "w/mid/child")
73+
}
74+
75+
expectedParentContexts := []payload.ModuleContext{
76+
{Name: "w/mid/sibling", Content: "Sibling public"},
77+
{Name: "w/cousin", Content: "Cousin public"},
78+
}
79+
80+
if !reflect.DeepEqual(req.ParentModuleContexts, expectedParentContexts) {
81+
t.Errorf("ParentModuleContexts mismatch:\ngot: %+v\nwant: %+v", req.ParentModuleContexts, expectedParentContexts)
82+
}
83+
84+
// Should be empty since target module has no sub-modules
85+
if len(req.SubModuleContexts) != 0 {
86+
t.Errorf("SubModuleContexts should be empty, got: %+v", req.SubModuleContexts)
87+
}
88+
}
89+
90+
func Test_buildExtendedUserMessage_nilValidation(t *testing.T) {
91+
mfs := fstest.MapFS{
92+
"file.txt": &fstest.MapFile{Data: []byte("content")},
93+
}
94+
95+
ec := &context.ExecutionContext{
96+
ProjectRoot: ".",
97+
WorkingDir: ".",
98+
TargetDir: ".",
99+
}
100+
101+
// Test nil metadata
102+
_, err := buildWorkspaceChangeRequest(mfs, nil, ec, []string{"file.txt"})
103+
if err == nil || err.Error() != "metadata cannot be nil" {
104+
t.Errorf("Expected 'metadata cannot be nil' error, got: %v", err)
105+
}
106+
107+
// Test nil modules
108+
meta := &project.Metadata{Modules: nil}
109+
_, err = buildWorkspaceChangeRequest(mfs, meta, ec, []string{"file.txt"})
110+
if err == nil || err.Error() != "metadata.Modules cannot be nil" {
111+
t.Errorf("Expected 'metadata.Modules cannot be nil' error, got: %v", err)
78112
}
79113
}

llm/README.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,11 @@ debugging.
4040

4141
### `llm/payload`
4242

43-
Pure data & helper utilities:
43+
Pure data structures for LLM communication:
4444

45-
* `BuildUserMessage` – turns a list of files into a Markdown payload.
46-
* `BuildModuleContextUserMessage` – embeds annotations into the payload
47-
according to precise inclusion rules.
48-
* Go structs mirroring every JSON schema (WorkspaceChangeProposal,
49-
ModuleSelfContainedContext, …).
45+
* Go structs for request payloads (WorkspaceChangeRequest, ModuleContextRequest, ExternalContextsRequest)
46+
* Go structs for response payloads (WorkspaceChangeProposal, ModuleSelfContainedContext, ModuleExternalContextResponse)
47+
* All structs support JSON marshalling/unmarshalling for LLM interactions
5048

5149
## JSON Schema enforcement
5250

0 commit comments

Comments
 (0)