Skip to content

Generate enums with associated values as Foundation Models does - #301

Closed
akbashev wants to merge 1 commit into
huggingface:mainfrom
akbashev:fix/generable-enum-payloads
Closed

akbashev wants to merge 1 commit into
huggingface:mainfrom
akbashev:fix/generable-enum-payloads

Conversation

@akbashev

@akbashev akbashev commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

@Generable describes an enum whose cases carry values as {case: String, value: String}. The case names aren't listed, and every value is a string, whatever its type, so a model has nothing that tells it a case's values or their types. Foundation Models' own @Generable describes such an enum as one object per case, with a type that names the case and a property for each value, typed. Code that moves between the two frameworks gets different schemas and different content for the same enum.

This PR generates what Foundation Models generates, for the schema, the content, and decoding. A case is an object like {"type": "set", "field": "count", "to": {"type": "number", "value": 1}}: a labeled value is a property named by its label, and unlabeled ones are value, value1, and so on. A case without values is {"type": "reset"}, and an optional value that's nil is left out. The schema comes from a new GenerationSchema(type:description:cases:), which builds one object per case. Enums without associated values are unchanged.

Two details differ from Foundation Models:

  • A case's schema is named after its enum, like Module.Step.DiscriminatedSet: Foundation Models names it DiscriminatedSet alone, so two enums with a set case share one definition, and one of them loses its values.
  • And type is a string with one choice rather than a const, which means the same and works with every provider, including Gemini, whose schemas have no const.

Content saved in the old form, such as a stored transcript, no longer decodes. The old schema gave models nothing typed to fill in, so little such content is likely to exist.

Tests: GenerableEnumPayloadTests checks the content and schema against what Foundation Models generates for the same enums, and guidedGenerationWithEnumPayloads generates a list of such enums with the system model.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Root unions break OpenAI schemas, while optional and naming collisions can produce invalid or lossy payloads.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
What changed in this PR

Aligns associated-value enum schemas, serialization, and decoding with Foundation Models.

Changes:

  • Represents each enum case as a typed object with a discriminator.
  • Adds schema construction for payload enums.
  • Adds unit and system-model coverage.
File Description
GenerationSchema.swift Builds discriminated case schemas.
GenerableMacro.swift Generates payload serialization and decoding.
GenerableEnumPayloadTests.swift Tests schemas and round trips.
SystemLanguageModelTests.swift Exercises system-model generation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +432 to +433
let caseName = "\(typeName).Discriminated\(name.prefix(1).uppercased() + name.dropFirst())"
allDefs[caseName] = .object(
Comment on lines +1280 to +1282
let type = value.type
let isOptional = type.hasSuffix("?") || type.hasPrefix("Optional<")
return (value.label, value.label ?? (index == 0 ? "value" : "value\(index)"), type, isOptional)
@mattt

mattt commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

Thanks for opening this PR, @akbashev! A couple of things before we merge:

  1. Copilot's two findings look right to me: the Discriminated definition names can collide (for cases foo and Foo), and checking the spelling for optionals misses typealias Maybe = String? and Swift.Optional<String>. Could you take a look at those?
  2. On main, the schema for these enums is an object at the root. With this change, a top-level enum's root is an anyOf, and OpenAI's structured outputs require an object at the root. So respond(to:generating:) with one of these enums may now fail with OpenAILanguageModel. I haven't tried it against the API. Do you have a way to check, or an idea for how to handle it?

On const: Foundation Models does emit "const" for type, but a one-choice enum is fine here. We'll look at choosing between them per provider in a follow-up.

@mattt

mattt commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Thanks, @akbashev. To get this into 1.0, I opened #322 with your commit and fixes on top for Copilot's findings and the root schema. It also writes nil values as null, which is what Foundation Models does. I'll close this in favor of that one. Thanks again for working on this!

@mattt mattt closed this Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants