Skip to content

Fail the task when a reviewed output no longer matches output_type - #74473

Merged
kaxil merged 11 commits into
apache:mainfrom
astronomer:common-ai-review-edit-validation
Oct 14, 2026
Merged

kaxil merged 11 commits into
apache:mainfrom
astronomer:common-ai-review-edit-validation

Conversation

@pankajkoti

@pankajkoti pankajkoti commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

When a reviewer edits the output of an LLMOperator approval gate, the edited text is validated back into the operator's output_type. If validation failed, rehydrate_pydantic_output silently returned the raw string. The task succeeded and downstream tasks received a str instead of an int, a list or a model. For example output_type=int with the edit "thirty-one" produced the string 'thirty-one'.

It now raises ReviewedOutputValidationError, a ValueError subclass in the provider's exceptions module. The message names the output type, the text received (truncated) and the pydantic validation message, so the task fails with a readable error. Details:

  • output_type=str still accepts any text.
  • Types whose value is plain text (a str Literal, a str Enum, str | None) still accept bare text. For these the message reports the plain text failure instead of "Invalid JSON". A JSON edit with a typo, such as a trailing comma, keeps the JSON error, because the plain text attempt would only report a top-level type mismatch.
  • Only an edit can fail the task. An unedited approval whose output cannot be validated back (for example a model with an irreversible serializer) returns the text unchanged and logs a warning, as on main.
  • The HITL form hands back an object output as a parsed dict, which rounds integers above 2**53, writes 1.0 as 1 and may reorder keys. An untouched object is compared by value the way the browser parses it, so it is never read as an edit.
  • An output_type that pydantic has no schema for, a pydantic-ai output function or marker (such as ToolOutput), and an output function whose parameters pydantic wraps in a definitions schema, still pass the text through. There is nothing to validate against, or validating would call the function again with the reviewed text. No warning is logged for this passthrough. A union with a str member, such as Model | str, keeps an edit that is not the model as text.

The approval gate (LLMOperator and its subclasses) builds the review string with dump_output_to_json. It dumps by field name with round_trip=True, and validation accepts a field name, and an alias unless the model sets validate_by_alias=False. So models with aliases, including serialization-only aliases, validate back, and computed fields are left out. The decision-policy review body now shows the same JSON as the edit field. AgentOperator never validates its review text back, so it uses dump_output_for_display, a plain JSON dump that keeps computed fields visible to the reviewer. Both fall back to str() with a warning for values pydantic cannot serialize.

LLMSchemaCompareOperator re-validated the string after the parent returned and raised its own ValueError. That fallback is no longer reachable, so the override is removed. The new exception is still a ValueError.

Behavior change: an invalid reviewer edit now fails the task instead of passing a string downstream.

Related PRs: #74537 (merged) made AgentOperator return the output it already holds instead of converting the approved text, so this PR no longer touches its review loop apart from regenerated output using the shared helper. #70132 (open) also edits rehydrate_pydantic_output and mixins/hitl_review.py, and #74493 (open) overlaps operators/llm.py. Whichever merges later will need a rebase. A note for the behavior change is added to the provider changelog.

Follow-up idea, not part of this PR: validate the edit when the reviewer submits the form, so they see the error immediately instead of the task failing after approval.


Was generative AI tooling used to co-author this PR?
  • Yes (Claude Code, Sonnet 5.5)

Generated-by: Claude Code (Sonnet 5.5) following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

🤖 Generated with Claude Code

Comment thread providers/common/ai/src/airflow/providers/common/ai/mixins/hitl_review.py Outdated
Comment thread providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py Outdated
Comment thread providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py Outdated
Comment thread providers/common/ai/tests/unit/common/ai/utils/test_output_type.py
Comment thread providers/common/ai/tests/unit/common/ai/operators/test_agent.py Outdated
@pankajkoti
pankajkoti force-pushed the common-ai-review-edit-validation branch from 942a114 to 13c9cb1 Compare October 9, 2026 07:32
@pankajkoti
pankajkoti requested a review from kaxil October 9, 2026 07:32
Comment thread providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py Outdated
Comment thread providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py Outdated
Comment thread providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py Outdated
pankajkoti and others added 3 commits October 12, 2026 19:01
When a reviewer edits the output in the approval form, the edited text is
validated back into the operator's output_type. On a validation failure
rehydrate_pydantic_output silently returned the raw string, so the task
succeeded and downstream tasks received a str instead of an int, a list or
a model. It now raises ReviewedOutputValidationError, a ValueError subclass
that names the output type, the text received and the pydantic message.
output_type=str still passes any text through. Types whose value is bare
text, such as a str Literal, an Enum or str | None, are still accepted
as plain text. An output_type that pydantic has no schema for keeps passing
the text through, since there is nothing to validate against.

AgentOperator review serialized non-model outputs with str(), which gives a
Python repr for a list and would now fail an unedited approval. It uses
JSON instead, for the first output and for regenerated ones. Values that
pydantic cannot serialize keep str().

LLMSchemaCompareOperator re-validated the string after the parent returned
and raised its own ValueError. That fallback can no longer be reached, so
the override is removed.

Co-Authored-By: Claude <noreply@anthropic.com>
…t functions

An unedited approval of a model with field aliases failed because the
review string was dumped with field names while validation expects the
aliases. The review serializers now dump by alias. An output function used
as output_type was validated as if the text were its arguments, so an
unedited approval raised and the function could be called a second time.
It now returns the text unchanged until it is handled properly.

The validation error now reports the plain text failure when the JSON
attempt only failed because the text is not JSON, and it shortens a long
received text. Tests now pin the regenerated output serialization and the
enum conversion, and the agent review test is renamed to match the only
way that operator can reach the error.

Co-Authored-By: Claude <noreply@anthropic.com>
The review serializers dumped by alias, which breaks models whose alias is
serialization-only, so an unedited approval failed validation. Both
serializers now share dump_output_to_json, which dumps by field name with
round_trip. Validation accepts a field name or an alias, bare text gets one
retry with surrounding whitespace removed, and an output function is
recognised even when pydantic wraps it in a definitions schema.

An unedited approval of an output that cannot be validated back now returns
the text with a warning, and only an edited output fails the task. Types
pydantic cannot build a schema for keep passing the text through.

Tests cover the no-schema and unresolved forward reference types, models
serialized by alias, computed fields, and the unedited tolerance.

Co-Authored-By: Claude <noreply@anthropic.com>
@pankajkoti
pankajkoti force-pushed the common-ai-review-edit-validation branch from 13c9cb1 to d869d0e Compare October 12, 2026 18:47
Comment thread providers/common/ai/src/airflow/providers/common/ai/mixins/hitl_review.py Outdated
Comment thread providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py Outdated
AgentOperator never validates its review string back, so the round-trip
dump only hid computed fields from the reviewer. The review text now comes
from dump_output_for_display, and dump_output_to_json with round_trip stays
for the approval gate where an edit is validated back. Both share one
helper that keeps the str() fallback and its warning.

A JSON edit with a typo such as a trailing comma reported a top-level type
mismatch from the plain text attempt. The message now keeps the JSON error
when the text error is only a container mismatch, and bare-text types such
as Literal, Enum and int still report the text error.

Co-Authored-By: Claude <noreply@anthropic.com>
@pankajkoti
pankajkoti requested a review from kaxil October 13, 2026 11:19
kaxil and others added 4 commits October 14, 2026 02:26
…e-validators

The Airflow HITL form parses a JSON-object value into an object before sending it back, so
approving a model output in the UI handed the mixin a dict and failed the task even when
nothing was edited. Dump a dict to compact JSON, as a list already is, so an untouched
object compares equal to the generated output.

The bare-text retry feeds a string to validators that may only expect the type's input, so
a mode="before" validator calling data.get raised AttributeError past the except tuple, on
an unedited approval too. Any failure on that retry now just means "not bare text", and the
JSON error is reported.

A JSON typo in a union, dataclass, frozenset or NamedTuple edit now reports the JSON error,
pydantic-ai output markers pass the text through instead of raising AttributeError, and the
docs and changelog say how a failed edit behaves under retries.
The HITL form round-trips an object output through JavaScript, which rounds integers
above 2**53, writes 1.0 as 1 and may reorder keys (as may the metadata database). The
re-dumped text then differed from the generated output, so an untouched approval read as
an edit: a large id could change silently, and a model whose dump does not validate back
failed the task. Compare values the way the browser parses them, with a bool kept distinct
from a number, and keep the generated output unchanged when they match.

A before-validator that raises AttributeError, KeyError or IndexError on a JSON edit that is
not an object now fails with ReviewedOutputValidationError on the JSON attempt too, while any
other validator error (a timeout, say) still propagates instead of reading as a failed
conversion. An object or array the reviewer
typed reports its JSON error even when the type also takes bare text, and the docs no
longer claim a retry opens a new review on every Airflow version.
@kaxil

kaxil commented Oct 14, 2026

Copy link
Copy Markdown
Member

:( test failure:

FAILED providers/common/ai/tests/unit/common/ai/utils/test_output_type.py::TestRehydratePydanticOutput::test_error_reports_the_json_error_for_plain_text_sent_to_a_structured_type[arguments_type] - AssertionError: Regex pattern did not match.
  Expected regex: 'Invalid JSON'
  Actual message: "The reviewed output could not be converted to the output_type Point. Received 'hello'. 1 validation error for Point\n  Input should be a tuple, list, dictionary or an instance of Point [type=named_tuple_type, input_value='hello', input_type=str]\n    For further information visit https://errors.pydantic.dev/2.14/v/named_tuple_type"

https://github.com/apache/airflow/actions/runs/38529714144/job/115743582407?pr=74473

… 2.14

Pydantic 2.14 raises named_tuple_type instead of arguments_type when a whole
string meets a NamedTuple. The error was missing from the container mismatch
set, so the review message showed the plain text error where it should show
the JSON error. The set now lists both, and the test notes the split.

Co-Authored-By: Claude <noreply@anthropic.com>
@pankajkoti

pankajkoti commented Oct 14, 2026 •

Copy link
Copy Markdown
Member Author

:( test failure:

FAILED providers/common/ai/tests/unit/common/ai/utils/test_output_type.py::TestRehydratePydanticOutput::test_error_reports_the_json_error_for_plain_text_sent_to_a_structured_type[arguments_type] - AssertionError: Regex pattern did not match.

Yes :) Looks like the test was newly added in the recent commit a4f58a9. It seems to have failed because pydantic 2.14 renamed the NamedTuple error from arguments_type to named_tuple_type, which the set of container mismatch errors did not list. I attempted a fix in 2139a29 by adding named_tuple_type to the list

@kaxil

kaxil commented Oct 14, 2026

Copy link
Copy Markdown
Member

:( test failure:

FAILED providers/common/ai/tests/unit/common/ai/utils/test_output_type.py::TestRehydratePydanticOutput::test_error_reports_the_json_error_for_plain_text_sent_to_a_structured_type[arguments_type] - AssertionError: Regex pattern did not match.

Yes :) Looks like the test was newly added in the recent commit a4f58a9. It seems to have failed because pydantic 2.14 renamed the NamedTuple error from arguments_type to named_tuple_type, which the set of container mismatch errors did not list. I attempted a fix in 2139a29 by adding named_tuple_type to the list

Yeah, I was hoping to get CI to green at midnight to merge to this in 1.0.0rc1 😬

…ame pattern

A decision-policy review body showed the output's Python repr, so the docs' advice to copy
"the JSON shown in the review body" into the edit field did not hold there. It now shows the
same JSON the default body and the edit field use.

Telling a JSON typo from plain text relied on a list of pydantic error names, which pydantic
2.14 renamed for a NamedTuple and a deque. Match the "_type" suffix those errors share, plus
"sequence_str", instead.

Rehydrating no longer forces validation by alias, which overrode a model that sets
validate_by_alias=False: with an alias named like another field, an untouched approval
swapped the two values. Field names and, by default, aliases are still accepted.

A multi-select edit is dumped without escaping non-ASCII, like the generated output, so an
untouched choice no longer reads as an edit. The changelog no longer says a Literal or str
Enum keeps accepting free text, and the docs say a union with a str member keeps an edit
that is not the model as text.
@kaxil
kaxil merged commit dd7e8ab into apache:main Oct 14, 2026
85 checks passed
@kaxil
kaxil deleted the common-ai-review-edit-validation branch October 14, 2026 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants