kaxil commented on code in PR #70132:
URL: https://github.com/apache/airflow/pull/70132#discussion_r3755990602


##########
providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py:
##########
@@ -18,11 +18,29 @@
 
 from __future__ import annotations
 
+import json
 from typing import Any
 
 from pydantic import BaseModel, TypeAdapter, ValidationError
 
 
+def dump_output_to_json(output: Any) -> str:
+    """
+    Serialize an LLM output into the string carried through human review.
+
+    The inverse of :func:`rehydrate_pydantic_output`: both sides of the review
+    round-trip live here so they cannot drift apart.
+    """
+    if isinstance(output, BaseModel):
+        return output.model_dump_json()
+    if isinstance(output, str):
+        return output
+    try:
+        return TypeAdapter(type(output)).dump_json(output).decode()
+    except Exception:

Review Comment:
   This turns what used to be a crash into a silent repr. At the merge-base 
`approval.py` had no `try`/`except` here, so a non-serializable output raised 
before the reviewer saw anything; now `str(output)` runs and the reviewer is 
shown a Python object address -- `dump_output_to_json(Widget(3))` returns 
`'<__main__.Widget object at 0x1023430b0>'`.
   
   Reachable on the path the new test comment already names: 
`Agent(TestModel(), output_type=make_widget)` builds on just a `UserWarning` 
("Could not generate return schema for 'make_widget': unsupported return type 
... Falling back to unconstrained schema") and returns a `Widget`. That string 
goes into the approval body and the `airflow_hitl_review_agent_output_N` XComs, 
and comes back out of `rehydrate_pydantic_output` as the same plain string -- 
the repr-instead-of-JSON failure this PR is fixing. Preferring the fallback 
over the crash is right, but it should say so in the log; the module has no 
logger, and `utils/file_analysis.py` in the same package already has one.



##########
providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py:
##########
@@ -46,7 +65,19 @@ def rehydrate_pydantic_output(
     if output_type is str:
         return raw
     try:
-        rehydrated = TypeAdapter(output_type).validate_json(raw)
+        adapter = TypeAdapter(output_type)

Review Comment:
   `TypeAdapter` doesn't raise for an output function -- it builds a `call` 
schema -- so this guard never fires for them, and `adapter.validate_json(raw)` 
below validates `raw` as the function's *arguments* and invokes it. Verified on 
both the provider's `pydantic-ai>=2.0.0` floor and 2.22.0, with
   
   ```python
   def open_ticket(severity: int) -> Ticket:
       SIDE_EFFECTS.append(...)   # e.g. POST to Jira
       return Ticket(severity=severity)
   ```
   
   `rehydrate_pydantic_output(open_ticket, '{"severity":0}')` returns 
`Ticket(severity=0)` **and runs `open_ticket` a second time**. If the reviewer 
edits the approved string it re-runs with the edited arguments (`'{"severity": 
9999}'` gives a second call with `severity=9999`).
   
   That is new here: `Agent(TestModel(), output_type=open_ticket)` is accepted 
by pydantic-ai, and at the merge-base `isinstance(open_ticket, type)` was False 
so `execute()` took `json.loads` and the function was never re-invoked (same 
probe against the pre-PR source: no new side effects). Output functions are 
pydantic-ai's tool-shaped final-output hook, so the ones with side effects now 
fire twice, after the reviewer has already approved.
   
   The comment just below lists output functions as a case this branch handles, 
which is the tell: "did `TypeAdapter` raise" isn't the right test. A positive 
check works -- reject a non-class callable before adapting, and unwrap the 
markers instead of falling back for them (`ToolOutput(A).output`, 
`NativeOutput(A).outputs`, `PromptedOutput(A).outputs` all give `A`). That also 
fixes the smaller problem that `ToolOutput(A)` currently returns `{'x': 7}` 
where a bare `A` returns `A(x=7)`, with `serialize_output` ignored on that 
path, and it leaves `json.loads` serving only the genuinely un-buildable `[A, 
B]` union. Worth a test either way: 
`test_falls_back_to_json_when_output_type_has_no_schema` parametrizes only `[A, 
B]` and `ToolOutput(A)`, so the output-function case is asserted nowhere.



##########
providers/common/ai/src/airflow/providers/common/ai/mixins/approval.py:
##########
@@ -105,11 +104,8 @@ def defer_for_approval(
                 "require_approval."
             )
 
-        if isinstance(output, BaseModel):
-            output = output.model_dump_json()
-        elif not isinstance(output, str):
-            # JSON round-trip: execute_complete validates the string back into 
output_type.
-            output = TypeAdapter(type(output)).dump_json(output).decode()
+        # JSON round-trip: execute_complete validates the string back into 
output_type.
+        output = dump_output_to_json(output)

Review Comment:
   Heads-up for the rebase, since this is the only file that conflicts with 
`main`: #71046 added `raw_output = output` immediately above this block 
(`approval.py:125` on main) and reads it further down -- 
`isinstance(raw_output, list)` then `param_value = raw_output` -- to render the 
multi-select. Resolving the conflict by taking this side wholesale drops it and 
quietly breaks that path. The `pydantic` import removal is still correct after 
the rebase: `BaseModel` and `TypeAdapter` have no other use in the file.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to