Skip to content

[Bug] Pydantic converter writes computed fields that models with extra="forbid" then reject #2052

Description

@sohom-cs

What are you really trying to do?

Pass a Pydantic model that has a @computed_field and model_config = ConfigDict(extra="forbid") as an activity or workflow argument or result, with pydantic_data_converter or with PydanticPayloadConverter(ToJsonOptions(...)) (the Deep Agents, Google ADK and OpenAI Agents plugins in temporalio/ai-integrations install the latter). One place this combination appears is models for LLM structured output: extra="forbid" gives the JSON schema additionalProperties: false, and a computed field adds a derived value to the dumped output.

Describe the bug

PydanticJSONPlainPayloadConverter.to_payload serializes with pydantic_core.to_json(value), or with SchemaSerializer.to_json(...) when ToJsonOptions is set. Both write computed fields. from_payload validates with TypeAdapter.validate_json, which reads the computed field as an input key, so the converter can't read back what it wrote:

  • extra="forbid": ValidationError (extra_forbidden), also when the model is nested (in a list, or as a field of another model). As an activity argument this becomes "Failed decoding arguments", a retryable ApplicationError (temporalio/worker/_activity.py:606-609), so the activity is retried like any other failure.
  • extra="allow": decodes, but the computed value is also stored as an extra, so the result doesn't equal the value sent (Allow(text='hi', length=2, length=2)).
  • Json[...] fields have the same problem: they are written as the parsed object, and validation expects a JSON string (json_type).

Models with the default extra="ignore" decode fine; the key is ignored.

Minimal Reproduction

from pydantic import BaseModel, ConfigDict, computed_field

from temporalio.contrib.pydantic import pydantic_data_converter


class Answer(BaseModel):
    model_config = ConfigDict(extra="forbid")
    text: str

    @computed_field
    @property
    def length(self) -> int:
        return len(self.text)


converter = pydantic_data_converter.payload_converter
payloads = converter.to_payloads([Answer(text="hi")])
print(payloads[0].data)
print(converter.from_payloads(payloads, [Answer]))

Output with temporalio 1.34.0 and pydantic 2.14.1:

b'{"text":"hi","length":2}'
pydantic_core._pydantic_core.ValidationError: 1 validation error for Answer
length
  Extra inputs are not permitted [type=extra_forbidden, input_value=2, input_type=int]

main at c1a79fb gives the same, with and without ToJsonOptions.

Environment/Versions

  • OS and processor: Linux x86_64
  • Temporal Version: temporalio 1.34.0, and main at c1a79fb. Pydantic 2.14.1 (the lock file's version); the encode side behaves the same on 2.0.3, 2.10.6, 2.11.0 and 2.12.0.
  • No Docker or Kubernetes.

Additional context

Proposed fix: pass round_trip=True to both serializer calls. Pydantic documents it as "dumped values should be valid as input for non-idempotent types such as Json[T]". In that mode it leaves computed fields out (from pydantic 2.4; 2.0 to 2.3 still write them) and writes Json[...] fields as JSON strings. In my checks it doesn't change aliases or inf/nan handling from #2039, plain dicts and lists, or stdlib dataclasses with init=False fields. Field serializers that read info.round_trip will see True.

Trade-off: this is a wire change. Computed fields are no longer in payloads. Python receivers recompute them on decode, but anything that reads the payload JSON directly (the UI, the CLI, workers or clients in other languages) no longer sees them. Json[...] fields change from an object to a string. A search of the agent SDKs in this repo's lock file (openai, openai-agents, anthropic, google-adk, google-genai, langchain-core, langgraph, deepagents, mcp, strands-agents) found no computed fields or Json[...] fields, so the plugins' own payloads keep their bytes.

Decoding is unchanged, so payloads already written (in histories) decode as they do today: extra="ignore" and extra="allow" models still read them, and extra="forbid" models still fail on them. Old and new workers can run side by side, since each reads the other's payloads at least as well as today. Core's replay check for a scheduled activity compares its id and type, not its input (activity_state_machine.rs), so histories still replay. I read that code; I didn't run a replay.

Alternatives I tried:

  1. exclude_computed_fields=True instead of round_trip=True: the same wire change for computed fields, and Json[...] fields stay broken. It exists only on SchemaSerializer.to_json and only from pydantic 2.12 (pydantic_core.to_json has no such parameter), so the default path would have to switch serializers and older pydantic would need a guard.
  2. Keep the bytes and drop computed-field keys on decode. Retrying with validate_json(..., extra="ignore") (pydantic 2.12+) also drops the real extras of any extra="allow" model in the same payload. Deleting the keys at the error locations breaks on unions, whose error locations include member names that aren't JSON keys. It would read old payloads, at the cost of more code and version checks on the decode path.
  3. An opt-in ToJsonOptions(round_trip=True): no wire change by default, but the default converter keeps writing payloads it can't read.

I'd go with round_trip=True, and I'm opening a PR with it and a test. If computed fields need to stay on the wire, I can look at a decode-side version instead.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions