Skip to content

feat(go): relax option merging to accumulate or last-win - #5875

Open
apascal07 wants to merge 1 commit into
mainfrom
ap/pr-5814-option-relaxation-6fa3aa
Open

feat(go): relax option merging to accumulate or last-win#5875
apascal07 wants to merge 1 commit into
mainfrom
ap/pr-5814-option-relaxation-6fa3aa

Conversation

@apascal07

@apascal07 apascal07 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Every option in ai/option.go returned an error when it was set more than once, or combined with a sibling variant (WithMessages with WithMessagesFn, WithModel with WithModelName, and so on). That made options impossible to compose: a caller could not build a request up from several helpers, and GenerateData could not inject its output type without conflicting with a caller-supplied WithOutputSchema.

Options now follow the standard Go functional-options pattern, merging left to right under two rules, and applying them never fails.

// Before: rejected with "cannot set messages more than once"
// After:  messages accumulate in call order
genkit.Generate(ctx, g,
    ai.WithMessages(history...),
    ai.WithMessages(turn...),
)

Options carrying multiple items accumulate across repeats and across their variants: WithMessages/WithMessagesFn, WithTools, WithResources, WithUse, WithMiddleware, WithDocs/WithTextDocs, WithDataset, WithToolResponses, WithToolRestarts.

Options filling a single slot take the last one set: WithConfig, WithModel/WithModelName, WithSystem/WithPrompt, the output schema and format options, WithStreaming, WithInput*, WithStrictSchema, and the tool restart/respond options.

Only genuinely invalid arguments still fail, and they panic at the call site (a type WithInputType cannot turn into a schema). The unexported apply* methods drop their error return accordingly.

The two rules are documented in go/README.md beside the generate examples, since composability is the reason to reach for them and nothing else in the docs said repeats were allowed.

Not a breaking change

Compile-time. Every apply* method is unexported and takes unexported parameter types, so ai.GenerateOption and its siblings were only ever implementable inside package ai. No consumer can have implemented them. Every exported constructor keeps its signature. This was verified by building a package outside genkit/go that holds each option interface, constructs each one, directly constructs the two exported option structs (ai.RestartOptions, ai.RespondOptions), and embeds one in a user type.

Runtime. The change is a relaxation, not a redefinition. "Valid today" means each slot was set at most once, and with exactly one setter both merge rules reduce to that same one value. The only inputs whose behavior changes are the ones that return an error today.

Two deltas worth naming:

  • GenerateData now prepends its inferred WithOutputType instead of appending it, so a caller-supplied WithOutputSchema wins the schema slot while typed extraction keeps working. This matches what GenerateDataStream and DefineDataPrompt already did. It changes one currently-valid combination: GenerateData[T](..., ai.WithOutputFormat("text")) previously ended up as JSON because the injected option applied last; the caller's format now wins.
  • A few genuinely-bad inputs report a different error. WithTools(a), WithTools(a) now accumulates and trips the existing duplicate check, so the message moves from cannot set tools more than once to duplicate tool "a".

Examples

Compose a request from several helpers:

func withRetrievedContext(docs []*ai.Document) []ai.GenerateOption {
    return []ai.GenerateOption{ai.WithDocs(docs...), ai.WithMaxTurns(3)}
}

opts := []ai.GenerateOption{
    ai.WithModelName("googleai/gemini-flash-latest"),
    ai.WithSystem("You are a helpful assistant."),
    ai.WithMessages(history...),
}
opts = append(opts, withRetrievedContext(docs)...)
opts = append(opts, ai.WithPrompt("Summarize the above."))

resp, err := genkit.Generate(ctx, g, opts...)

Override a base configuration by appending:

base := []ai.GenerateOption{
    ai.WithModelName("googleai/gemini-flash-latest"),
    ai.WithConfig(&googlegenai.GeminiConfig{Temperature: 0.2}),
}

// Same model, hotter: the later WithConfig fills the slot.
creative := append(base, ai.WithConfig(&googlegenai.GeminiConfig{Temperature: 1.0}))

Keep typed extraction while supplying your own schema:

// The caller's schema wins; the value is still parsed into Recipe.
recipe, _, err := genkit.GenerateData[Recipe](ctx, g,
    ai.WithPrompt("Invent a recipe."),
    ai.WithOutputSchemaName("StrictRecipe"),
)

Notes

Message accumulation composes the MessagesFn closures and copies into a fresh slice rather than appending in place. ai.WithMessages(history...) hands the caller's slice straight through, so appending onto it could scribble over the array backing their history. There is a regression test for this.

Options outside go/ai are unchanged and still reject repeats: genkit.Init options (WithPlugins, WithDefaultModel, WithPromptDir), ai/exp agent options, and the firebase/localstore plugin options. Whether those should follow, particularly whether WithPlugins should accumulate, is a separate design question.

Testing

Go 1.26.3: go build ./... and go vet ./... clean across the module, and go test ./... passes all 39 packages with tests. go test -race ./ai/ passes.

New coverage: collection options accumulate (messages across both variants, tools, docs, resources, middleware, tool responses/restarts, dataset), single-value options take last-win, the caller's slice is not aliased by message accumulation, and GenerateData reaches the model with a caller-supplied schema while still extracting into the typed output.

Every option in ai/option.go returned an error when it was set more than once
or combined with a sibling variant (WithMessages with WithMessagesFn, WithModel
with WithModelName, ...). That made options impossible to compose: a caller
could not build a request up from several helpers, and GenerateData could not
inject its output type without conflicting with a caller-supplied
WithOutputSchema.

Options now follow the standard Go functional-options pattern, merging left to
right under two rules:

- Options carrying multiple items accumulate across repeats and across their
  variants: WithMessages/WithMessagesFn, WithTools, WithResources, WithUse,
  WithMiddleware, WithDocs/WithTextDocs, WithDataset, WithToolResponses, and
  WithToolRestarts.
- Options filling a single slot take the last one set: WithConfig,
  WithModel/WithModelName, WithSystem/WithPrompt, the output schema and format
  options, WithStreaming, WithInput*, WithStrictSchema, and the tool
  restart/respond options.

Applying an option can therefore no longer fail, so the unexported apply*
methods drop their error return; only genuinely invalid arguments (a type
WithInputType cannot turn into a schema) still panic at the call site.
GenerateData prepends its inferred WithOutputType so an explicit
WithOutputSchema or WithOutputSchemaName wins the schema slot while typed
output extraction keeps working, matching what GenerateDataStream and
DefineDataPrompt already did.

Message accumulation composes the MessagesFn closures and copies into a fresh
slice rather than appending in place, since WithMessages(history...) hands the
caller's slice straight through and appending onto it could scribble over the
array backing their history.

This is not a breaking change. The apply* methods are unexported and take
unexported parameter types, so the option interfaces were never implementable
outside package ai, and every option list that is valid today produces the same
request afterward: with a slot set at most once, both merge rules reduce to
that one value. Only calls that error today behave differently.
@github-actions github-actions Bot added docs Improvements or additions to documentation go labels Jul 30, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the functional options pattern across the Go SDK to support option composition. Instead of returning errors when options are set multiple times, single-value options now follow a 'last-wins' approach, while collection-based options (such as messages, tools, documents, resources, and middleware) accumulate. Additionally, GenerateData has been updated to prepend the inferred output type option, allowing caller-supplied schemas to take precedence. Comprehensive unit tests have been added and updated to verify these new behaviors. No review comments were provided, so there is no additional feedback.

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

Labels

docs Improvements or additions to documentation go

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant