From a91b883fda96450d7c74d696aa4c15a86e6bee38 Mon Sep 17 00:00:00 2001 From: Diego Colombo <> Date: Mon, 11 May 2026 17:41:03 +0100 Subject: [PATCH] fix: pass mode='json' to model_dump() in PyHookRegistry emit path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust hook registry calls try_model_dump() on every hook payload then passes the result directly to json.dumps() with no custom encoder. Bare model_dump() returns Python-native types — including Decimal for fields such as Usage.cost_usd — which json.dumps() cannot handle, producing: TypeError: Object of type Decimal is not JSON serializable Root cause for this crash is the hook emitter itself: it is the caller of json.dumps(), so it is the right place to request JSON-safe output. Passing mode="json" tells Pydantic to emit only JSON-native Python types (str, int, float, list, dict, bool, None) regardless of the field's Python type or whether a @field_serializer is present on the model. try_model_dump() is a single shared helper that serves both emit() and emit_and_collect() in hooks.rs, so this one-function change covers the entire emit path. Note: amplifier-core 1.5.1 added a @field_serializer on Usage.cost_usd which also fixes the immediate symptom, but the Docker worker image is built from 1.5.0. This fix is architecturally correct regardless of the model version — the hook system should not rely on individual field serializers to be JSON-safe. Rebuilding amplifier-cache:python after this merges will resolve the crash for all consumers without any per-consumer workarounds. Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- bindings/python/src/helpers.rs | 38 +++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/bindings/python/src/helpers.rs b/bindings/python/src/helpers.rs index c98e97c..869c2e8 100644 --- a/bindings/python/src/helpers.rs +++ b/bindings/python/src/helpers.rs @@ -3,6 +3,7 @@ // --------------------------------------------------------------------------- use pyo3::prelude::*; +use pyo3::types::PyDict; /// Parse an approval system's decision string into a boolean. /// @@ -33,14 +34,37 @@ pub(crate) fn wrap_future_as_coroutine<'py>( wrapper.call1((&future,)) } -/// Try `model_dump()` on a Python object (Pydantic BaseModel → dict). -/// Falls back to the original object reference if not a Pydantic model. +/// Try `model_dump(mode="json")` on a Python object (Pydantic BaseModel → JSON-safe dict). +/// +/// Serialization strategy (three-tier): +/// +/// 1. `model_dump(mode="json")` — preferred. Pydantic emits only JSON-native Python types +/// (str, int, float, list, dict, bool, None). Fields like `cost_usd: Decimal` that have a +/// `@field_serializer` convert correctly here. Any field type that Pydantic knows how to +/// JSON-encode is handled without extra effort. +/// +/// 2. `model_dump()` — fallback for objects whose `model_dump()` does not accept a `mode` +/// kwarg (e.g. hand-rolled fakes, legacy Pydantic v1 models). The caller (`emit()`) then +/// calls `json.dumps()` on the result; if any field is still non-JSON-native, that will +/// surface as a `TypeError` rather than silently returning the raw object. +/// +/// 3. Return the original object — if neither `model_dump` variant is callable. The caller +/// attempts `json.dumps()` on the raw object; if it is not serializable, a `TypeError` +/// propagates naturally. pub(crate) fn try_model_dump<'py>(obj: &Bound<'py, PyAny>) -> Bound<'py, PyAny> { - match obj.call_method0("model_dump") { - Ok(dict) => dict, - Err(e) => { - log::debug!("model_dump() failed (falling back to raw object): {e}"); - obj.clone() + let py = obj.py(); + // Tier 1: model_dump(mode="json") — JSON-safe output from real Pydantic models. + let kwargs = PyDict::new(py); + if kwargs.set_item("mode", "json").is_ok() { + if let Ok(dict) = obj.call_method("model_dump", (), Some(&kwargs)) { + return dict; } } + // Tier 2: bare model_dump() — for objects without a mode kwarg (fakes, Pydantic v1). + if let Ok(dict) = obj.call_method0("model_dump") { + return dict; + } + // Tier 3: raw object — let the caller's json.dumps() surface any TypeError. + log::debug!("model_dump() not available — passing raw object to json serialiser"); + obj.clone() }