From bec5d38bd8a807e7bc8fcfe1541f912591390333 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Wed, 17 Dec 2025 13:47:43 -0800 Subject: [PATCH 1/9] Added support for embedding prompt directory into the Go binary. --- go/ai/prompt.go | 73 ++++++++++- go/ai/prompt_test.go | 117 ++++++++++++++++++ go/genkit/genkit.go | 86 ++++++++++++- go/samples/prompts-embed/main.go | 68 ++++++++++ .../prompts-embed/prompts/example.prompt | 20 +++ 5 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 go/samples/prompts-embed/main.go create mode 100644 go/samples/prompts-embed/prompts/example.prompt diff --git a/go/ai/prompt.go b/go/ai/prompt.go index d47dcc4ab5..8f101d3bbb 100644 --- a/go/ai/prompt.go +++ b/go/ai/prompt.go @@ -19,9 +19,11 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "log/slog" "maps" "os" + "path" "path/filepath" "reflect" "strings" @@ -574,6 +576,70 @@ func loadPromptDir(r api.Registry, dir string, namespace string) { } } +// LoadPromptFS loads prompts and partials from an embedded filesystem for the given namespace. +// The fsys parameter should be an fs.FS implementation (e.g., embed.FS). +// The root parameter specifies the root directory within the filesystem where prompts are located. +func LoadPromptFS(r api.Registry, fsys fs.FS, root string, namespace string) { + if fsys == nil { + slog.Debug("No prompt filesystem provided, skipping loading .prompt files") + return + } + + if root == "" { + root = "." + } + + if _, err := fs.Stat(fsys, root); err != nil { + panic(fmt.Errorf("failed to access prompt directory %q in filesystem: %w", root, err)) + } + + loadPromptFS(r, fsys, root, namespace) +} + +// loadPromptFS recursively loads prompts and partials from the embedded filesystem. +func loadPromptFS(r api.Registry, fsys fs.FS, dir string, namespace string) { + entries, err := fs.ReadDir(fsys, dir) + if err != nil { + panic(fmt.Errorf("failed to read prompt directory structure from filesystem: %w", err)) + } + + for _, entry := range entries { + filename := entry.Name() + filePath := path.Join(dir, filename) + if entry.IsDir() { + loadPromptFS(r, fsys, filePath, namespace) + } else if strings.HasSuffix(filename, ".prompt") { + if strings.HasPrefix(filename, "_") { + partialName := strings.TrimSuffix(filename[1:], ".prompt") + source, err := fs.ReadFile(fsys, filePath) + if err != nil { + slog.Error("Failed to read partial file from filesystem", "error", err) + continue + } + r.RegisterPartial(partialName, string(source)) + slog.Debug("Registered Dotprompt partial from filesystem", "name", partialName, "file", filePath) + } else { + LoadPromptFromFS(r, fsys, dir, filename, namespace) + } + } + } +} + +// LoadPromptFromFS loads a single prompt from an embedded filesystem into the registry. +func LoadPromptFromFS(r api.Registry, fsys fs.FS, dir, filename, namespace string) Prompt { + name := strings.TrimSuffix(filename, ".prompt") + name, variant, _ := strings.Cut(name, ".") + + sourceFile := path.Join(dir, filename) + source, err := fs.ReadFile(fsys, sourceFile) + if err != nil { + slog.Error("Failed to read prompt file from filesystem", "file", sourceFile, "error", err) + return nil + } + + return loadPromptFromSource(r, sourceFile, name, variant, namespace, source) +} + // LoadPrompt loads a single prompt into the registry. func LoadPrompt(r api.Registry, dir, filename, namespace string) Prompt { name := strings.TrimSuffix(filename, ".prompt") @@ -586,6 +652,11 @@ func LoadPrompt(r api.Registry, dir, filename, namespace string) Prompt { return nil } + return loadPromptFromSource(r, sourceFile, name, variant, namespace, source) +} + +// loadPromptFromSource parses and registers a prompt from its source content. +func loadPromptFromSource(r api.Registry, sourceFile, name, variant, namespace string, source []byte) Prompt { dp := r.Dotprompt() parsedPrompt, err := dp.Parse(string(source)) @@ -696,12 +767,10 @@ func LoadPrompt(r api.Registry, dir, filename, namespace string) Prompt { promptOpts := []PromptOption{opts} - // Add system prompt if found if systemText != "" { promptOpts = append(promptOpts, WithSystem(systemText)) } - // If there are non-system messages, use WithMessages, otherwise use WithPrompt for template if len(nonSystemMessages) > 0 { promptOpts = append(promptOpts, WithMessages(nonSystemMessages...)) } else if systemText == "" { diff --git a/go/ai/prompt_test.go b/go/ai/prompt_test.go index 3c02959ddb..ee46956b4d 100644 --- a/go/ai/prompt_test.go +++ b/go/ai/prompt_test.go @@ -21,6 +21,7 @@ import ( "path/filepath" "strings" "testing" + "testing/fstest" "github.com/firebase/genkit/go/core/api" "github.com/firebase/genkit/go/internal/base" @@ -1150,6 +1151,122 @@ func TestLoadPromptFolder_DirectoryNotFound(t *testing.T) { } } +func TestLoadPromptFS(t *testing.T) { + mockPromptContent := `--- +model: test/chat +description: A test prompt +input: + schema: + type: object + properties: + name: + type: string +output: + format: text + schema: + type: string +--- + +Hello, {{name}}! +` + mockPartialContent := `Welcome {{name}}!` + + fsys := fstest.MapFS{ + "prompts/example.prompt": &fstest.MapFile{Data: []byte(mockPromptContent)}, + "prompts/sub/nested.prompt": &fstest.MapFile{Data: []byte(mockPromptContent)}, + "prompts/_greeting.prompt": &fstest.MapFile{Data: []byte(mockPartialContent)}, + } + + reg := registry.New() + + LoadPromptFS(reg, fsys, "prompts", "test-namespace") + + prompt := LookupPrompt(reg, "test-namespace/example") + if prompt == nil { + t.Fatalf("Prompt 'test-namespace/example' was not registered") + } + + nestedPrompt := LookupPrompt(reg, "test-namespace/nested") + if nestedPrompt == nil { + t.Fatalf("Nested prompt 'test-namespace/nested' was not registered") + } +} + +func TestLoadPromptFS_WithVariant(t *testing.T) { + mockPromptContent := `--- +model: test/chat +description: A test prompt with variant +--- + +Hello from variant! +` + + fsys := fstest.MapFS{ + "prompts/greeting.experimental.prompt": &fstest.MapFile{Data: []byte(mockPromptContent)}, + } + + reg := registry.New() + + LoadPromptFS(reg, fsys, "prompts", "") + + prompt := LookupPrompt(reg, "greeting.experimental") + if prompt == nil { + t.Fatalf("Prompt with variant 'greeting.experimental' was not registered") + } +} + +func TestLoadPromptFS_NilFS(t *testing.T) { + reg := registry.New() + + LoadPromptFS(reg, nil, "prompts", "test-namespace") + + if prompt := LookupPrompt(reg, "test-namespace/example"); prompt != nil { + t.Fatalf("Prompt should not have been registered with nil filesystem") + } +} + +func TestLoadPromptFS_InvalidRoot(t *testing.T) { + fsys := fstest.MapFS{ + "other/example.prompt": &fstest.MapFile{Data: []byte("test")}, + } + + reg := registry.New() + + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic for invalid root directory") + } + }() + + LoadPromptFS(reg, fsys, "nonexistent", "test-namespace") +} + +func TestLoadPromptFromFS(t *testing.T) { + mockPromptContent := `--- +model: test/chat +description: A single prompt test +--- + +Test content +` + + fsys := fstest.MapFS{ + "prompts/single.prompt": &fstest.MapFile{Data: []byte(mockPromptContent)}, + } + + reg := registry.New() + + prompt := LoadPromptFromFS(reg, fsys, "prompts", "single.prompt", "ns") + if prompt == nil { + t.Fatalf("LoadPromptFromFS failed to load prompt") + } + + lookedUp := LookupPrompt(reg, "ns/single") + if lookedUp == nil { + t.Fatalf("Prompt 'ns/single' was not registered") + } +} + // TestDefinePartialAndHelperJourney demonstrates a complete user journey for defining // and using both partials and helpers. func TestDefinePartialAndHelper(t *testing.T) { diff --git a/go/genkit/genkit.go b/go/genkit/genkit.go index 63db6cbd3d..eee2264aba 100644 --- a/go/genkit/genkit.go +++ b/go/genkit/genkit.go @@ -21,6 +21,7 @@ import ( "context" "errors" "fmt" + "io/fs" "log/slog" "os" "os/signal" @@ -46,6 +47,7 @@ type Genkit struct { type genkitOptions struct { DefaultModel string // Default model to use if no other model is specified. PromptDir string // Directory where dotprompts are stored. Will be loaded automatically on initialization. + PromptFS fs.FS // Embedded filesystem containing prompts (alternative to PromptDir). Plugins []api.Plugin // Plugin to initialize automatically. } @@ -66,6 +68,20 @@ func (o *genkitOptions) apply(gOpts *genkitOptions) error { if gOpts.PromptDir != "" { return errors.New("cannot set prompt directory more than once (WithPromptDir)") } + if gOpts.PromptFS != nil { + return errors.New("cannot use WithPromptDir together with WithPromptFS") + } + gOpts.PromptDir = o.PromptDir + } + + if o.PromptFS != nil { + if gOpts.PromptFS != nil { + return errors.New("cannot set prompt filesystem more than once (WithPromptFS)") + } + if gOpts.PromptDir != "" { + return errors.New("cannot use WithPromptFS together with WithPromptDir") + } + gOpts.PromptFS = o.PromptFS gOpts.PromptDir = o.PromptDir } @@ -99,13 +115,44 @@ func WithDefaultModel(model string) GenkitOption { // The default directory is "prompts" relative to the project root where // [Init] is called. // +// When used with [WithPromptFS], this directory serves as the root path within +// the embedded filesystem instead of a local disk path. For example, if using +// `//go:embed prompts/*`, set the directory to "prompts" to match. +// // Invalid prompt files will result in logged errors during initialization, // while valid files that define invalid prompts will cause [Init] to panic. -// This option can only be applied once. func WithPromptDir(dir string) GenkitOption { return &genkitOptions{PromptDir: dir} } +// WithPromptFS specifies an embedded filesystem ([fs.FS]) containing `.prompt` files. +// This is useful for embedding prompts directly into the binary using Go's [embed] package, +// eliminating the need to distribute prompt files separately. +// +// The `fsys` parameter should be an [fs.FS] implementation (e.g., [embed.FS]). +// Use [WithPromptDir] to specify the root directory within the filesystem where +// prompts are located (defaults to "prompts"). +// +// Example: +// +// import "embed" +// +// //go:embed prompts/* +// var promptsFS embed.FS +// +// func main() { +// g := genkit.Init(ctx, +// genkit.WithPromptFS(promptsFS), +// genkit.WithPromptDir("prompts"), +// ) +// } +// +// Invalid prompt files will result in logged errors during initialization, +// while valid files that define invalid prompts will cause [Init] to panic. +func WithPromptFS(fsys fs.FS) GenkitOption { + return &genkitOptions{PromptFS: fsys} +} + // Init creates and initializes a new [Genkit] instance with the provided options. // It sets up the registry, initializes plugins ([WithPlugins]), loads prompts // ([WithPromptDir]), and configures other settings like the default model @@ -184,7 +231,11 @@ func Init(ctx context.Context, opts ...GenkitOption) *Genkit { ai.ConfigureFormats(r) ai.DefineGenerateAction(ctx, r) - ai.LoadPromptDir(r, gOpts.PromptDir, "") + if gOpts.PromptFS != nil { + ai.LoadPromptFS(r, gOpts.PromptFS, gOpts.PromptDir, "") + } else { + ai.LoadPromptDir(r, gOpts.PromptDir, "") + } r.RegisterValue(api.DefaultModelKey, gOpts.DefaultModel) r.RegisterValue(api.PromptDirKey, gOpts.PromptDir) @@ -931,6 +982,37 @@ func LoadPromptDir(g *Genkit, dir string, namespace string) { ai.LoadPromptDir(g.reg, dir, namespace) } +// LoadPromptFS loads all `.prompt` files from the specified embedded filesystem `fsys` +// into the registry, associating them with the given `namespace`. +// Files starting with `_` are treated as partials and are not registered as +// executable prompts but can be included in other prompts. +// +// The `fsys` parameter should be an [fs.FS] implementation (e.g., [embed.FS]). +// The `root` parameter specifies the root directory within the filesystem where +// prompts are located (e.g., "prompts" if using `//go:embed prompts/*`). +// The `namespace` acts as a prefix to the prompt name (e.g., namespace "myApp" and +// file "greeting.prompt" results in prompt name "myApp/greeting"). Use an empty +// string for no namespace. +// +// This function provides an alternative to [LoadPromptDir] for loading prompts +// from embedded filesystems, enabling self-contained binaries without external +// prompt files. +// +// Example: +// +// import "embed" +// +// //go:embed prompts/* +// var promptsFS embed.FS +// +// func main() { +// g := genkit.Init(ctx) +// genkit.LoadPromptFS(g, promptsFS, "prompts", "myNamespace") +// } +func LoadPromptFS(g *Genkit, fsys fs.FS, root string, namespace string) { + ai.LoadPromptFS(g.reg, fsys, root, namespace) +} + // LoadPrompt loads a single `.prompt` file specified by `path` into the registry, // associating it with the given `namespace`, and returns the resulting [ai.prompt]. // diff --git a/go/samples/prompts-embed/main.go b/go/samples/prompts-embed/main.go new file mode 100644 index 0000000000..552433d33a --- /dev/null +++ b/go/samples/prompts-embed/main.go @@ -0,0 +1,68 @@ +// Copyright 2025 Google LLC +// SPDX-License-Identifier: Apache-2.0 + +// This sample demonstrates how to use embedded prompts with genkit. +// Prompts are embedded directly into the binary using Go's embed package, +// which allows you to ship a self-contained binary without needing to +// distribute prompt files separately. + +// [START main] +package main + +import ( + "context" + "embed" + "errors" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/googlegenai" +) + +// Embed the prompts directory into the binary. +// The //go:embed directive makes the prompts available at compile time. +// +//go:embed prompts/* +var promptsFS embed.FS + +func main() { + ctx := context.Background() + + g := genkit.Init(ctx, + genkit.WithPlugins(&googlegenai.GoogleAI{}), + genkit.WithPromptFS(promptsFS, "prompts"), + ) + + type greetingStyle struct { + Style string `json:"style"` + Location string `json:"location"` + Name string `json:"name"` + } + + type greeting struct { + Greeting string `json:"greeting"` + } + + genkit.DefineFlow(g, "assistantGreetingFlow", func(ctx context.Context, input greetingStyle) (string, error) { + prompt := genkit.LookupPrompt(g, "example") + if prompt == nil { + return "", errors.New("assistantGreetingFlow: failed to find prompt") + } + + resp, err := prompt.Execute(ctx, ai.WithInput(input)) + if err != nil { + return "", err + } + + var output greeting + if err = resp.Output(&output); err != nil { + return "", err + } + + return output.Greeting, nil + }) + + <-ctx.Done() +} + +// [END main] diff --git a/go/samples/prompts-embed/prompts/example.prompt b/go/samples/prompts-embed/prompts/example.prompt new file mode 100644 index 0000000000..a9699cc22a --- /dev/null +++ b/go/samples/prompts-embed/prompts/example.prompt @@ -0,0 +1,20 @@ +--- +model: googleai/gemini-2.5-flash +config: + temperature: 0.9 +input: + schema: + location: string + style?: string + name?: string + default: + name: Alex +output: + schema: + greeting: string +--- + +You are the world's most welcoming AI assistant and are currently working at {{location}}. + +Greet a guest{{#if name}} named {{name}}{{/if}}{{#if style}} in the style of {{style}}{{/if}}. + From ac4ceca335698843a6783fd06fc3ada2cddda8c9 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Wed, 17 Dec 2025 13:55:46 -0800 Subject: [PATCH 2/9] Simplified functions. --- go/ai/prompt.go | 68 +++++++++++++------------------------------------ 1 file changed, 17 insertions(+), 51 deletions(-) diff --git a/go/ai/prompt.go b/go/ai/prompt.go index 8f101d3bbb..34e504d7db 100644 --- a/go/ai/prompt.go +++ b/go/ai/prompt.go @@ -544,36 +544,7 @@ func LoadPromptDir(r api.Registry, dir string, namespace string) { return } - loadPromptDir(r, path, namespace) -} - -// loadPromptDir recursively loads prompts and partials from the directory. -func loadPromptDir(r api.Registry, dir string, namespace string) { - entries, err := os.ReadDir(dir) - if err != nil { - panic(fmt.Errorf("failed to read prompt directory structure: %w", err)) - } - - for _, entry := range entries { - filename := entry.Name() - path := filepath.Join(dir, filename) - if entry.IsDir() { - loadPromptDir(r, path, namespace) - } else if strings.HasSuffix(filename, ".prompt") { - if strings.HasPrefix(filename, "_") { - partialName := strings.TrimSuffix(filename[1:], ".prompt") - source, err := os.ReadFile(path) - if err != nil { - slog.Error("Failed to read partial file", "error", err) - continue - } - r.RegisterPartial(partialName, string(source)) - slog.Debug("Registered Dotprompt partial", "name", partialName, "file", path) - } else { - LoadPrompt(r, dir, filename, namespace) - } - } - } + loadPromptDirFromFS(r, os.DirFS(path), ".", namespace) } // LoadPromptFS loads prompts and partials from an embedded filesystem for the given namespace. @@ -593,33 +564,33 @@ func LoadPromptFS(r api.Registry, fsys fs.FS, root string, namespace string) { panic(fmt.Errorf("failed to access prompt directory %q in filesystem: %w", root, err)) } - loadPromptFS(r, fsys, root, namespace) + loadPromptDirFromFS(r, fsys, root, namespace) } -// loadPromptFS recursively loads prompts and partials from the embedded filesystem. -func loadPromptFS(r api.Registry, fsys fs.FS, dir string, namespace string) { +// loadPromptDirFromFS is the unified implementation for recursively loading prompts from any fs.FS. +func loadPromptDirFromFS(r api.Registry, fsys fs.FS, dir string, namespace string) { entries, err := fs.ReadDir(fsys, dir) if err != nil { - panic(fmt.Errorf("failed to read prompt directory structure from filesystem: %w", err)) + panic(fmt.Errorf("failed to read prompt directory structure: %w", err)) } for _, entry := range entries { filename := entry.Name() filePath := path.Join(dir, filename) if entry.IsDir() { - loadPromptFS(r, fsys, filePath, namespace) + loadPromptDirFromFS(r, fsys, filePath, namespace) } else if strings.HasSuffix(filename, ".prompt") { if strings.HasPrefix(filename, "_") { partialName := strings.TrimSuffix(filename[1:], ".prompt") source, err := fs.ReadFile(fsys, filePath) if err != nil { - slog.Error("Failed to read partial file from filesystem", "error", err) + slog.Error("Failed to read partial file", "error", err) continue } r.RegisterPartial(partialName, string(source)) - slog.Debug("Registered Dotprompt partial from filesystem", "name", partialName, "file", filePath) + slog.Debug("Registered Dotprompt partial", "name", partialName, "file", filePath) } else { - LoadPromptFromFS(r, fsys, dir, filename, namespace) + loadPromptFromFS(r, fsys, dir, filename, namespace) } } } @@ -627,26 +598,21 @@ func loadPromptFS(r api.Registry, fsys fs.FS, dir string, namespace string) { // LoadPromptFromFS loads a single prompt from an embedded filesystem into the registry. func LoadPromptFromFS(r api.Registry, fsys fs.FS, dir, filename, namespace string) Prompt { - name := strings.TrimSuffix(filename, ".prompt") - name, variant, _ := strings.Cut(name, ".") - - sourceFile := path.Join(dir, filename) - source, err := fs.ReadFile(fsys, sourceFile) - if err != nil { - slog.Error("Failed to read prompt file from filesystem", "file", sourceFile, "error", err) - return nil - } - - return loadPromptFromSource(r, sourceFile, name, variant, namespace, source) + return loadPromptFromFS(r, fsys, dir, filename, namespace) } // LoadPrompt loads a single prompt into the registry. func LoadPrompt(r api.Registry, dir, filename, namespace string) Prompt { + return loadPromptFromFS(r, os.DirFS(dir), ".", filename, namespace) +} + +// loadPromptFromFS is the unified implementation for loading a single prompt from any fs.FS. +func loadPromptFromFS(r api.Registry, fsys fs.FS, dir, filename, namespace string) Prompt { name := strings.TrimSuffix(filename, ".prompt") name, variant, _ := strings.Cut(name, ".") - sourceFile := filepath.Join(dir, filename) - source, err := os.ReadFile(sourceFile) + sourceFile := path.Join(dir, filename) + source, err := fs.ReadFile(fsys, sourceFile) if err != nil { slog.Error("Failed to read prompt file", "file", sourceFile, "error", err) return nil From c26a1f76aae13bfb91316df641e84999b1074891 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Wed, 17 Dec 2025 13:56:35 -0800 Subject: [PATCH 3/9] Update main.go --- go/samples/prompts-embed/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/samples/prompts-embed/main.go b/go/samples/prompts-embed/main.go index 552433d33a..8e600f895a 100644 --- a/go/samples/prompts-embed/main.go +++ b/go/samples/prompts-embed/main.go @@ -30,7 +30,7 @@ func main() { g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.GoogleAI{}), - genkit.WithPromptFS(promptsFS, "prompts"), + genkit.WithPromptFS(promptsFS), ) type greetingStyle struct { From 03170048eb078ff664d80a197576d2f02006ab68 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Wed, 17 Dec 2025 14:03:08 -0800 Subject: [PATCH 4/9] Updated sample. --- go/samples/prompts-embed/main.go | 40 ++++++++----------- .../prompts-embed/prompts/example.prompt | 17 +------- 2 files changed, 17 insertions(+), 40 deletions(-) diff --git a/go/samples/prompts-embed/main.go b/go/samples/prompts-embed/main.go index 8e600f895a..f0f7a5bde3 100644 --- a/go/samples/prompts-embed/main.go +++ b/go/samples/prompts-embed/main.go @@ -1,12 +1,22 @@ // Copyright 2025 Google LLC -// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. // This sample demonstrates how to use embedded prompts with genkit. // Prompts are embedded directly into the binary using Go's embed package, // which allows you to ship a self-contained binary without needing to // distribute prompt files separately. -// [START main] package main import ( @@ -14,7 +24,6 @@ import ( "embed" "errors" - "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" "github.com/firebase/genkit/go/plugins/googlegenai" ) @@ -33,36 +42,19 @@ func main() { genkit.WithPromptFS(promptsFS), ) - type greetingStyle struct { - Style string `json:"style"` - Location string `json:"location"` - Name string `json:"name"` - } - - type greeting struct { - Greeting string `json:"greeting"` - } - - genkit.DefineFlow(g, "assistantGreetingFlow", func(ctx context.Context, input greetingStyle) (string, error) { + genkit.DefineFlow(g, "sayHello", func(ctx context.Context, name string) (string, error) { prompt := genkit.LookupPrompt(g, "example") if prompt == nil { - return "", errors.New("assistantGreetingFlow: failed to find prompt") + return "", errors.New("prompt not found") } - resp, err := prompt.Execute(ctx, ai.WithInput(input)) + resp, err := prompt.Execute(ctx) if err != nil { return "", err } - var output greeting - if err = resp.Output(&output); err != nil { - return "", err - } - - return output.Greeting, nil + return resp.Text(), nil }) <-ctx.Done() } - -// [END main] diff --git a/go/samples/prompts-embed/prompts/example.prompt b/go/samples/prompts-embed/prompts/example.prompt index a9699cc22a..abdd8bdee5 100644 --- a/go/samples/prompts-embed/prompts/example.prompt +++ b/go/samples/prompts-embed/prompts/example.prompt @@ -1,20 +1,5 @@ --- model: googleai/gemini-2.5-flash -config: - temperature: 0.9 -input: - schema: - location: string - style?: string - name?: string - default: - name: Alex -output: - schema: - greeting: string --- -You are the world's most welcoming AI assistant and are currently working at {{location}}. - -Greet a guest{{#if name}} named {{name}}{{/if}}{{#if style}} in the style of {{style}}{{/if}}. - +Say hello!. From e3281bbcb9f7a55b0fbaa1074cd9a56c1db536e5 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Wed, 17 Dec 2025 14:27:14 -0800 Subject: [PATCH 5/9] Further cleaned up the functions. --- go/ai/prompt.go | 77 +++++++------------------------------------- go/ai/prompt_test.go | 40 ++++++++++++----------- go/genkit/genkit.go | 52 ++++++++++++++++++++++++------ 3 files changed, 77 insertions(+), 92 deletions(-) diff --git a/go/ai/prompt.go b/go/ai/prompt.go index 34e504d7db..39be86af6a 100644 --- a/go/ai/prompt.go +++ b/go/ai/prompt.go @@ -22,9 +22,7 @@ import ( "io/fs" "log/slog" "maps" - "os" "path" - "path/filepath" "reflect" "strings" @@ -519,56 +517,18 @@ func convertToPartPointers(parts []dotprompt.Part) ([]*Part, error) { return result, nil } -// LoadPromptDir loads prompts and partials from the input directory for the given namespace. -func LoadPromptDir(r api.Registry, dir string, namespace string) { - useDefaultDir := false - if dir == "" { - dir = "./prompts" - useDefaultDir = true - } - - path, err := filepath.Abs(dir) - if err != nil { - if !useDefaultDir { - panic(fmt.Errorf("failed to resolve prompt directory %q: %w", dir, err)) - } - slog.Debug("default prompt directory not found, skipping loading .prompt files", "dir", dir) - return - } - - if _, err := os.Stat(path); os.IsNotExist(err) { - if !useDefaultDir { - panic(fmt.Errorf("failed to resolve prompt directory %q: %w", dir, err)) - } - slog.Debug("Default prompt directory not found, skipping loading .prompt files", "dir", dir) - return - } - - loadPromptDirFromFS(r, os.DirFS(path), ".", namespace) -} - -// LoadPromptFS loads prompts and partials from an embedded filesystem for the given namespace. -// The fsys parameter should be an fs.FS implementation (e.g., embed.FS). -// The root parameter specifies the root directory within the filesystem where prompts are located. -func LoadPromptFS(r api.Registry, fsys fs.FS, root string, namespace string) { +// LoadPromptDirFromFS loads prompts and partials from a filesystem for the given namespace. +// The fsys parameter should be an fs.FS implementation (e.g., embed.FS or os.DirFS). +// The dir parameter specifies the directory within the filesystem where prompts are located. +func LoadPromptDirFromFS(r api.Registry, fsys fs.FS, dir, namespace string) { if fsys == nil { - slog.Debug("No prompt filesystem provided, skipping loading .prompt files") - return + panic(errors.New("no prompt filesystem provided")) } - if root == "" { - root = "." + if _, err := fs.Stat(fsys, dir); err != nil { + panic(fmt.Errorf("failed to access prompt directory %q in filesystem: %w", dir, err)) } - if _, err := fs.Stat(fsys, root); err != nil { - panic(fmt.Errorf("failed to access prompt directory %q in filesystem: %w", root, err)) - } - - loadPromptDirFromFS(r, fsys, root, namespace) -} - -// loadPromptDirFromFS is the unified implementation for recursively loading prompts from any fs.FS. -func loadPromptDirFromFS(r api.Registry, fsys fs.FS, dir string, namespace string) { entries, err := fs.ReadDir(fsys, dir) if err != nil { panic(fmt.Errorf("failed to read prompt directory structure: %w", err)) @@ -578,7 +538,7 @@ func loadPromptDirFromFS(r api.Registry, fsys fs.FS, dir string, namespace strin filename := entry.Name() filePath := path.Join(dir, filename) if entry.IsDir() { - loadPromptDirFromFS(r, fsys, filePath, namespace) + LoadPromptDirFromFS(r, fsys, filePath, namespace) } else if strings.HasSuffix(filename, ".prompt") { if strings.HasPrefix(filename, "_") { partialName := strings.TrimSuffix(filename[1:], ".prompt") @@ -590,24 +550,16 @@ func loadPromptDirFromFS(r api.Registry, fsys fs.FS, dir string, namespace strin r.RegisterPartial(partialName, string(source)) slog.Debug("Registered Dotprompt partial", "name", partialName, "file", filePath) } else { - loadPromptFromFS(r, fsys, dir, filename, namespace) + LoadPromptFromFS(r, fsys, dir, filename, namespace) } } } } -// LoadPromptFromFS loads a single prompt from an embedded filesystem into the registry. +// LoadPromptFromFS loads a single prompt from a filesystem into the registry. +// The fsys parameter should be an fs.FS implementation (e.g., embed.FS or os.DirFS). +// The dir parameter specifies the directory within the filesystem where the prompt is located. func LoadPromptFromFS(r api.Registry, fsys fs.FS, dir, filename, namespace string) Prompt { - return loadPromptFromFS(r, fsys, dir, filename, namespace) -} - -// LoadPrompt loads a single prompt into the registry. -func LoadPrompt(r api.Registry, dir, filename, namespace string) Prompt { - return loadPromptFromFS(r, os.DirFS(dir), ".", filename, namespace) -} - -// loadPromptFromFS is the unified implementation for loading a single prompt from any fs.FS. -func loadPromptFromFS(r api.Registry, fsys fs.FS, dir, filename, namespace string) Prompt { name := strings.TrimSuffix(filename, ".prompt") name, variant, _ := strings.Cut(name, ".") @@ -618,11 +570,6 @@ func loadPromptFromFS(r api.Registry, fsys fs.FS, dir, filename, namespace strin return nil } - return loadPromptFromSource(r, sourceFile, name, variant, namespace, source) -} - -// loadPromptFromSource parses and registers a prompt from its source content. -func loadPromptFromSource(r api.Registry, sourceFile, name, variant, namespace string, source []byte) Prompt { dp := r.Dotprompt() parsedPrompt, err := dp.Parse(string(source)) diff --git a/go/ai/prompt_test.go b/go/ai/prompt_test.go index ee46956b4d..fbb4b3f453 100644 --- a/go/ai/prompt_test.go +++ b/go/ai/prompt_test.go @@ -914,7 +914,7 @@ Hello, {{name}}! reg := registry.New() // Call loadPrompt - LoadPrompt(reg, tempDir, "example.prompt", "test-namespace") + LoadPromptFromFS(reg, os.DirFS(tempDir), ".", "example.prompt", "test-namespace") // Verify that the prompt was registered correctly prompt := LookupPrompt(reg, "test-namespace/example") @@ -963,7 +963,7 @@ input: } reg := registry.New() - LoadPrompt(reg, tempDir, "snake.prompt", "snake-namespace") + LoadPromptFromFS(reg, os.DirFS(tempDir), ".", "snake.prompt", "snake-namespace") prompt := LookupPrompt(reg, "snake-namespace/snake") if prompt == nil { @@ -1011,8 +1011,9 @@ func TestLoadPrompt_FileNotFound(t *testing.T) { // Initialize a mock registry reg := registry.New() - // Call loadPrompt with a non-existent file - LoadPrompt(reg, "./nonexistent", "missing.prompt", "test-namespace") + // Call loadPrompt with a non-existent file in a valid temp directory + tempDir := t.TempDir() + LoadPromptFromFS(reg, os.DirFS(tempDir), ".", "missing.prompt", "test-namespace") // Verify that the prompt was not registered prompt := LookupPrompt(reg, "missing") @@ -1037,7 +1038,7 @@ func TestLoadPrompt_InvalidPromptFile(t *testing.T) { reg := registry.New() // Call loadPrompt - LoadPrompt(reg, tempDir, "invalid.prompt", "test-namespace") + LoadPromptFromFS(reg, os.DirFS(tempDir), ".", "invalid.prompt", "test-namespace") // Verify that the prompt was not registered prompt := LookupPrompt(reg, "invalid") @@ -1068,7 +1069,7 @@ Hello, {{name}}! reg := registry.New() // Call loadPrompt - LoadPrompt(reg, tempDir, "example.variant.prompt", "test-namespace") + LoadPromptFromFS(reg, os.DirFS(tempDir), ".", "example.variant.prompt", "test-namespace") // Verify that the prompt was registered correctly prompt := LookupPrompt(reg, "test-namespace/example.variant") @@ -1123,7 +1124,7 @@ Hello, {{name}}! reg := registry.New() // Call LoadPromptFolder - LoadPromptDir(reg, tempDir, "test-namespace") + LoadPromptDirFromFS(reg, os.DirFS(tempDir), ".", "test-namespace") // Verify that the prompt was registered correctly prompt := LookupPrompt(reg, "test-namespace/example") @@ -1138,16 +1139,19 @@ Hello, {{name}}! } } -func TestLoadPromptFolder_DirectoryNotFound(t *testing.T) { +func TestLoadPromptFolder_EmptyDirectory(t *testing.T) { // Initialize a mock registry - reg := ®istry.Registry{} + reg := registry.New() + + // Create an empty temp directory + tempDir := t.TempDir() - // Call LoadPromptFolder with a non-existent directory - LoadPromptDir(reg, "", "test-namespace") + // Call LoadPromptFolder with an empty directory + LoadPromptDirFromFS(reg, os.DirFS(tempDir), ".", "test-namespace") // Verify that no prompts were registered if prompt := LookupPrompt(reg, "example"); prompt != nil { - t.Fatalf("Prompt should not have been registered for a non-existent directory") + t.Fatalf("Prompt should not have been registered for an empty directory") } } @@ -1179,7 +1183,7 @@ Hello, {{name}}! reg := registry.New() - LoadPromptFS(reg, fsys, "prompts", "test-namespace") + LoadPromptDirFromFS(reg, fsys, "prompts", "test-namespace") prompt := LookupPrompt(reg, "test-namespace/example") if prompt == nil { @@ -1207,7 +1211,7 @@ Hello from variant! reg := registry.New() - LoadPromptFS(reg, fsys, "prompts", "") + LoadPromptDirFromFS(reg, fsys, "prompts", "") prompt := LookupPrompt(reg, "greeting.experimental") if prompt == nil { @@ -1218,7 +1222,7 @@ Hello from variant! func TestLoadPromptFS_NilFS(t *testing.T) { reg := registry.New() - LoadPromptFS(reg, nil, "prompts", "test-namespace") + LoadPromptDirFromFS(reg, nil, "prompts", "test-namespace") if prompt := LookupPrompt(reg, "test-namespace/example"); prompt != nil { t.Fatalf("Prompt should not have been registered with nil filesystem") @@ -1238,7 +1242,7 @@ func TestLoadPromptFS_InvalidRoot(t *testing.T) { } }() - LoadPromptFS(reg, fsys, "nonexistent", "test-namespace") + LoadPromptDirFromFS(reg, fsys, "nonexistent", "test-namespace") } func TestLoadPromptFromFS(t *testing.T) { @@ -1323,7 +1327,7 @@ Hello! ConfigureFormats(reg) definePromptModel(reg) - prompt := LoadPrompt(reg, tempDir, "example.prompt", "multi-namespace") + prompt := LoadPromptFromFS(reg, os.DirFS(tempDir), ".", "example.prompt", "multi-namespace") _, err = prompt.Execute(context.Background()) if err != nil { @@ -1350,7 +1354,7 @@ Hello! t.Fatalf("Failed to create mock prompt file: %v", err) } - prompt := LoadPrompt(registry.New(), tempDir, "example.prompt", "multi-namespace-roles") + prompt := LoadPromptFromFS(registry.New(), os.DirFS(tempDir), ".", "example.prompt", "multi-namespace-roles") actionOpts, err := prompt.Render(context.Background(), map[string]any{}) if err != nil { diff --git a/go/genkit/genkit.go b/go/genkit/genkit.go index eee2264aba..eb7b30c503 100644 --- a/go/genkit/genkit.go +++ b/go/genkit/genkit.go @@ -232,9 +232,13 @@ func Init(ctx context.Context, opts ...GenkitOption) *Genkit { ai.ConfigureFormats(r) ai.DefineGenerateAction(ctx, r) if gOpts.PromptFS != nil { - ai.LoadPromptFS(r, gOpts.PromptFS, gOpts.PromptDir, "") + dir := gOpts.PromptDir + if dir == "" { + dir = "prompts" + } + ai.LoadPromptDirFromFS(r, gOpts.PromptFS, dir, "") } else { - ai.LoadPromptDir(r, gOpts.PromptDir, "") + loadPromptDirOS(r, gOpts.PromptDir, "") } r.RegisterValue(api.DefaultModelKey, gOpts.DefaultModel) @@ -979,10 +983,38 @@ func Evaluate(ctx context.Context, g *Genkit, opts ...ai.EvaluatorOption) (*ai.E // by [WithPromptDir], but can be called explicitly to load prompts from other // locations or with different namespaces. func LoadPromptDir(g *Genkit, dir string, namespace string) { - ai.LoadPromptDir(g.reg, dir, namespace) + loadPromptDirOS(g.reg, dir, namespace) +} + +// loadPromptDirOS loads prompts from an OS directory by converting to os.DirFS. +func loadPromptDirOS(r api.Registry, dir string, namespace string) { + useDefaultDir := false + if dir == "" { + dir = "./prompts" + useDefaultDir = true + } + + absPath, err := filepath.Abs(dir) + if err != nil { + if !useDefaultDir { + panic(fmt.Errorf("failed to resolve prompt directory %q: %w", dir, err)) + } + slog.Debug("default prompt directory not found, skipping loading .prompt files", "dir", dir) + return + } + + if _, err := os.Stat(absPath); os.IsNotExist(err) { + if !useDefaultDir { + panic(fmt.Errorf("failed to resolve prompt directory %q: %w", dir, err)) + } + slog.Debug("Default prompt directory not found, skipping loading .prompt files", "dir", dir) + return + } + + ai.LoadPromptDirFromFS(r, os.DirFS(absPath), ".", namespace) } -// LoadPromptFS loads all `.prompt` files from the specified embedded filesystem `fsys` +// LoadPromptDirFromFS loads all `.prompt` files from the specified embedded filesystem `fsys` // into the registry, associating them with the given `namespace`. // Files starting with `_` are treated as partials and are not registered as // executable prompts but can be included in other prompts. @@ -1007,10 +1039,10 @@ func LoadPromptDir(g *Genkit, dir string, namespace string) { // // func main() { // g := genkit.Init(ctx) -// genkit.LoadPromptFS(g, promptsFS, "prompts", "myNamespace") +// genkit.LoadPromptDirFromFS(g, promptsFS, "prompts", "myNamespace") // } -func LoadPromptFS(g *Genkit, fsys fs.FS, root string, namespace string) { - ai.LoadPromptFS(g.reg, fsys, root, namespace) +func LoadPromptDirFromFS(g *Genkit, fsys fs.FS, dir string, namespace string) { + ai.LoadPromptDirFromFS(g.reg, fsys, dir, namespace) } // LoadPrompt loads a single `.prompt` file specified by `path` into the registry, @@ -1037,11 +1069,13 @@ func LoadPromptFS(g *Genkit, fsys fs.FS, root string, namespace string) { // // ... handle response and error ... func LoadPrompt(g *Genkit, path string, namespace string) ai.Prompt { dir, filename := filepath.Split(path) - if dir != "" { + if dir == "" { + dir = "." + } else { dir = filepath.Clean(dir) } - return ai.LoadPrompt(g.reg, dir, filename, namespace) + return ai.LoadPromptFromFS(g.reg, os.DirFS(dir), ".", filename, namespace) } // DefinePartial wraps DefinePartial to register a partial template with the given name and source. From aee70ce824e2e26d5c834068ee68191dd1156d55 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Wed, 17 Dec 2025 14:37:16 -0800 Subject: [PATCH 6/9] Update genkit.go --- go/genkit/genkit.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/go/genkit/genkit.go b/go/genkit/genkit.go index eb7b30c503..c86665b53c 100644 --- a/go/genkit/genkit.go +++ b/go/genkit/genkit.go @@ -1020,7 +1020,7 @@ func loadPromptDirOS(r api.Registry, dir string, namespace string) { // executable prompts but can be included in other prompts. // // The `fsys` parameter should be an [fs.FS] implementation (e.g., [embed.FS]). -// The `root` parameter specifies the root directory within the filesystem where +// The `dir` parameter specifies the directory within the filesystem where // prompts are located (e.g., "prompts" if using `//go:embed prompts/*`). // The `namespace` acts as a prefix to the prompt name (e.g., namespace "myApp" and // file "greeting.prompt" results in prompt name "myApp/greeting"). Use an empty @@ -1041,7 +1041,7 @@ func loadPromptDirOS(r api.Registry, dir string, namespace string) { // g := genkit.Init(ctx) // genkit.LoadPromptDirFromFS(g, promptsFS, "prompts", "myNamespace") // } -func LoadPromptDirFromFS(g *Genkit, fsys fs.FS, dir string, namespace string) { +func LoadPromptDirFromFS(g *Genkit, fsys fs.FS, dir, namespace string) { ai.LoadPromptDirFromFS(g.reg, fsys, dir, namespace) } @@ -1067,7 +1067,7 @@ func LoadPromptDirFromFS(g *Genkit, fsys fs.FS, dir string, namespace string) { // // Execute the loaded prompt // resp, err := customPrompt.Execute(ctx, ai.WithInput(map[string]any{"text": "some data"})) // // ... handle response and error ... -func LoadPrompt(g *Genkit, path string, namespace string) ai.Prompt { +func LoadPrompt(g *Genkit, path, namespace string) ai.Prompt { dir, filename := filepath.Split(path) if dir == "" { dir = "." From eda2211256b784a0e23dd7e01ad7fe42d1c9e3d1 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Wed, 17 Dec 2025 14:49:04 -0800 Subject: [PATCH 7/9] Update prompt_test.go --- go/ai/prompt_test.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/go/ai/prompt_test.go b/go/ai/prompt_test.go index fbb4b3f453..889829cb1c 100644 --- a/go/ai/prompt_test.go +++ b/go/ai/prompt_test.go @@ -1222,11 +1222,13 @@ Hello from variant! func TestLoadPromptFS_NilFS(t *testing.T) { reg := registry.New() - LoadPromptDirFromFS(reg, nil, "prompts", "test-namespace") + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic for nil filesystem") + } + }() - if prompt := LookupPrompt(reg, "test-namespace/example"); prompt != nil { - t.Fatalf("Prompt should not have been registered with nil filesystem") - } + LoadPromptDirFromFS(reg, nil, "prompts", "test-namespace") } func TestLoadPromptFS_InvalidRoot(t *testing.T) { From b42e2213d119a809032a74db57de03718bd4eeb8 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Wed, 17 Dec 2025 15:49:20 -0800 Subject: [PATCH 8/9] Update genkit.go --- go/genkit/genkit.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go/genkit/genkit.go b/go/genkit/genkit.go index c86665b53c..79a78d7bad 100644 --- a/go/genkit/genkit.go +++ b/go/genkit/genkit.go @@ -982,12 +982,12 @@ func Evaluate(ctx context.Context, g *Genkit, opts ...ai.EvaluatorOption) (*ai.E // This function is often called implicitly by [Init] using the directory specified // by [WithPromptDir], but can be called explicitly to load prompts from other // locations or with different namespaces. -func LoadPromptDir(g *Genkit, dir string, namespace string) { +func LoadPromptDir(g *Genkit, dir, namespace string) { loadPromptDirOS(g.reg, dir, namespace) } // loadPromptDirOS loads prompts from an OS directory by converting to os.DirFS. -func loadPromptDirOS(r api.Registry, dir string, namespace string) { +func loadPromptDirOS(r api.Registry, dir, namespace string) { useDefaultDir := false if dir == "" { dir = "./prompts" From 32d4cea30ed8252e3d9a459c9569a7d131f62c4b Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Tue, 23 Dec 2025 09:01:17 -0800 Subject: [PATCH 9/9] Update prompt_test.go --- go/ai/prompt_test.go | 64 -------------------------------------------- 1 file changed, 64 deletions(-) diff --git a/go/ai/prompt_test.go b/go/ai/prompt_test.go index 889829cb1c..36d4761ca4 100644 --- a/go/ai/prompt_test.go +++ b/go/ai/prompt_test.go @@ -878,70 +878,6 @@ func assertResponse(t *testing.T, resp *ModelResponse, want string) { } } -func TestLoadPrompt(t *testing.T) { - // Create a temporary directory for testing - tempDir := t.TempDir() - - // Create a mock .prompt file - mockPromptFile := filepath.Join(tempDir, "example.prompt") - mockPromptContent := `--- -model: test-model -maxTurns: 5 -description: A test prompt -toolChoice: required -returnToolRequests: true -input: - schema: - type: object - properties: - name: - type: string - default: - name: world -output: - format: text - schema: - type: string ---- -Hello, {{name}}! -` - err := os.WriteFile(mockPromptFile, []byte(mockPromptContent), 0o644) - if err != nil { - t.Fatalf("Failed to create mock prompt file: %v", err) - } - - // Initialize a mock registry - reg := registry.New() - - // Call loadPrompt - LoadPromptFromFS(reg, os.DirFS(tempDir), ".", "example.prompt", "test-namespace") - - // Verify that the prompt was registered correctly - prompt := LookupPrompt(reg, "test-namespace/example") - if prompt == nil { - t.Fatalf("Prompt was not registered") - } - - if prompt.(api.Action).Desc().InputSchema == nil { - t.Fatal("Input schema is nil") - } - - if prompt.(api.Action).Desc().InputSchema["type"] != "object" { - t.Errorf("Expected input schema type 'object', got '%s'", prompt.(api.Action).Desc().InputSchema["type"]) - } - - promptMetadata, ok := prompt.(api.Action).Desc().Metadata["prompt"].(map[string]any) - if !ok { - t.Fatalf("Expected Metadata['prompt'] to be a map, but got %T", prompt.(api.Action).Desc().Metadata["prompt"]) - } - if promptMetadata["model"] != "test-model" { - t.Errorf("Expected model name 'test-model', got '%s'", prompt.(api.Action).Desc().Metadata["model"]) - } - if promptMetadata["maxTurns"] != 5 { - t.Errorf("Expected maxTurns set to 5, got: %d", promptMetadata["maxTurns"]) - } -} - func TestLoadPromptSnakeCase(t *testing.T) { tempDir := t.TempDir() mockPromptFile := filepath.Join(tempDir, "snake.prompt")