diff --git a/go/ai/prompt.go b/go/ai/prompt.go index d47dcc4ab5..39be86af6a 100644 --- a/go/ai/prompt.go +++ b/go/ai/prompt.go @@ -19,10 +19,10 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "log/slog" "maps" - "os" - "path/filepath" + "path" "reflect" "strings" @@ -517,70 +517,54 @@ 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 +// 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 { + panic(errors.New("no prompt filesystem provided")) } - 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 := fs.Stat(fsys, dir); err != nil { + panic(fmt.Errorf("failed to access prompt directory %q in filesystem: %w", dir, err)) } - 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 - } - - 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) + entries, err := fs.ReadDir(fsys, 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) + filePath := path.Join(dir, filename) if entry.IsDir() { - loadPromptDir(r, path, namespace) + LoadPromptDirFromFS(r, fsys, filePath, namespace) } else if strings.HasSuffix(filename, ".prompt") { if strings.HasPrefix(filename, "_") { partialName := strings.TrimSuffix(filename[1:], ".prompt") - source, err := os.ReadFile(path) + source, err := fs.ReadFile(fsys, filePath) 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) + slog.Debug("Registered Dotprompt partial", "name", partialName, "file", filePath) } else { - LoadPrompt(r, dir, filename, namespace) + LoadPromptFromFS(r, fsys, dir, filename, namespace) } } } } -// LoadPrompt loads a single prompt into the registry. -func LoadPrompt(r api.Registry, dir, filename, namespace string) Prompt { +// 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 { 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 @@ -696,12 +680,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..36d4761ca4 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" @@ -877,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 - LoadPrompt(reg, 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") @@ -962,7 +899,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 { @@ -1010,8 +947,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") @@ -1036,7 +974,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") @@ -1067,7 +1005,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") @@ -1122,7 +1060,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") @@ -1137,16 +1075,137 @@ Hello, {{name}}! } } -func TestLoadPromptFolder_DirectoryNotFound(t *testing.T) { +func TestLoadPromptFolder_EmptyDirectory(t *testing.T) { // Initialize a mock registry - reg := ®istry.Registry{} + reg := registry.New() - // Call LoadPromptFolder with a non-existent directory - LoadPromptDir(reg, "", "test-namespace") + // Create an empty temp directory + tempDir := t.TempDir() + + // 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") + } +} + +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() + + LoadPromptDirFromFS(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() + + LoadPromptDirFromFS(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() + + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic for nil filesystem") + } + }() + + LoadPromptDirFromFS(reg, nil, "prompts", "test-namespace") +} + +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") + } + }() + + LoadPromptDirFromFS(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") } } @@ -1206,7 +1265,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 { @@ -1233,7 +1292,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 63db6cbd3d..79a78d7bad 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,15 @@ func Init(ctx context.Context, opts ...GenkitOption) *Genkit { ai.ConfigureFormats(r) ai.DefineGenerateAction(ctx, r) - ai.LoadPromptDir(r, gOpts.PromptDir, "") + if gOpts.PromptFS != nil { + dir := gOpts.PromptDir + if dir == "" { + dir = "prompts" + } + ai.LoadPromptDirFromFS(r, gOpts.PromptFS, dir, "") + } else { + loadPromptDirOS(r, gOpts.PromptDir, "") + } r.RegisterValue(api.DefaultModelKey, gOpts.DefaultModel) r.RegisterValue(api.PromptDirKey, gOpts.PromptDir) @@ -927,8 +982,67 @@ 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) { - ai.LoadPromptDir(g.reg, dir, namespace) +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, 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) +} + +// 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. +// +// The `fsys` parameter should be an [fs.FS] implementation (e.g., [embed.FS]). +// 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 +// 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.LoadPromptDirFromFS(g, promptsFS, "prompts", "myNamespace") +// } +func LoadPromptDirFromFS(g *Genkit, fsys fs.FS, dir, namespace string) { + ai.LoadPromptDirFromFS(g.reg, fsys, dir, namespace) } // LoadPrompt loads a single `.prompt` file specified by `path` into the registry, @@ -953,13 +1067,15 @@ func LoadPromptDir(g *Genkit, 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 != "" { + 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. diff --git a/go/samples/prompts-embed/main.go b/go/samples/prompts-embed/main.go new file mode 100644 index 0000000000..f0f7a5bde3 --- /dev/null +++ b/go/samples/prompts-embed/main.go @@ -0,0 +1,60 @@ +// Copyright 2025 Google LLC +// +// 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. + +package main + +import ( + "context" + "embed" + "errors" + + "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), + ) + + genkit.DefineFlow(g, "sayHello", func(ctx context.Context, name string) (string, error) { + prompt := genkit.LookupPrompt(g, "example") + if prompt == nil { + return "", errors.New("prompt not found") + } + + resp, err := prompt.Execute(ctx) + if err != nil { + return "", err + } + + return resp.Text(), nil + }) + + <-ctx.Done() +} diff --git a/go/samples/prompts-embed/prompts/example.prompt b/go/samples/prompts-embed/prompts/example.prompt new file mode 100644 index 0000000000..abdd8bdee5 --- /dev/null +++ b/go/samples/prompts-embed/prompts/example.prompt @@ -0,0 +1,5 @@ +--- +model: googleai/gemini-2.5-flash +--- + +Say hello!.