Skip to content

feat(output-format): add XML output format - #3991

Open
mvanhorn wants to merge 5 commits into
BoundaryML:canaryfrom
mvanhorn:fix/2409-baml-xml-output-format
Open

mvanhorn wants to merge 5 commits into
BoundaryML:canaryfrom
mvanhorn:fix/2409-baml-xml-output-format

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Jul 12, 2026 •

Copy link
Copy Markdown
Contributor

Issue Reference

@aaronvg mentioned on #2409 that there's a design doc for an XML output format and asked who'd be interested. This is a first pass — happy to reconcile the element/attribute mapping with whatever the design doc specifies before merge.

Changes

Adds XML as a selectable output format alongside JSON:

  • {{ ctx.output_format(format="xml") }} renders an XML-shaped schema in the prompt instead of JSON. format defaults to "json", so existing prompts are unchanged.
  • The format argument is validated at compile time in the jinja type checker.
  • Response parsing decodes XML through the same schema-aligned (jsonish) coercion used for JSON, with preamble tolerance, malformed closing-tag recovery, null handling, Unicode aliases, and a depth limit. Existing JSONish behavior for XML-like reasoning/code text is preserved.
  • Shares the output-format backend with the TOON work in feat(output-format): add TOON output format #3990, so adding formats stays a single abstraction rather than bespoke code paths.

Rendering lives in engine/baml-lib/jinja-runtime/src/output_format/ (new xml.rs); parsing in engine/baml-lib/jsonish/ (new xmlish.rs). Docs in fern/03-reference/baml/prompt-syntax/output-format.mdx.

Testing

  • Unit tests added/updated
  • Manual testing performed

Added XML fixtures covering class/enum/list/map/optional/union/recursion/aliases, preamble tolerance, and malformed closing tags. Local runs:

  • cargo test -p internal-baml-jinja -p jsonish --lib — 120 + 492 tests pass
  • cargo clippy -p internal-baml-jinja -p jsonish --lib clean
  • current-branch baml check against an XML prompt fixture, git diff --check clean

Screenshots

Simulated demo of the prompt-side contrast (JSON vs XML schema for the same type) and the parse-back to a typed value:

XML output format demo

PR Checklist

  • I have read and followed the contributing guidelines
  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings

Additional Notes

Motivation from the issue thread: some models emit more reliable structured output in XML than JSON (the Morph/Warp-Grep guide @yonil7 linked). Branch is aligned with current canary. AI was used for assistance.

Summary by CodeRabbit

  • New Features
    • Added ctx.output_format(format=...) to choose none | "json" | "xml", including XML-structured prompt/output schema rendering.
    • Enabled XML deserialization into typed results (classes, enums, lists, maps, optionals, unions, and recursive types with aliases).
  • Documentation
    • Documented XML formatting, XML type mapping rules, alias behavior, and XML element-name sanitization.
  • Bug Fixes
    • Improved XML input robustness (tolerates preamble/whitespace and recovers from incomplete or slightly malformed XML).
  • Tests
    • Expanded rendering and XML→structured decoding coverage, including Unicode/sanitization and additional error cases.

Adds ctx.output_format(format="xml") to request XML from the model, with
schema-aligned parsing of the response back into typed values. Covers
class/enum/list/map/optional/union/recursion, preamble tolerance, and
malformed closing-tag recovery. Shares the output-format backend with TOON.

Refs BoundaryML#2409
@vercel

vercel Bot commented Jul 12, 2026

Copy link
Copy Markdown

@mvanhorn is attempting to deploy a commit to the Boundary Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f9511e34-53a1-4681-965d-7a20506d1060

📥 Commits

Reviewing files that changed from the base of the PR and between 94f7de6 and bb71619.

📒 Files selected for processing (3)
  • engine/baml-lib/jinja-runtime/src/output_format/types.rs
  • engine/baml-lib/jinja-runtime/src/output_format/xml.rs
  • engine/baml-lib/jsonish/src/tests/test_xml.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • engine/baml-lib/jinja-runtime/src/output_format/types.rs
  • engine/baml-lib/jinja-runtime/src/output_format/xml.rs

📝 Walkthrough

Walkthrough

Adds format="xml" support to Jinja output schemas, implements XML rendering and tolerant XML deserialization, wires XML detection into JSONish parsing, and adds type-checking, integration tests, dependency configuration, and documentation.

Changes

XML output format support

Layer / File(s) Summary
Rendering contract
engine/baml-lib/jinja-runtime/src/output_format/*, engine/baml-lib/jinja/src/evaluate_type/*, engine/baml-lib/jinja-runtime/src/lib.rs
ctx.output_format accepts JSON or XML, dispatches rendering accordingly, and validates and tests the new option.
XML schema renderer
engine/baml-lib/jinja-runtime/src/output_format/xml.rs
Renders TypeIR primitives, classes, enums, lists, maps, unions, aliases, nulls, descriptions, and recursive structures as XML templates.
XML response parser
engine/baml-lib/jsonish/src/jsonish/*, engine/Cargo.toml, engine/baml-lib/jsonish/Cargo.toml
Adds tolerant XML parsing with typed conversion for classes, enums, lists, maps, unions, aliases, nulls, and incomplete XML nodes.
Deserialization integration and documentation
engine/baml-lib/jsonish/src/lib.rs, engine/baml-lib/jsonish/src/tests/*, fern/03-reference/.../output-format.mdx
XML-like input uses the XML parser with JSONish fallback, with broad coverage and documentation for XML schema and response behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant JinjaTemplate
  participant OutputFormat
  participant XmlRenderer
  participant Jsonish
  participant XmlParser
  JinjaTemplate->>OutputFormat: request format="xml"
  OutputFormat->>XmlRenderer: render typed XML schema
  XmlRenderer-->>JinjaTemplate: return XML prompt
  Jsonish->>XmlParser: parse XML-like response
  XmlParser-->>Jsonish: return typed Value
Loading

Poem

A rabbit hops through XML bright,
With carrots tagged in nested light.
JSON or XML, the prompt now sings,
While tiny ears parse broken things.
Typed fields bloom in Unicode dreams.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding XML as a new output format.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@engine/baml-lib/jinja/src/evaluate_type/types.rs`:
- Around line 362-369: Align the `format` type declaration with the
`output_format()` runtime parser: either update parsing to accept
`Option<String>` and interpret an explicit `none` as the default, or remove
`Type::None` from the union containing the `format` field. Ensure the declared
accepted types cannot produce a value the runtime rejects.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 360d9680-a17f-4545-bbb9-2f783be3d737

📥 Commits

Reviewing files that changed from the base of the PR and between 663ad17 and 5866fb6.

⛔ Files ignored due to path filters (1)
  • engine/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • engine/Cargo.toml
  • engine/baml-lib/jinja-runtime/src/lib.rs
  • engine/baml-lib/jinja-runtime/src/output_format/mod.rs
  • engine/baml-lib/jinja-runtime/src/output_format/types.rs
  • engine/baml-lib/jinja-runtime/src/output_format/xml.rs
  • engine/baml-lib/jinja/src/evaluate_type/test_expr.rs
  • engine/baml-lib/jinja/src/evaluate_type/types.rs
  • engine/baml-lib/jsonish/Cargo.toml
  • engine/baml-lib/jsonish/src/jsonish/mod.rs
  • engine/baml-lib/jsonish/src/jsonish/xmlish.rs
  • engine/baml-lib/jsonish/src/lib.rs
  • engine/baml-lib/jsonish/src/tests/mod.rs
  • engine/baml-lib/jsonish/src/tests/test_xml.rs
  • fern/03-reference/baml/prompt-syntax/output-format.mdx

Parse the output_format `format` argument as Option<String> so an explicit
`none` (allowed by the argument's type union, which also keeps `format`
optional) resolves to the default JSON format instead of failing at runtime.

Addresses CodeRabbit review.
@aaronvg

aaronvg commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

This is the design doc: https://app.notion.com/p/gloochat/XML-Outputs-218bb2d26216806fb4dfef242d150c51?source=copy_link

It'd be great to have a ton of tests for this kind of thing (recursive types, arrays, classes, unions). All of these are discussed in the design doc as well

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Addressed the review in ba5350a — output_format now parses format as Option<String>, so an explicit format=none resolves to the default JSON format instead of failing at runtime (kept the type union so format stays optional). Updated the null-options test to cover it.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Thanks @aaronvg — I'll go through the design doc and make sure the element/attribute mapping and list encoding match it. The PR already covers classes, enums, lists, maps, optionals, unions, and recursion in tests; I'll expand the suite to exercise deeper recursive types and nested arrays/unions per the doc. One thing: the Notion link is gated for me — if there are specific mapping decisions there you want matched exactly (e.g. how unions or nullable fields serialize), a quick note here would help me align precisely.

Add round-trip and renderer tests for recursive types, class/union
arrays, nested arrays, nullable fields, class-valued maps, and
malformed-but-recoverable XML. The new nested-array case surfaced a bug
where nested XML arrays parsed as empty; xmlish now distinguishes nested
<item> containers correctly.
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Pushed 869ae00 with expanded tests — recursive types, arrays of classes/unions, nested arrays, nullable fields, class-valued maps, and malformed-but-recoverable XML. Adding the nested-array case actually surfaced a real bug (nested XML arrays were parsing as empty), now fixed in xmlish. I still owe a pass to reconcile the exact serialization against the design doc; if the union/nullable mapping there differs from what I have I'll match it — a pointer to the specific section would help since the Notion link is gated for me.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
engine/baml-lib/jinja-runtime/src/lib.rs (1)

695-695: 📐 Maintainability & Code Quality | 🔵 Trivial

Run the engine Rust checks before merge
cargo test --lib from engine; cargo fmt needs rustfmt in the toolchain.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@engine/baml-lib/jinja-runtime/src/lib.rs` at line 695, Run the engine Rust
validation before merging: execute cargo test --lib from the engine workspace
and run cargo fmt, ensuring rustfmt is available in the active toolchain.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@engine/baml-lib/jinja-runtime/src/lib.rs`:
- Line 695: Run the engine Rust validation before merging: execute cargo test
--lib from the engine workspace and run cargo fmt, ensuring rustfmt is available
in the active toolchain.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 12a680fc-bda7-4b34-b572-92980abaeda4

📥 Commits

Reviewing files that changed from the base of the PR and between 5866fb6 and ba5350a.

📒 Files selected for processing (2)
  • engine/baml-lib/jinja-runtime/src/lib.rs
  • engine/baml-lib/jinja-runtime/src/output_format/mod.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • engine/baml-lib/jinja-runtime/src/output_format/mod.rs

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
engine/baml-lib/jsonish/src/tests/test_xml.rs (1)

306-330: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the total-XML-parse-failure → JSONish fallback path.

The two malformed-XML cases here exercise recoverable XML (partial trees still yield roots). None of the tests exercise the case where parse_xml fully fails (roots.is_empty() → bail!) and from_str falls back to jsonish::parse, which is a core contract of this layer per the PR description ("falls back to JSONish parsing on XML errors").

test_deserializer!(
    xml_falls_back_to_jsonish_on_unparseable_xml,
    XML_TYPES,
    r#"{"city": "London", "zip": 12345} <unrelated"#,
    TypeIR::class("Address"),
    {"city": "London", "zip": 12345}
);

Verify this input genuinely fails parse_xml (dangling unclosed tag with no roots) and that jsonish::parse tolerates the trailing garbage before asserting the exact expected shape.
Based on the upstream contract in engine/baml-lib/jsonish/src/lib.rs: the from_str entrypoint tries jsonish::parse_xml when the input contains <, and if that fails it falls back to jsonish::parse (JSONish parsing) for the same raw payload.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@engine/baml-lib/jsonish/src/tests/test_xml.rs` around lines 306 - 330, Add a
test named xml_falls_back_to_jsonish_on_unparseable_xml alongside the existing
XML deserializer tests, using the provided JSON object followed by dangling
unclosed-tag garbage. Assert it deserializes as Address with the expected city
and zip values, thereby covering the parse_xml total-failure path and subsequent
jsonish::parse fallback while preserving the exact result shape.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@engine/baml-lib/jsonish/src/tests/test_xml.rs`:
- Around line 306-330: Add a test named
xml_falls_back_to_jsonish_on_unparseable_xml alongside the existing XML
deserializer tests, using the provided JSON object followed by dangling
unclosed-tag garbage. Assert it deserializes as Address with the expected city
and zip values, thereby covering the parse_xml total-failure path and subsequent
jsonish::parse fallback while preserving the exact result shape.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 597d8094-ce60-4cd3-bffa-9a6eff6723e7

📥 Commits

Reviewing files that changed from the base of the PR and between ba5350a and 869ae00.

📒 Files selected for processing (3)
  • engine/baml-lib/jinja-runtime/src/output_format/types.rs
  • engine/baml-lib/jsonish/src/jsonish/xmlish.rs
  • engine/baml-lib/jsonish/src/tests/test_xml.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • engine/baml-lib/jinja-runtime/src/output_format/types.rs
  • engine/baml-lib/jsonish/src/jsonish/xmlish.rs

Match the XML output-format structure to the design doc: nested classes
render inside a <ClassName> wrapper; class arrays use a {ClassName}List
wrapper with <ClassName> elements; primitive/enum/union arrays use
<list><item>; optionals render inline as 'value or null' (via or_splitter
/ render_null_as); class unions render as sibling <ClassName> blocks
separated by a bare OR; recursive classes reference their name inline
with enum/class hoisting and the doc's default prefixes. Parser updated
to accept the new shapes; tests and docs updated to match.
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Went through the design doc and aligned the XML rendering to it in 94f7de6:

  • nested classes render inside a <ClassName> wrapper
  • class arrays use the {ClassName}List wrapper with <ClassName> elements; primitive/enum/union arrays use <list><item>
  • optionals render inline as value or null (honoring or_splitter / render_null_as)
  • class unions render as sibling <ClassName> blocks separated by a bare OR
  • recursive classes reference their name inline, with enum/class hoisting and the doc's default prefixes

Parser updated to accept the new shapes; 633 tests pass. One question: the doc's canonical arg is type=xml (and ctx.output_format_xml), but I kept format="xml" here to match the TOON PR (#3990). Want me to switch to type=xml? If so, it'd probably make sense to move TOON to type=toon too for consistency.

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@engine/baml-lib/jinja-runtime/src/output_format/xml.rs`:
- Around line 651-663: Validate sanitized XML tag names for uniqueness within
each container before rendering, so distinct raw names that collide after
xml_tag_name cannot produce indistinguishable fields. Update the
container-rendering flow around container and xml_tag_name to detect duplicate
sanitized names and reject the container instead of emitting ambiguous XML;
preserve normal rendering for unique names.
- Around line 423-427: Update the catch-all match in the XML rendering function
containing leaf so it is exhaustive: recursively resolve alias variants through
the existing alias-resolution path, and explicitly return the established errors
for unsupported recursive aliases, tuples, arrows, and Top instead of
serializing them with TypeIR::to_string(). Preserve the current scalar fallback
only for genuinely supported scalar variants.
- Around line 218-247: Update the class-rendering flow around the class lookup
and fields loop to render class.description through the existing XML comment
helper before rendering fields. Ensure the description is included for root,
nested, and expanded classes while preserving the existing hoisted-class leaf
behavior and field-description rendering.
- Around line 665-683: Update append_to_field_content to XML-escape the
user-configurable suffix before inserting it into rendered content, ensuring
values containing characters such as & or < remain valid XML while preserving
the existing insertion and fallback behavior.
- Around line 337-348: Update the union rendering flow around
render_union_blocks and container so TypeIR::Map variants emit a typed <map>
root rather than the field tag or generic <value> wrapper. Preserve existing
rendering for non-map variants and null handling, then add round-trip coverage
for map | null and map | class cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 6f42c071-9f2e-41e3-9e2e-2bf1f69e9f87

📥 Commits

Reviewing files that changed from the base of the PR and between 869ae00 and 94f7de6.

📒 Files selected for processing (6)
  • engine/baml-lib/jinja-runtime/src/lib.rs
  • engine/baml-lib/jinja-runtime/src/output_format/types.rs
  • engine/baml-lib/jinja-runtime/src/output_format/xml.rs
  • engine/baml-lib/jsonish/src/jsonish/xmlish.rs
  • engine/baml-lib/jsonish/src/tests/test_xml.rs
  • fern/03-reference/baml/prompt-syntax/output-format.mdx
🚧 Files skipped from review as they are similar to previous changes (4)
  • engine/baml-lib/jinja-runtime/src/lib.rs
  • fern/03-reference/baml/prompt-syntax/output-format.mdx
  • engine/baml-lib/jinja-runtime/src/output_format/types.rs
  • engine/baml-lib/jsonish/src/jsonish/xmlish.rs

…ustive match, union maps, escaping, class descriptions)
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough pass. Pushed bb71619 addressing all five:

  1. Tag collisions — distinct raw field names that sanitize to the same XML tag now reject the class with a clear error instead of emitting indistinguishable fields.
  2. Exhaustive type handling — the union-variant catch-all resolves recursive aliases through the alias path, handles nested unions, and returns explicit errors for tuple/arrow/Top rather than serializing via to_string(); the scalar fallback is limited to genuine scalars.
  3. Union maps — map variants now render under a <map> root so the parser's variant disambiguation recognizes them. Added render→parse round-trip tests for both map | class and map | null.
  4. Escaping — the optional/null suffix is XML-escaped before insertion.
  5. Class descriptions — rendered as a leading XML comment for root, nested, and expanded classes, matching the JSON renderer.

641 tests pass (internal-baml-jinja + jsonish), cargo fmt clean. @aaronvg let me know if any of this diverges from the design doc.

This branch has not been deployed

No deployments
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.

2 participants