From aff618980fce6cc697695a50b21205576111d0ef Mon Sep 17 00:00:00 2001 From: Wario Date: Wed, 4 Nov 2026 07:42:41 -0800 Subject: [PATCH 1/3] Track Eredu main 465cf519: port to the planned-session generation API - Pin eredu, eredu-core and eredu-backend-mlx to jbg/eredu@465cf519 (MLX 0.32.3, new families, validated tool-call completion events). - native.rs: keep the PlannedModel returned by load_execution_plan (target-only), prepare through model_mut(), generate with GenerationRequest { output_mode: Semantic, control_handle: None, .. }, count SemanticEvent::ToolCallCompleted instead of the removed ToolCallEnd, and read NativeToolSupport::Unsupported { rejection }. - check-model path uses model_mut() for reset/synchronize. Portable verify (fmt, 13 mock tests, clippy -D warnings) passes locally; the native build is exercised by CI on macos-26. --- Cargo.lock | 33 ++++++++------- Cargo.toml | 6 +-- src/main.rs | 4 +- src/native.rs | 114 ++++++++++++++++++++++++++------------------------ 4 files changed, 81 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9a7baf8..2d75cbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -621,10 +621,11 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "eredu" version = "0.5.0" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "eredu-architectures", "eredu-backend-mlx", + "eredu-checkpoint", "eredu-core", "eredu-gguf", "eredu-media", @@ -643,7 +644,7 @@ dependencies = [ [[package]] name = "eredu-architectures" version = "0.5.0" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "eredu-checkpoint", "eredu-codec", @@ -663,7 +664,7 @@ dependencies = [ [[package]] name = "eredu-backend-mlx" version = "0.5.0" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "eredu-architectures", "eredu-backend-mlx-macros", @@ -687,7 +688,7 @@ dependencies = [ [[package]] name = "eredu-backend-mlx-macros" version = "0.1.0" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "proc-macro2", "quote", @@ -697,7 +698,7 @@ dependencies = [ [[package]] name = "eredu-checkpoint" version = "0.2.0" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "eredu-gguf", "safetensors", @@ -710,7 +711,7 @@ dependencies = [ [[package]] name = "eredu-codec" version = "0.5.0" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "eredu-checkpoint", "eredu-core", @@ -722,7 +723,7 @@ dependencies = [ [[package]] name = "eredu-core" version = "0.5.0" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "eredu-checkpoint", "eredu-gguf", @@ -735,7 +736,7 @@ dependencies = [ [[package]] name = "eredu-gguf" version = "0.1.0" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "half", "thiserror", @@ -744,7 +745,7 @@ dependencies = [ [[package]] name = "eredu-media" version = "0.4.0" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "eredu-core", "thiserror", @@ -753,7 +754,7 @@ dependencies = [ [[package]] name = "eredu-nn" version = "0.2.1" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "eredu-checkpoint", "eredu-nn-macros", @@ -763,7 +764,7 @@ dependencies = [ [[package]] name = "eredu-nn-macros" version = "0.1.0" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "proc-macro2", "quote", @@ -791,7 +792,7 @@ dependencies = [ [[package]] name = "eredu-runtime" version = "0.5.0" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "eredu-checkpoint", "eredu-core", @@ -809,7 +810,7 @@ dependencies = [ [[package]] name = "eredu-text" version = "0.1.1" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "base64 0.23.1", "chrono", @@ -2043,7 +2044,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "safemlx" version = "0.3.1" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "bytemuck", "half", @@ -2065,7 +2066,7 @@ dependencies = [ [[package]] name = "safemlx-internal-macros" version = "0.2.0" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "itertools 0.15.0", "proc-macro2", @@ -2076,7 +2077,7 @@ dependencies = [ [[package]] name = "safemlx-sys" version = "0.2.3" -source = "git+https://github.com/jbg/eredu?rev=d5683bb3a454a627b1023713a053d0a0d5a3c74a#d5683bb3a454a627b1023713a053d0a0d5a3c74a" +source = "git+https://github.com/jbg/eredu?rev=465cf519fc5e0f90a40072320aa1d546103fbcc8#465cf519fc5e0f90a40072320aa1d546103fbcc8" dependencies = [ "cmake", ] diff --git a/Cargo.toml b/Cargo.toml index de61a4a..a7c6906 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,9 +18,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } mesh-llm-plugin = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "bc215ce5b880e21c6c3840b8574ab64f83278821" } -eredu = { git = "https://github.com/jbg/eredu", rev = "d5683bb3a454a627b1023713a053d0a0d5a3c74a", default-features = false, optional = true } -eredu-core = { git = "https://github.com/jbg/eredu", rev = "d5683bb3a454a627b1023713a053d0a0d5a3c74a", optional = true } -eredu-backend-mlx = { git = "https://github.com/jbg/eredu", rev = "d5683bb3a454a627b1023713a053d0a0d5a3c74a", default-features = false, optional = true } +eredu = { git = "https://github.com/jbg/eredu", rev = "465cf519fc5e0f90a40072320aa1d546103fbcc8", default-features = false, optional = true } +eredu-core = { git = "https://github.com/jbg/eredu", rev = "465cf519fc5e0f90a40072320aa1d546103fbcc8", optional = true } +eredu-backend-mlx = { git = "https://github.com/jbg/eredu", rev = "465cf519fc5e0f90a40072320aa1d546103fbcc8", default-features = false, optional = true } [lints.rust] unsafe_code = "forbid" diff --git a/src/main.rs b/src/main.rs index 492baca..1923259 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,9 +41,9 @@ fn check_model(path: PathBuf) -> Result<()> { let owner = std::thread::spawn(move || -> Result<()> { let plan = ExecutionPlan::fully_resident(local_device_plan(default_local_device())?); - let planned = + let mut planned = LoadedModel::load_execution_plan(&MlxBackendFactory::default(), &path, &plan)?; - let (mut model, _) = planned.into_parts(); + let model = planned.model_mut(); model.reset()?; model.synchronize()?; println!("native load succeeded: {}", path.display()); diff --git a/src/native.rs b/src/native.rs index e89a693..e4c0e86 100644 --- a/src/native.rs +++ b/src/native.rs @@ -6,8 +6,8 @@ use crate::{ use anyhow::{Result, bail}; use eredu::{ api::{ - LoadedModel, PreparedChatGenerationRequest, PreparedChatGenerationSettings, - PreparedChatInput, default_local_device, local_device_plan, + GenerationRequest, LoadedModel, NoDrafting, OutputMode, PlannedModel, + PreparedChatGenerationSettings, PreparedChatInput, default_local_device, local_device_plan, }, runtime::chat::{ChatTemplateRequest, NativeToolSupport, ParallelToolCallPolicy, ToolChoice}, }; @@ -20,14 +20,16 @@ use eredu_core::{ use std::path::PathBuf; use tokio::sync::mpsc; -pub struct Native(LoadedModel<::Backend>); +type MlxBackend = ::Backend; + +/// Target-only generation owner: the plugin never selects drafting resources. +pub struct Native(PlannedModel); impl Native { pub fn load(path: PathBuf) -> Result { let plan = ExecutionPlan::fully_resident(local_device_plan(default_local_device())?); let planned = LoadedModel::load_execution_plan(&MlxBackendFactory::default(), path, &plan)?; - let (model, _) = planned.into_parts(); - Ok(Self(model)) + Ok(Self(planned.into_target_only())) } } @@ -43,13 +45,13 @@ impl Backend for Native { .messages .iter() .any(|m| m.get("tool_calls").is_some() || m["role"] == "tool"); - self.0.reset()?; + self.0.model_mut().reset()?; let tool_choice = match request.tool_choice.as_deref() { Some("none") => ToolChoice::None, Some("required") => ToolChoice::Required, _ => ToolChoice::Auto, }; - let prepared = self.0.prepare_chat(ChatTemplateRequest { + let prepared = self.0.model_mut().prepare_chat(ChatTemplateRequest { messages: request.messages, tools: request.tools, tool_choice, @@ -63,68 +65,69 @@ impl Backend for Native { ..Default::default() })?; if needs_tools - && let NativeToolSupport::Unsupported { reason } = prepared.native_tool_support() + && let NativeToolSupport::Unsupported { rejection } = prepared.native_tool_support() { - bail!("native tools unavailable for this template: {reason}"); + bail!("native tools unavailable for this template: {rejection}"); } let mut started_calls = 0usize; let mut ended_calls = 0usize; let cancellation = GenerationCancellationToken::new(); let mut delivery_failed = false; - let result = self - .0 - .generate_prepared_chat(PreparedChatGenerationRequest { - input: PreparedChatInput::rendered_prompt(&prepared), - settings: PreparedChatGenerationSettings { - overrides: GenerationConfigOverrides { - max_new_tokens: Some(request.max_tokens.unwrap_or(256)), - temperature: request.temperature, - do_sample: request.temperature.map(|t| t > 0.0), - ..Default::default() - }, + let result = self.0.generate(GenerationRequest { + input: PreparedChatInput::rendered_prompt(&prepared), + output_mode: OutputMode::Semantic, + control_handle: None, + settings: PreparedChatGenerationSettings { + overrides: GenerationConfigOverrides { + max_new_tokens: Some(request.max_tokens.unwrap_or(256)), + temperature: request.temperature, + do_sample: request.temperature.map(|t| t > 0.0), ..Default::default() }, - caller_stop_sequences: &[], - cancellation: cancellation.clone(), - on_event: |event| { - if work.is_cancelled() { - cancellation.cancel(); - return; + ..Default::default() + }, + caller_stop_sequences: &[], + cancellation: cancellation.clone(), + on_event: |event| { + if work.is_cancelled() { + cancellation.cancel(); + return; + } + let event = match event { + SemanticEvent::TextDelta(text) => Some(Event::Text(text)), + SemanticEvent::ToolCallStart { index, id, name } => { + started_calls += 1; + Some(Event::ToolStart { index, id, name }) } - let event = match event { - SemanticEvent::TextDelta(text) => Some(Event::Text(text)), - SemanticEvent::ToolCallStart { index, id, name } => { - started_calls += 1; - Some(Event::ToolStart { index, id, name }) - } - SemanticEvent::ToolArgumentsDelta { - index, - json_fragment, - } => Some(Event::ToolArguments { - index, - fragment: json_fragment, - }), - SemanticEvent::ToolCallEnd => { - ended_calls += 1; - None - } - _ => None, - }; - if let Some(event) = event - && publish(output, event).is_err() - { - delivery_failed = true; - cancellation.cancel(); + SemanticEvent::ToolArgumentsDelta { + index, + json_fragment, + } => Some(Event::ToolArguments { + index, + fragment: json_fragment, + }), + // Only a schema-validated, complete call closes a tool call. + SemanticEvent::ToolCallCompleted { .. } => { + ended_calls += 1; + None } - }, - })?; + _ => None, + }; + if let Some(event) = event + && publish(output, event).is_err() + { + delivery_failed = true; + cancellation.cancel(); + } + }, + })?; if delivery_failed || work.is_cancelled() { bail!("generation cancelled by consumer"); } if started_calls != ended_calls { bail!("generation ended with incomplete tool calls"); } - let reason = match result.finish_reason { + let reason = match result.finish_reason() { FinishReason::MaxTokens => "length", FinishReason::Cancelled => bail!("generation cancelled"), _ if started_calls > 0 => "tool_calls", @@ -134,8 +137,9 @@ impl Backend for Native { } fn settle(&mut self) -> Result<()> { - self.0.synchronize()?; - self.0.reset()?; + let model = self.0.model_mut(); + model.synchronize()?; + model.reset()?; Ok(()) } } From bd0d7a77776bf39bbc0a39692dc31d7f27749e31 Mon Sep 17 00:00:00 2001 From: Wario Date: Wed, 4 Nov 2026 09:11:50 -0800 Subject: [PATCH 2/3] Accept Mesh's thinking knobs: chat_template_kwargs.enable_thinking and reasoning_effort Through Mesh every thinking control (enable_thinking, reasoning_effort, ...) is canonicalized into chat_template_kwargs.enable_thinking and forwarded with the source field, which the strict request schema rejected (HTTP 400 for any request that turns thinking off, including tool calls with enable_thinking=false). Resolve the knobs with Mesh's precedence and pass remaining template kwargs to Eredu's extra_template_kwargs. --- README.md | 6 +++- src/native.rs | 1 + src/request.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6f63788..12fd530 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,11 @@ checkpoint-native function calls through OpenAI JSON/SSE. Tools require an Eredu registered native-tool template; unsupported templates fail closed. Tool requests support `tools`, `tool_choice` (`auto`, `none`, `required`), -`parallel_tool_calls` (default false), and `enable_thinking`. Named-function +`parallel_tool_calls` (default false), and thinking control through +`enable_thinking`, `reasoning_effort` (`none` disables thinking) or +`chat_template_kwargs.enable_thinking` — the form Mesh forwards, so these knobs +work through Mesh too. Other `chat_template_kwargs` reach the checkpoint template +unchanged. Named-function `tool_choice` objects are not supported and are rejected, not silently ignored. The plugin returns calls for your client to execute; it never executes tools. Replay the assistant message and a `role: "tool"` message with its `tool_call_id` diff --git a/src/native.rs b/src/native.rs index e4c0e86..780869b 100644 --- a/src/native.rs +++ b/src/native.rs @@ -61,6 +61,7 @@ impl Backend for Native { ParallelToolCallPolicy::Disabled }, enable_thinking: request.enable_thinking, + extra_template_kwargs: request.chat_template_kwargs, add_generation_prompt: true, ..Default::default() })?; diff --git a/src/request.rs b/src/request.rs index c500c29..c48ee68 100644 --- a/src/request.rs +++ b/src/request.rs @@ -1,6 +1,6 @@ use anyhow::{Result, bail}; use serde::Deserialize; -use serde_json::Value; +use serde_json::{Map, Value}; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -16,6 +16,12 @@ pub struct ChatRequest { #[serde(default)] pub parallel_tool_calls: bool, pub enable_thinking: Option, + /// OpenAI `reasoning_effort`; `none` disables thinking, the other levels enable it. + pub reasoning_effort: Option, + /// Checkpoint chat-template variables. Mesh canonicalizes every thinking knob + /// into `chat_template_kwargs.enable_thinking` before forwarding a request. + #[serde(default)] + pub chat_template_kwargs: Map, pub max_tokens: Option, pub temperature: Option, } @@ -61,8 +67,35 @@ impl ChatRequest { for message in &mut self.messages { normalize_message(message)?; } + self.enable_thinking = self.resolve_enable_thinking()?; Ok(self) } + + /// Same precedence as Mesh's reasoning normalization: `reasoning_effort`, + /// then the explicit `enable_thinking` flag, then caller-supplied + /// `chat_template_kwargs.enable_thinking`, which is authoritative. + fn resolve_enable_thinking(&mut self) -> Result> { + let mut enabled = match self.reasoning_effort.as_deref() { + None => None, + Some("none") => Some(false), + Some("minimal" | "low" | "medium" | "high" | "xhigh" | "max") => Some(true), + Some(_) => { + bail!( + "reasoning_effort must be one of none, minimal, low, medium, high, xhigh, max" + ) + } + }; + if let Some(explicit) = self.enable_thinking { + enabled = Some(explicit); + } + if let Some(value) = self.chat_template_kwargs.remove("enable_thinking") { + let Some(flag) = value.as_bool() else { + bail!("chat_template_kwargs.enable_thinking must be a boolean"); + }; + enabled = Some(flag); + } + Ok(enabled) + } } fn normalize_message(message: &mut Value) -> Result<()> { @@ -116,6 +149,49 @@ mod tests { assert_eq!(prepared.messages[1]["tool_call_id"], "call_7"); } + #[test] + fn thinking_knobs_follow_mesh_precedence() { + let prepare = |body: Value| { + serde_json::from_value::(body) + .map_err(anyhow::Error::from) + .and_then(|r| r.prepare("local")) + }; + let user = json!([{"role":"user","content":"hi"}]); + let prepared = prepare( + json!({"model":"local","messages":user,"enable_thinking":true, + "reasoning_effort":"none","chat_template_kwargs":{"enable_thinking":false,"custom":7}}), + ) + .unwrap(); + assert_eq!(prepared.enable_thinking, Some(false)); + assert_eq!( + prepared.chat_template_kwargs, + json!({"custom":7}).as_object().cloned().unwrap() + ); + let prepared = + prepare(json!({"model":"local","messages":user,"reasoning_effort":"low"})).unwrap(); + assert_eq!(prepared.enable_thinking, Some(true)); + let prepared = prepare( + json!({"model":"local","messages":user,"reasoning_effort":"none", + "enable_thinking":true}), + ) + .unwrap(); + assert_eq!(prepared.enable_thinking, Some(true)); + assert_eq!( + prepare(json!({"model":"local","messages":user})) + .unwrap() + .enable_thinking, + None + ); + assert!( + prepare(json!({"model":"local","messages":user,"reasoning_effort":"lots"})).is_err() + ); + assert!( + prepare(json!({"model":"local","messages":user,"chat_template_kwargs":{"enable_thinking":"no"}})) + .is_err() + ); + assert!(prepare(json!({"model":"local","messages":user,"use_reasoning":true})).is_err()); + } + #[test] fn rejects_nonobject_replayed_arguments() { let mut message = From e577e27e6e62ccd4456743e5279b138e0bb8f605 Mon Sep 17 00:00:00 2001 From: Wario Date: Wed, 4 Nov 2026 09:54:38 -0800 Subject: [PATCH 3/3] Replay assistant tool-call turns with null content The plugin emits OpenAI's content: null for a text-free tool call; replaying that message failed in Qwen3's template ('' in message.content on null). Normalize null/missing assistant content to an empty string before rendering. --- src/request.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/request.rs b/src/request.rs index c48ee68..7342d4a 100644 --- a/src/request.rs +++ b/src/request.rs @@ -109,6 +109,11 @@ fn normalize_message(message: &mut Value) -> Result<()> { if role == Some("tool") && object.get("tool_call_id").and_then(Value::as_str).is_none() { bail!("tool results require tool_call_id"); } + // OpenAI clients replay tool-call turns with `content: null` (the shape this plugin + // emits); checkpoint templates test `content` as text, so give them an empty string. + if role == Some("assistant") && object.get("content").is_none_or(Value::is_null) { + object.insert("content".to_string(), Value::String(String::new())); + } if let Some(calls) = object.get_mut("tool_calls") { let Some(calls) = calls.as_array_mut() else { bail!("tool_calls must be an array"); @@ -192,6 +197,20 @@ mod tests { assert!(prepare(json!({"model":"local","messages":user,"use_reasoning":true})).is_err()); } + #[test] + fn replayed_null_assistant_content_becomes_text() { + for mut message in [ + json!({"role":"assistant","content":null,"tool_calls":[]}), + json!({"role":"assistant","tool_calls":[]}), + ] { + normalize_message(&mut message).unwrap(); + assert_eq!(message["content"], ""); + } + let mut kept = json!({"role":"assistant","content":"x"}); + normalize_message(&mut kept).unwrap(); + assert_eq!(kept["content"], "x"); + } + #[test] fn rejects_nonobject_replayed_arguments() { let mut message =