fix(agents): keep null in the task output schema embedded in the prompt (#6775)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / Detect changes (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled

* fix(agents): keep null in the task output schema embedded in the prompt

build_task_prompt_with_schema embeds the task output schema into the prompt
via generate_model_description, whose strip_null_types defaults to True.
Combined with ensure_all_properties_required, an Optional[str] = None field
reaches the model as a required, non-nullable string, contradicting the
provider-side response schema generated from the same model.

That sanitizer targets OpenAI strict function-calling schemas. This call site
produces prompt prose, where those constraints do not apply.

Pass strip_null_types=False, matching the existing call for tool schemas in
utilities/agent_utils.py.

Fixes #6774

* test(agents): cover the output_json branch of the prompt schema

build_task_prompt_with_schema embeds a schema on both the output_json and
the output_pydantic branch, and this PR changes both. The regression test
only built a Task with output_pydantic, so the output_json branch shipped
unpinned.

Parameterize over both output attributes. Checked on this branch with the
fix reverted: both cases fail on the missing anyOf, and both pass with it.

Also compare the anyOf members as a set, so member order is not part of the
test contract.

---------

Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
This commit is contained in:
monkscode
2026-09-10 13:46:16 +05:30
committed by GitHub
parent 8616dca5c0
commit 5704ea08eb
2 changed files with 47 additions and 2 deletions

View File

@@ -74,13 +74,17 @@ def build_task_prompt_with_schema(task: Task, task_prompt: str) -> str:
if (task.output_json or task.output_pydantic) and not task.response_model:
if task.output_json:
schema_dict = generate_model_description(task.output_json)
schema_dict = generate_model_description(
task.output_json, strip_null_types=False
)
schema = json.dumps(schema_dict["json_schema"]["schema"], indent=2)
task_prompt += "\n" + I18N_DEFAULT.slice(
"formatted_task_instructions"
).format(output_format=schema)
elif task.output_pydantic:
schema_dict = generate_model_description(task.output_pydantic)
schema_dict = generate_model_description(
task.output_pydantic, strip_null_types=False
)
schema = json.dumps(schema_dict["json_schema"]["schema"], indent=2)
task_prompt += "\n" + I18N_DEFAULT.slice(
"formatted_task_instructions"

View File

@@ -0,0 +1,41 @@
"""Tests for crewai.agent.utils prompt-building helpers."""
from __future__ import annotations
import json
import pytest
from pydantic import BaseModel, Field
from crewai import Task
from crewai.agent.utils import build_task_prompt_with_schema
class _Output(BaseModel):
name: str
note: str | None = Field(default=None)
@pytest.mark.parametrize("output_attribute", ["output_pydantic", "output_json"])
def test_optional_fields_stay_nullable_in_the_prompt_schema(
output_attribute: str,
) -> None:
"""An Optional field must still be expressible as null in the prompt schema.
The provider-side response schema generated from the same model allows null,
so stripping it here hands the model two contradictory contracts and leaves
it no way to say "not applicable". Both output attributes embed a schema in
the prompt, so both are pinned.
"""
task = Task(description="d", expected_output="e", **{output_attribute: _Output})
prompt = build_task_prompt_with_schema(task, "")
start = prompt.index("{")
end = prompt.rindex("}", start) + 1
schema = json.loads(prompt[start:end])
assert {entry["type"] for entry in schema["properties"]["note"]["anyOf"]} == {
"string",
"null",
}