Skip to content

Conversation

@Zereker
Copy link

@Zereker Zereker commented Dec 11, 2025

Summary

  • Fix template variable substitution bug in LoadPrompt where variables were replaced with empty values at load time
  • Defer template rendering to execution time using WithMessagesFn
  • Add convertDotpromptMessages helper function
  • Add regression test TestLoadPromptTemplateVariableSubstitution

Problem

When using LoadPrompt to load .prompt files, the template was rendered at load time with an empty DataArgument. This caused all template variables (like {{name}}, {{topic}}, etc.) to be replaced with empty values immediately.

As a result, subsequent calls to Execute() or Render() with actual input values had no effect - the template was already "baked" with empty values.

Example

// greeting.prompt content:
// Hello {{name}}, welcome to {{place}}!

prompt := genkit.LookupPrompt(g, "greeting")

// BUG: Variables not substituted!
result, _ := prompt.Execute(ctx, ai.WithInput(map[string]any{
    "name":  "Alice",
    "place": "Wonderland",
}))
// Expected: "Hello Alice, welcome to Wonderland!"
// Actual: "Hello , welcome to !"  (empty values)

Solution

Defer template rendering to execution time by using WithMessagesFn. The closure:

  1. Captures the raw template text at load time
  2. Compiles and renders the template with actual input values at execution time
  3. Properly handles multi-role messages (<<<dotprompt:role:XXX>>> markers)
  4. Properly handles history insertion (<<<dotprompt:history>>> markers)

Test Plan

  • Added TestLoadPromptTemplateVariableSubstitution regression test
  • Verified TestMultiMessagesRenderPrompt still passes (multi-role support)
  • All existing ai package tests pass

Fixes #3924

@Zereker Zereker force-pushed the fix/go-loadprompt-template-rendering branch from 3b4f001 to bf10054 Compare December 11, 2025 14:46
@hugoaguirre hugoaguirre self-requested a review December 15, 2025 20:23
@hugoaguirre
Copy link
Contributor

Hi @Zereker
Thanks for both of your contributions (here and Dotprompt). I'll take a look at them.

We are making improvements in the core which are causing merge conflicts with your contribution. Would it be possible if you address the conflicts?

Previously, LoadPrompt called ToMessages with an empty DataArgument
at load time, causing template variables to be replaced with empty
values. This meant all subsequent Execute() calls would use prompts
with empty template variable values.

This change defers template rendering to execution time by using
WithMessagesFn. The closure captures the raw template text and
compiles/renders it with actual input values when Execute() or
Render() is called.

The fix properly handles:
1. Template variable substitution with actual input values
2. Multi-role messages (<<<dotprompt:role:XXX>>> markers)
3. History insertion (<<<dotprompt:history>>> markers)

Added convertDotpromptMessages helper to convert dotprompt.Message
to ai.Message format.

Added regression test TestLoadPromptTemplateVariableSubstitution
to verify template variables are correctly substituted with different
input values on multiple calls.

Fixes firebase#3924
@Zereker Zereker force-pushed the fix/go-loadprompt-template-rendering branch from bf10054 to 64ef676 Compare December 16, 2025 03:41
@Zereker
Copy link
Author

Zereker commented Dec 16, 2025

Hi @hugoaguirre, I've rebased on the latest main and resolved the conflicts. Ready for review!

@hugoaguirre
Copy link
Contributor

hugoaguirre commented Dec 16, 2025

Hi @Zereker, I've tried to reproduce the issue with the latest changes in main and I was able to see the prompt rendering correctly. This is the code that I used to reproduce the issue:

greeting.prompt contents:

---
description: "A greeting prompt with variables"
---
Hello {{name}}, welcome to {{place}}!

Genkit code:

func PromptFromZereker(ctx context.Context, g *genkit.Genkit) {
	prompt := genkit.LoadPrompt(g, "./prompts/greeting.prompt", "greetings")
	if prompt == nil {
		log.Fatal("empty prompt")
	}

	resp, err := prompt.Execute(ctx,
		ai.WithInput(map[string]any{
			"name":  "Alice",
			"place": "Wonderland",
		}))
	if err != nil {
		log.Fatalf("error executing prompt: %v", err)
	}
	fmt.Printf("request: %#v\n", resp.Request.Messages[0].Text())
	log.Print(resp.Text())
}

Output:

request: "Hello Alice, welcome to Wonderland!"
2025/12/16 21:11:11 Thank you for the warm welcome! What an intriguing place to find myself. I'm already feeling a delightful sense of wonder and perhaps a touch of delightful confusion, which I hear is quite common here.

So, tell me, where shall our adventure begin? Are there any White Rabbits I should follow, or perhaps a curious riddle to solve? I'm quite ready for whatever Wonderland has in store!

Could you point me in the right direction to reproduce the issue you are reporting?

You can copy paste this sample in go/samples/prompts/main.go and run it

@Zereker
Copy link
Author

Zereker commented Dec 17, 2025

Hi @hugoaguirre,

Thanks for testing! I found that the {{role "system"}} Handlebars syntax is already used in the codebase:

  • go/samples/prompts/prompts/multi-msg.prompt
  • go/samples/coffee-shop/main.go
  • go/ai/prompt_test.go (TestMultiMessagesPrompt)

However, there's an issue with how this syntax is handled in LoadPrompt.

How to Reproduce

Add this test to go/ai/prompt_test.go:

func TestHandlebarsRoleMarkers(t *testing.T) {
    tempDir := t.TempDir()
    mockPromptFile := filepath.Join(tempDir, "test.prompt")
    content := `---
model: test/chat
---
{{role "system"}}
You are a helpful assistant.

{{role "user"}}
Hello {{name}}, welcome to {{place}}!
`
    if err := os.WriteFile(mockPromptFile, []byte(content), 0644); err != nil {
        t.Fatal(err)
    }

    prompt := LoadPrompt(registry.New(), tempDir, "test.prompt", "test")
    opts, err := prompt.Render(context.Background(), map[string]any{
        "name":  "Alice",
        "place": "Wonderland",
    })
    if err != nil {
        t.Fatal(err)
    }

    // Verify messages are correctly separated
    if len(opts.Messages) != 2 {
        t.Errorf("Expected 2 messages, got %d", len(opts.Messages))
    }
    if opts.Messages[0].Role != RoleSystem {
        t.Errorf("Expected first message to be system role")
    }
}

Expected:

Messages: 2
[0] Role: system, Text: "You are a helpful assistant."
[1] Role: user, Text: "Hello Alice, welcome to Wonderland!"

Actual (on main):

Messages: 1
[0] Role: user, Text: "\nYou are a helpful assistant.\n\n\nHello Alice, welcome to Wonderland!"

Comparison

The existing test TestMultiMessagesRenderPrompt uses <<<dotprompt:role:system>>> format (internal markers), which works correctly. But the Handlebars syntax {{role "system"}} that users write does not work properly - all roles are merged into a single user message.

Format Messages Result
<<<dotprompt:role:system>>> 2 ✅ Correctly separated
{{role "system"}} 1 ❌ Merged into single user message

Root Cause

In LoadPrompt, line 711 calls:

dpMessages, err := dotprompt.ToMessages(parsedPrompt.Template, &dotprompt.DataArgument{})

This renders the template at load time with an empty DataArgument, before the Handlebars {{role "..."}} syntax is processed into internal markers.

My Fix

My fix defers template rendering to execution time by using WithMessagesFn:

  1. Compile the template at load time (but don't render)
  2. Render at execution time with actual input values via WithMessagesFn closure
  3. Convert the rendered dotprompt.Message list to ai.Message with correct roles

This ensures both template variables ({{name}}) and role markers ({{role "system"}}) are properly processed.

@hugoaguirre
Copy link
Contributor

Hi @Zereker,
Thanks for the clarification. I'll make some internal validations and will get back to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Bug: LoadPrompt pre-renders template with empty DataArgument, ignoring Execute() input parameters

2 participants