Skip to content

feat(recipe): support structured parameters (object/array) in recipe templates#8934

Open
jordigilh wants to merge 13 commits intoaaif-goose:mainfrom
jordigilh:feat/structured-recipe-parameters
Open

feat(recipe): support structured parameters (object/array) in recipe templates#8934
jordigilh wants to merge 13 commits intoaaif-goose:mainfrom
jordigilh:feat/structured-recipe-parameters

Conversation

@jordigilh
Copy link
Copy Markdown

@jordigilh jordigilh commented Apr 30, 2026

Summary

Add object and array variants to RecipeParameterInputType and wire
structured parameter support end-to-end through the build pipeline, enabling:

  • Dot-notation access for objects: {{ signal.namespace }}
  • Iteration for arrays: {% for item in findings %}{{ item.name }}{% endfor %}
  • Conditionals on structured fields: {% if signal.severity == "critical" %}

Callers pass JSON strings via the existing Vec<(String, String)> API; the
build pipeline detects input_type: object/array, parses the strings into
serde_json::Value, and routes through the structured MiniJinja renderer.
No API signature changes to build_recipe_from_template or its callers.

Related discussion: #8917

Motivation

The current recipe template rendering accepts only HashMap<String, String>,
requiring callers to flatten structured data into individual string keys.
For recipes that process complex inputs (multi-field signals, arrays of
findings, enrichment metadata), this creates an impedance mismatch --
MiniJinja natively supports dot-notation, iteration, and conditionals on
structured data.

Changes

File Change
`crates/goose/src/recipe/mod.rs` Add `Object` and `Array` to `RecipeParameterInputType` enum
`crates/goose/src/recipe/template_recipe.rs` Add `render_recipe_content_with_structured_params` fn + 10 unit tests
`crates/goose/src/recipe/build_recipe/mod.rs` Wire structured rendering into `render_recipe_template` + `to_structured_params` helper
`crates/goose/src/recipe/build_recipe/tests.rs` 4 integration tests through `build_recipe_from_template`
`documentation/docs/guides/recipes/recipe-reference.md` Document `object` and `array` input types

How it works

  1. `render_recipe_template` checks if any recipe parameter has `input_type: object` or `array`
  2. If yes, `to_structured_params` converts the string map to `HashMap<String, serde_json::Value>` -- only keys matching object/array params are parsed as JSON; all others remain as `Value::String`
  3. The structured map is passed to `render_recipe_content_with_structured_params`, which feeds it directly to MiniJinja
  4. If no structured params exist, the existing string path is used (zero overhead)

All callers (`goose-cli`, `goose-server`, `summon`, `execute_commands`) benefit automatically since the change is internal to the build pipeline.

Scope and limitations

  • Desktop/CLI UX: Recipe editor dropdown and CLI `--params` prompting degrade gracefully (text input fallback for unknown types) but don't yet provide JSON-aware input widgets -- these are future work for the respective teams
  • API surface: `PUT /sessions/{id}/user_recipe_values` body remains `HashMap<String, String>` -- callers pass JSON-encoded strings for object/array params

Security model

The new function uses the same MiniJinja `Environment` setup as the existing
`render_recipe_content_with_params`:

  • Same `UndefinedBehavior::Strict`
  • Same `recipe_dir`-scoped template loader for `extends`/`include`
  • No additional filesystem access, eval, or code execution capabilities
  • MiniJinja's expression model prevents arbitrary code execution regardless
    of parameter structure

Test plan

  • `cargo check -p goose --lib` passes
  • `cargo fmt -p goose -- --check` clean
  • `cargo clippy` introduces no new warnings
  • 10 unit tests in `template_recipe.rs` (string compat, object dot-notation, conditionals, arrays, nesting, optional fields, mixed params, edge cases)
  • 4 serde round-trip tests in `mod.rs` (JSON object, JSON array, YAML mixed, enum round-trip)
  • 4 integration tests in `build_recipe/tests.rs` (object param, array param, mixed string+object, invalid JSON error)
  • Containerized test run on Fedora (all tests pass)

…templates

Add Object and Array variants to RecipeParameterInputType enum and
introduce render_recipe_content_with_structured_params() that accepts
HashMap<String, serde_json::Value>, enabling recipes to receive nested
objects and arrays directly without flattening to strings.

This is an additive, backward-compatible change. Existing string-based
parameter passing remains unchanged. The new function allows callers to
pass structured data (JSON objects, arrays) that MiniJinja can render
natively using dot-access and iteration.

Includes unit tests covering: object field access, conditionals,
array iteration, nested objects, and mixed scalar/structured params.

Ref: aaif-goose#8917
Made-with: Cursor
MiniJinja raises an undefined error when accessing a non-existent field
on an object (unlike Jinja2's default). Use 'is defined' test in the
unit test to match MiniJinja semantics.

Made-with: Cursor
…ay params

- Serde round-trip: JSON and YAML deserialization of input_type: object/array
  via Recipe::from_content, plus direct enum serialization/deserialization
- Negative/edge-case: empty array iteration, deeply nested object (3 levels),
  dot-access on scalar type (expected failure)

Made-with: Cursor
Add object and array to the input_type reference table, Input Types
section, parameter examples, and constraints callout.

Made-with: Cursor
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: adb49849c6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +186 to +189
Object,
/// Array parameter passed as a JSON array.
/// Enables iteration in templates: `{% for item in param %}`
Array,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wire object/array input types into recipe rendering

Adding Object and Array as valid input_type values here lets these recipes parse and validate, but the active build path still renders with HashMap<String, String> (apply_values_to_parameters and render_recipe_content_with_params in crates/goose/src/recipe/build_recipe/mod.rs), so structured values are never materialized as JSON objects/arrays. In practice, templates using {{ signal.namespace }} or {% for item in findings %} will fail at render time even though the recipe schema now accepts those input types.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for calling this out. The latest push (2de4daa) addresses this: render_recipe_template now detects input_type: object/array and routes through render_recipe_content_with_structured_params, parsing the JSON string values into serde_json::Value before MiniJinja rendering. All existing callers benefit without API changes. Four integration tests through build_recipe_from_template cover this end-to-end (object, array, mixed, and invalid-JSON error cases).

Comment on lines +360 to +363
- key: findings
input_type: array
requirement: optional
description: "List of diagnostic findings"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add default for optional findings example parameter

The new sample declares findings as requirement: optional but does not provide a default, which conflicts with the validator rule that optional parameters must include defaults (validate_optional_parameters in crates/goose/src/recipe/validate_recipe.rs). Users copying this reference snippet will hit a validation error immediately, so the docs now demonstrate a non-runnable recipe.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. Fixed in 2de4daa -- added default: "[]" to the findings example so it passes validate_optional_parameters.

- render_recipe_template now detects object/array input_type params and
  routes through render_recipe_content_with_structured_params, parsing
  JSON string values into serde_json::Value before template rendering
- All existing callers (CLI, server, summon, execute_commands) benefit
  automatically since the change is internal to the build pipeline
- Add to_structured_params helper that converts the string map to a
  Value map, parsing only keys whose input_type is object or array
- Add 4 integration tests through build_recipe_from_template: object
  param, array param, mixed string+object, invalid JSON error
- Fix docs example: add missing default for optional array parameter

Made-with: Cursor
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2de4daa8c1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +88 to +95
let value = if structured_keys.contains(k.as_str()) {
serde_json::from_str(v).map_err(|e| {
anyhow::anyhow!(
"Parameter '{}' has input_type object/array but value is not valid JSON: {}",
k,
e
)
})?
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate structured parameter shape by input_type

to_structured_params currently treats object and array parameters identically and only checks that the value is valid JSON. That allows mismatched payloads (e.g., input_type: array receiving {...} or input_type: object receiving [...]) to pass parsing and fail later during template rendering with confusing MiniJinja errors (item.name/field access on the wrong type). Since input_type semantics are now exposed in schema/docs, this should reject mismatches at conversion time by checking Value::is_object() vs Value::is_array() per parameter key and returning a targeted validation error.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good call. Fixed in 83db531 -- to_structured_params now validates that input_type: object receives a JSON object and input_type: array receives a JSON array, returning a targeted error like Parameter 'signal' has input_type object but received an array instead of letting mismatches surface as confusing MiniJinja template errors. Two new tests cover both mismatch directions.

Reject object/array type mismatches at conversion time instead of
letting them surface as confusing MiniJinja errors during rendering.
to_structured_params now checks Value::is_object() for input_type:
object and Value::is_array() for input_type: array, returning a
targeted error message on mismatch.

Made-with: Cursor
parse_recipe_content renders the template with no parameters to discover
variable names. With Lenient mode, dot-access on undefined values
(e.g., {{ signal.name }}) fails because Lenient forbids attribute access
on undefined. Chainable mode allows chaining, returning undefined instead
of erroring. This is needed for structured parameters (object/array)
that use dot-notation and iteration in templates.

The actual rendering phase still uses Strict mode via
render_recipe_content_with_params / render_recipe_content_with_structured_params.

Made-with: Cursor
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f174a245aa

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

&preprocessed_content,
recipe_dir,
UndefinedBehavior::Lenient,
UndefinedBehavior::Chainable,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep scalar dot-access failures in template validation

Using UndefinedBehavior::Chainable during parse_recipe_content allows {{ name.field }} to pass validation even when name is a scalar parameter, because undefined attribute access no longer errors in the parse phase. Those recipes then fail later at render time under Strict behavior (render_recipe_content_with_params / structured renderer), so malformed templates are now accepted and only break at execution.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is already handled by the validation layer. validate_parameters_in_template only normalizes dot-access for parameters with input_type: object or array. For a scalar parameter name, the template variable name.field is NOT normalized, so the validation correctly reports "missing: name.field, unnecessary: name", rejecting the recipe before it reaches the renderer.

Comment on lines +89 to +95
let parsed: Value = serde_json::from_str(v).map_err(|e| {
anyhow::anyhow!(
"Parameter '{}' has input_type {} but value is not valid JSON: {}",
k,
input_type,
e
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate structured defaults before render-time JSON parsing

Object/array values are first parsed in to_structured_params, which runs only in the rendering path. As a result, an optional structured parameter with an invalid default (for example, non-JSON text) passes recipe validation and only fails when the default is injected and parsed during build, turning a schema error into a late runtime error.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. Fixed in 3e95363 -- validate_optional_parameters now checks that object/array parameter defaults are valid JSON matching their input_type (object must be {}, array must be []). Invalid defaults are rejected during recipe validation instead of surfacing as late runtime errors.

validate_parameters_in_template now recognizes that template variables
like signal.name and signal.namespace are attribute accesses on the
signal parameter when it has input_type object or array. Dot-suffixed
template variables are normalized to their root key before comparison
with parameter definitions.

Made-with: Cursor
Add object and array to RecipeParameterInputType enum in the
generated openapi.json and types.gen.ts to match the Rust source.

Made-with: Cursor
Align recipeFormSchema.ts input_type enum with the generated
RecipeParameterInputType to fix TypeScript type compatibility.

Made-with: Cursor
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7626d92fa9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +119 to +121
let root = var.split('.').next().unwrap_or(var);
if structured_keys.contains(root) {
root.to_string()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize indexed structured vars before parameter diff

The new normalization only strips dot-notation (var.split('.')), so structured accesses that use indexing (for example {{ findings[0].name }} or {{ signal["namespace"] }}) are left as full paths and compared directly against parameter keys. That makes validate_parameters_in_template report these as missing definitions even when the root parameter (findings/signal) is correctly declared, blocking valid structured recipes during validation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. Fixed in 3e95363 -- extract_root_identifier now handles both dot-notation (signal.name) and bracket notation (findings[0].name, signal["namespace"]) by finding the earliest . or [ delimiter to extract the root identifier.

- extract_root_identifier handles both dot-notation (signal.name) and
  bracket notation (findings[0].name, signal["ns"]) when normalizing
  template variables to parameter keys
- validate_optional_parameters now checks that object/array parameter
  defaults are valid JSON matching their input_type, catching invalid
  defaults at validation time instead of at render time

Made-with: Cursor
…g_slice

`extract_root_identifier` previously used `&var[..pos]` which clippy
flags as potentially panicking on multi-byte UTF-8 characters.
Replace with `str::split` on `.` and `[` delimiters which is both
safe and more concise.

Made-with: Cursor
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.

1 participant