Repository navigation
[Bug] Pydantic converter writes computed fields that models with extra="forbid" then reject #2052
Description
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 retryableApplicationError(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
mainat 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:
exclude_computed_fields=Trueinstead ofround_trip=True: the same wire change for computed fields, andJson[...]fields stay broken. It exists only onSchemaSerializer.to_jsonand only from pydantic 2.12 (pydantic_core.to_jsonhas no such parameter), so the default path would have to switch serializers and older pydantic would need a guard.- 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 anyextra="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. - 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