Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,43 @@ LangfuseConnector("Standalone Agent example")
agent = Agent(chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"), tools=[...])
agent.run(messages=[ChatMessage.from_user("What's the weather in Berlin?")])
```

### `PromptBuilder` and `ChatPromptBuilder` template variables are required by default

**What changed:** `PromptBuilder` and `ChatPromptBuilder` now treat every Jinja2 template variable as required by default. Previously, variables were optional by default and missing values were silently rendered as empty strings. The `required_variables` parameter's default has been changed from `None` (all optional) to `"*"` (all required). Passing `required_variables=None` explicitly still opts into the old "all optional" behavior.

**Why:** Avoids silent rendering bugs where a missing variable produces an unexpectedly empty section of the prompt — especially in multi-branch pipelines where the issue often surfaces far from its root cause. Aligns the default with `ConditionalRouter`'s convention that inputs are required unless declared otherwise.

**How to migrate:**

Before (v2.x):
```python
from haystack.components.builders import PromptBuilder

# All variables were optional by default; missing values rendered as "".
builder = PromptBuilder(template="Hello, {{ name }}! {{ greeting }}")
builder.run(name="John") # greeting silently becomes "" → "Hello, John! "
```

After (v3.0):
```python
from haystack.components.builders import PromptBuilder

# Option 1: provide every variable (matches the new safe default).
builder = PromptBuilder(template="Hello, {{ name }}! {{ greeting }}")
builder.run(name="John", greeting="Welcome")

# Option 2: declare which variables are required; everything else stays optional.
builder = PromptBuilder(
template="Hello, {{ name }}! {{ greeting }}",
required_variables=["name"],
)
builder.run(name="John") # greeting renders as ""

# Option 3: restore the old "all optional" behavior.
builder = PromptBuilder(
template="Hello, {{ name }}! {{ greeting }}",
required_variables=None,
)
builder.run(name="John") # greeting renders as ""
```
23 changes: 13 additions & 10 deletions haystack/components/builders/chat_prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ class ChatPromptBuilder:

It constructs prompts using static or dynamic templates, which you can update for each pipeline run.

Template variables in the template are optional unless specified otherwise.
If an optional variable isn't provided, it defaults to an empty string. Use `variable` and `required_variables`
to define input types and required variables.
Template variables in the template are required by default. To make any subset of variables optional,
set `required_variables` to an explicit list of the variables that should remain required; any variable
not listed becomes optional and defaults to an empty string when missing.
Set `required_variables` to `None` to mark every variable as optional.

### Usage examples

Expand Down Expand Up @@ -139,7 +140,7 @@ class ChatPromptBuilder:
def __init__(
self,
template: list[ChatMessage] | str | None = None,
required_variables: list[str] | Literal["*"] | None = None,
required_variables: list[str] | Literal["*"] | None = "*",
variables: list[str] | None = None,
) -> None:
"""
Expand All @@ -151,8 +152,10 @@ def __init__(
the `init` method` or the `run` method.
:param required_variables:
List variables that must be provided as input to ChatPromptBuilder.
If a variable listed as required is not provided, an exception is raised.
If set to `"*"`, all variables found in the prompt are required. Optional.
Defaults to `"*"`, which marks every variable found in the prompt as required.
Pass an explicit list to only require a subset of the variables; any variable not listed becomes
optional and is replaced with an empty string in the rendered prompt when missing.
Set to `None` to mark every variable as optional.
:param variables:
List input variables to use in prompt templates instead of the ones inferred from the
`template` parameter. For example, to use more variables during prompt engineering than the ones present
Expand Down Expand Up @@ -192,10 +195,10 @@ def __init__(

if len(self.variables) > 0 and required_variables is None:
logger.warning(
"ChatPromptBuilder has {length} prompt variables, but `required_variables` is not set. "
"By default, all prompt variables are treated as optional, which may lead to unintended behavior in "
"multi-branch pipelines. To avoid unexpected execution, ensure that variables intended to be required "
"are explicitly set in `required_variables`.",
"ChatPromptBuilder has {length} prompt variables and `required_variables` is explicitly set to "
"`None`. This treats all prompt variables as optional, which may lead to unintended behavior in "
"multi-branch pipelines. Only set `required_variables` to `None` if you intentionally want all "
"variables to be optional.",
length=len(self.variables),
)

Expand Down
25 changes: 13 additions & 12 deletions haystack/components/builders/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ class PromptBuilder:
Renders a prompt filling in any variables so that it can send it to a Generator.

The prompt uses Jinja2 template syntax.
The variables in the default template are used as PromptBuilder's input and are all optional.
If they're not provided, they're replaced with an empty string in the rendered prompt.
The variables in the default template are used as PromptBuilder's input and are all required by default.
To make any subset of variables optional, set `required_variables` to an explicit list of the variables that
should remain required. Optional variables are replaced with an empty string in the rendered prompt.
To try out different prompts, you can replace the prompt template at runtime by
providing a template for each pipeline run invocation.

Expand Down Expand Up @@ -141,7 +142,7 @@ class PromptBuilder:
def __init__(
self,
template: str,
required_variables: list[str] | Literal["*"] | None = None,
required_variables: list[str] | Literal["*"] | None = "*",
variables: list[str] | None = None,
) -> None:
"""
Expand All @@ -151,12 +152,12 @@ def __init__(
A prompt template that uses Jinja2 syntax to add variables. For example:
`"Summarize this document: {{ documents[0].content }}\\nSummary:"`
It's used to render the prompt.
The variables in the default template are input for PromptBuilder and are all optional,
unless explicitly specified.
If an optional variable is not provided, it's replaced with an empty string in the rendered prompt.
The variables in the default template are input for PromptBuilder and are all required by default.
:param required_variables: List variables that must be provided as input to PromptBuilder.
If a variable listed as required is not provided, an exception is raised.
If set to `"*"`, all variables found in the prompt are required. Optional.
Defaults to `"*"`, which marks every variable found in the prompt as required.
Pass an explicit list to only require a subset of the variables; any variable not listed becomes
optional and is replaced with an empty string in the rendered prompt when missing.
Set to `None` to mark every variable as optional.
:param variables:
List input variables to use in prompt templates instead of the ones inferred from the
`template` parameter. For example, to use more variables during prompt engineering than the ones present
Expand Down Expand Up @@ -186,10 +187,10 @@ def __init__(

if len(self.variables) > 0 and required_variables is None:
logger.warning(
"PromptBuilder has {length} prompt variables, but `required_variables` is not set. "
"By default, all prompt variables are treated as optional, which may lead to unintended behavior in "
"multi-branch pipelines. To avoid unexpected execution, ensure that variables intended to be required "
"are explicitly set in `required_variables`.",
"PromptBuilder has {length} prompt variables and `required_variables` is explicitly set to `None`. "
"This treats all prompt variables as optional, which may lead to unintended behavior in "
"multi-branch pipelines. Only set `required_variables` to `None` if you intentionally want all "
"variables to be optional.",
length=len(self.variables),
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
upgrade:
- |
``PromptBuilder`` and ``ChatPromptBuilder`` now treat every Jinja2 template variable as **required by default**.
Previously, variables were optional by default unless explicitly listed in ``required_variables``.

To check if you're affected: look for ``PromptBuilder(...)`` or ``ChatPromptBuilder(...)`` calls in your code
that do not pass ``required_variables``. If those components relied on missing variables being silently
rendered as empty strings, the behavior depends on how the missing variable was reaching the builder:

- Calling ``builder.run()`` directly without providing a required variable now raises ``ValueError``.
- In a pipeline, if a required variable is neither provided as a user input nor connected from another
component, ``Pipeline.run`` raises ``ValueError("Missing mandatory input '<var>' for component '<name>'.")``.
- In a pipeline, if a required variable is connected to a sender that never produces output on a given run
(for example, a loop-fed input on the first iteration or a conditional branch that isn't taken), the
pipeline cannot make progress and raises ``PipelineComponentsBlockedError``.

Migration options:

- To require only a subset of variables, pass ``required_variables=["var1", "var2"]``.
- To preserve the previous "all optional" behavior, explicitly pass ``required_variables=None``.
- To require everything (the new default), no change is needed.
6 changes: 3 additions & 3 deletions test/components/agents/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ def test_to_dict(self, weather_tool, component_tool, monkeypatch):
"init_parameters": {
"template": "{{parrot}}",
"variables": None,
"required_variables": None,
"required_variables": "*",
},
},
"name": "parrot",
Expand Down Expand Up @@ -405,7 +405,7 @@ def test_from_dict(self, monkeypatch):
"init_parameters": {
"template": "{{parrot}}",
"variables": None,
"required_variables": None,
"required_variables": "*",
},
},
"name": "parrot",
Expand Down Expand Up @@ -552,7 +552,7 @@ def test_from_dict_state_schema_none(self, monkeypatch):
"init_parameters": {
"template": "{{parrot}}",
"variables": None,
"required_variables": None,
"required_variables": "*",
},
},
"name": "parrot",
Expand Down
54 changes: 36 additions & 18 deletions test/components/builders/test_chat_prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ def test_init(self):
ChatMessage.from_system("This is a {{ variable2 }}"),
]
)
assert builder.required_variables == []
assert builder.required_variables == "*"
assert builder.template[0].text == "This is a {{ variable }}"
assert builder.template[1].text == "This is a {{ variable2 }}"
assert builder._variables is None
assert builder._required_variables is None
assert builder._required_variables == "*"

# we have inputs that contain: template, template_variables + inferred variables
inputs = builder.__haystack_input__._sockets_dict
Expand All @@ -51,9 +51,9 @@ def test_init_without_template(self):
variables = ["var1", "var2"]
builder = ChatPromptBuilder(variables=variables)
assert builder.template is None
assert builder.required_variables == []
assert builder.required_variables == "*"
assert builder._variables == variables
assert builder._required_variables is None
assert builder._required_variables == "*"

# we have inputs that contain: template, template_variables + variables
inputs = builder.__haystack_input__._sockets_dict
Expand Down Expand Up @@ -93,10 +93,10 @@ def test_init_with_custom_variables(self):
variables = ["var1", "var2", "var3"]
template = [ChatMessage.from_user("Hello, {{ var1 }}, {{ var2 }}!")]
builder = ChatPromptBuilder(template=template, variables=variables)
assert builder.required_variables == []
assert builder.required_variables == "*"
assert builder._variables == variables
assert builder.template[0].text == "Hello, {{ var1 }}, {{ var2 }}!"
assert builder._required_variables is None
assert builder._required_variables == "*"

# we have inputs that contain: template, template_variables + variables
inputs = builder.__haystack_input__._sockets_dict
Expand Down Expand Up @@ -132,11 +132,18 @@ def test_run_without_input(self):
res = builder.run()
assert res == {"prompt": [ChatMessage.from_user("This is a template without input")]}

def test_run_with_missing_input(self):
builder = ChatPromptBuilder(template=[ChatMessage.from_user("This is a {{ variable }}")])
def test_run_with_missing_optional_input(self):
builder = ChatPromptBuilder(
template=[ChatMessage.from_user("This is a {{ variable }}")], required_variables=None
)
res = builder.run()
assert res == {"prompt": [ChatMessage.from_user("This is a ")]}

def test_run_with_missing_required_input_default(self):
builder = ChatPromptBuilder(template=[ChatMessage.from_user("This is a {{ variable }}")])
with pytest.raises(ValueError, match="variable"):
builder.run()

def test_run_with_missing_required_input(self):
builder = ChatPromptBuilder(
template=[ChatMessage.from_user("This is a {{ foo }}, not a {{ bar }}")], required_variables=["foo", "bar"]
Expand All @@ -163,7 +170,7 @@ def test_run_with_variables(self):
variables = ["var1", "var2", "var3"]
template = [ChatMessage.from_user("Hello, {{ name }}! {{ var1 }}")]

builder = ChatPromptBuilder(template=template, variables=variables)
builder = ChatPromptBuilder(template=template, variables=variables, required_variables=None)

template_variables = {"name": "John"}
expected_result = {"prompt": [ChatMessage.from_user("Hello, John! How are you?")]}
Expand All @@ -173,7 +180,7 @@ def test_run_with_variables(self):
def test_run_with_variables_and_runtime_template(self):
variables = ["var1", "var2", "var3"]

builder = ChatPromptBuilder(variables=variables)
builder = ChatPromptBuilder(variables=variables, required_variables=None)

template = [ChatMessage.from_user("Hello, {{ name }}! {{ var1 }}")]
template_variables = {"name": "John"}
Expand Down Expand Up @@ -209,7 +216,7 @@ def test_run_overwriting_default_template_with_variables(self):
variables = ["var1", "var2", "name"]
default_template = [ChatMessage.from_user("Hello, {{ name }}!")]

builder = ChatPromptBuilder(template=default_template, variables=variables)
builder = ChatPromptBuilder(template=default_template, variables=variables, required_variables=None)

template = [ChatMessage.from_user("Hello, {{ var1 }} {{ name }}!")]
expected_result = {"prompt": [ChatMessage.from_user("Hello, Big John!")]}
Expand Down Expand Up @@ -346,15 +353,26 @@ def test_example_in_pipeline_simple(self):
}
assert result == expected_dynamic

def test_warning_no_required_variables(self, caplog):
def test_warning_when_required_variables_explicitly_none(self, caplog):
with caplog.at_level(logging.WARNING):
_ = ChatPromptBuilder(
template=[
ChatMessage.from_system("Write your response in this language:{{language}}"),
ChatMessage.from_user("Tell me about {{location}}"),
],
required_variables=None,
)
assert "explicitly set to `None`" in caplog.text

def test_no_warning_with_default_required_variables(self, caplog):
with caplog.at_level(logging.WARNING):
_ = ChatPromptBuilder(
template=[
ChatMessage.from_system("Write your response in this language:{{language}}"),
ChatMessage.from_user("Tell me about {{location}}"),
]
)
assert "ChatPromptBuilder has 2 prompt variables, but `required_variables` is not set. " in caplog.text
assert "required_variables" not in caplog.text


class TestChatPromptBuilderWithJinja2TimeExtension:
Expand Down Expand Up @@ -697,7 +715,7 @@ def test_from_dict_template_none(self):

assert comp.template is None
assert comp._variables is None
assert comp._required_variables is None
assert comp._required_variables == "*"

def test_chat_message_list_with_templatize_part_init_raises_error(self):
template = [ChatMessage.from_user("This is a {{ variable | templatize_part }}")]
Expand All @@ -722,7 +740,7 @@ def test_init(self):

assert builder.template == template
assert builder._variables is None
assert builder._required_variables is None
assert builder._required_variables == "*"
assert builder.variables == ["name"]

def test_init_with_invalid_template(self):
Expand Down Expand Up @@ -774,13 +792,13 @@ def test_run_without_input(self):
result = builder.run()
assert result["prompt"] == [ChatMessage.from_user("Hello, my name is Lukas!")]

def test_run_with_missing_input(self):
def test_run_with_missing_optional_input(self):
template = """
{% message role="user" %}
Hello, my name is {{name}}!
{% endmessage %}
"""
builder = ChatPromptBuilder(template=template)
builder = ChatPromptBuilder(template=template, required_variables=None)
result = builder.run()
assert result["prompt"] == [ChatMessage.from_user("Hello, my name is !")]

Expand Down Expand Up @@ -826,7 +844,7 @@ def test_run_overwriting_default_template(self):
Hello, my name is {{name}}!
{% endmessage %}
"""
builder = ChatPromptBuilder(template=initial_template)
builder = ChatPromptBuilder(template=initial_template, required_variables=None)

runtime_template = """
{% message role="user" %}
Expand Down
Loading
Loading