diff --git a/.repo-seed/manifest.json b/.repo-seed/manifest.json index c8a509d..dd98598 100644 --- a/.repo-seed/manifest.json +++ b/.repo-seed/manifest.json @@ -258,7 +258,7 @@ }, { "path": "docs/postmortems/README.md", - "sha256": "f6b4879a58d15e90f9c899073554673d616be160795cfa2004c0c2145e859a87", + "sha256": "d24c314f3d100d022d47e1d7716522522740d025d73b1cfee7ec4fcea4751a79", "category": "docs", "capability": "postmortems" }, diff --git a/Cargo.lock b/Cargo.lock index 442178b..5180f27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,7 +415,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli" -version = "0.2.24" +version = "0.2.25" dependencies = [ "accelerator", "anyhow", diff --git a/Formula/rcm.rb b/Formula/rcm.rb index 2f8765b..77cb153 100644 --- a/Formula/rcm.rb +++ b/Formula/rcm.rb @@ -1,7 +1,7 @@ class Rcm < Formula desc "Composable AI pipelines with .rcm files" homepage "https://github.com/EricSanchezok/recursive-context-machine" - version "0.2.24" + version "0.2.25" license "MIT" livecheck do @@ -13,20 +13,20 @@ class Rcm < Formula on_macos do if Hardware::CPU.arm? url "https://github.com/EricSanchezok/recursive-context-machine/releases/download/v#{version}/accelerate-aarch64-darwin.tar.gz" - sha256 "2cc19b4c6d309d3eb47631c51f872d2c72488e9af546599c5c109f7df9c1260d" + sha256 "de6c60a533467d5a88f7d1364f31373b8ec68061ec2e4bcc11c7da11d2cf05f9" else url "https://github.com/EricSanchezok/recursive-context-machine/releases/download/v#{version}/accelerate-x86_64-darwin.tar.gz" - sha256 "9be3f3abdcdd4153bd643ea15f4d6bc20c42413eff73fef0dfa95401a0e4bf9d" + sha256 "3ae9b877df2c7a1e2bd5d055c392cbbf1332755d2d02a0815a18f26680b108d4" end end on_linux do if Hardware::CPU.arm? url "https://github.com/EricSanchezok/recursive-context-machine/releases/download/v#{version}/accelerate-aarch64-linux.tar.gz" - sha256 "12f9668224dce2b1cadc8570ef7ae3b88b0b8faf2cc3bddabe59152c97628c59" + sha256 "d69533927be1c8cc3d59f9488d87a65be969fd231189a21393d62768bc57645f" else url "https://github.com/EricSanchezok/recursive-context-machine/releases/download/v#{version}/accelerate-x86_64-linux.tar.gz" - sha256 "bbfc081691507672b70de8f2c20cc2b71470080cdcb78a3ca31cb846dd2bb347" + sha256 "727299dd97c638c967a039711632a2ee7810eac990104d23a76b1489ab5bf75f" end end diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 3bd15a4..df1f29d 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cli" -version = "0.2.24" +version = "0.2.25" edition = "2024" publish = false diff --git a/crates/machine/src/completion.rs b/crates/machine/src/completion.rs index c7d3787..899298c 100644 --- a/crates/machine/src/completion.rs +++ b/crates/machine/src/completion.rs @@ -766,6 +766,7 @@ fn merge_adjacent_system_messages(messages: &[Message]) -> Vec { pub fn decode<'a>(choice: impl Iterator) -> Vec { use rig::completion::message::MimeType; + let choice = choice.collect::>(); let mut fragments = Vec::new(); // Thinking-mode providers (DeepSeek, Kimi) emit one reasoning block per // assistant turn and require it to be echoed back on every assistant @@ -773,26 +774,34 @@ pub fn decode<'a>(choice: impl Iterator) -> Vec = Vec::new(); + // A completion response is one assistant turn, but provider adapters do + // not agree on normalized content order. In particular, Rig 0.36 emits + // DeepSeek tool calls before their shared reasoning block. Collect the + // turn's reasoning first so replay is independent of that adapter order. + let shared_reasoning = choice + .iter() + .filter_map(|content| match content { + AssistantContent::Reasoning(reasoning) => { + let text = reasoning.display_text(); + (!text.is_empty()).then_some(text) + } + _ => None, + }) + .collect::>() + .join("\n"); + let shared_reasoning = (!shared_reasoning.is_empty()).then_some(shared_reasoning); + for content in choice { match content { AssistantContent::Text(text) => { fragments.push(Fragment::assistant(&text.text)); } - AssistantContent::Reasoning(reasoning) => { - let text = reasoning.display_text(); - if !text.is_empty() { - pending_reasoning.push(text); - } - } + AssistantContent::Reasoning(_) => {} AssistantContent::ToolCall(tc) => { let mut frag = Fragment::tool_call(&tc.id, &tc.function.name, tc.function.arguments.clone()); - if !pending_reasoning.is_empty() { - frag = frag.with_reasoning(pending_reasoning.join("\n")); + if let Some(reasoning) = shared_reasoning.as_deref() { + frag = frag.with_reasoning(reasoning); } fragments.push(frag); } diff --git a/crates/machine/tests/completion.rs b/crates/machine/tests/completion.rs index a32fa71..bdbc3a8 100644 --- a/crates/machine/tests/completion.rs +++ b/crates/machine/tests/completion.rs @@ -805,6 +805,25 @@ fn decode_attaches_reasoning_to_following_tool_call() { assert_eq!(tc.reasoning.as_deref(), Some("I should list the files")); } +/// Rig 0.36's provider-native DeepSeek adapter normalizes the response as +/// tool calls followed by the shared reasoning block. The block still belongs +/// to that one assistant turn and must be retained for the next request. +#[test] +fn decode_attaches_trailing_reasoning_to_preceding_tool_call() { + let response = [ + assistant_tool_call("call_1", "shell"), + assistant_reasoning("I should list the files"), + ]; + + let fragments = decode(response.iter()); + + assert_eq!(fragments.len(), 1, "reasoning is folded into the tool call"); + let Content::ToolCall(tc) = &fragments[0].content else { + panic!("expected ToolCall content"); + }; + assert_eq!(tc.reasoning.as_deref(), Some("I should list the files")); +} + #[test] fn decode_concatenates_multi_block_reasoning_before_tool_call() { let response = [ diff --git a/docs/postmortems/0004-provider-normalization-order-dropped-reasoning.md b/docs/postmortems/0004-provider-normalization-order-dropped-reasoning.md new file mode 100644 index 0000000..80872b3 --- /dev/null +++ b/docs/postmortems/0004-provider-normalization-order-dropped-reasoning.md @@ -0,0 +1,33 @@ +# Provider normalization order dropped reasoning + +Artifact-Version: 1 + +## Executive summary + +RCM v0.2.24 selected DeepSeek's provider-native transport and produced the correct replay request shape, but the pinned adapter normalized response tool calls before their shared reasoning block. RCM only attached reasoning already seen while decoding, so it lost the original block and replayed a placeholder that DeepSeek rejected. The durable correction treats every completion as one assistant turn and associates its reasoning with all tool calls regardless of normalized content order. + +## Summary + +A downstream production release canary still received HTTP 400 on the second request of a fixed multi-turn tool workflow. Content-free diagnostics proved that assistant content was a string, every tool call had one result, and reasoning was present in the rejected request. The remaining mismatch was semantic: the value was RCM's legacy placeholder rather than the reasoning returned by DeepSeek. + +## Timeline + +- RCM v0.2.24 replaced the generic OpenAI transport with Rig 0.36's native DeepSeek adapter. +- Local wire tests proved the outgoing request shape when reasoning was already stored on a tool-call fragment. +- Scholight adopted v0.2.24 and its hermetic provider contract passed. +- The live production canary returned HTTP 400 before stack mutation with a balanced thinking/tool-history diagnostic. +- Inspection of the pinned adapter showed that it emits normalized response content in text, tool-call, reasoning order. +- A regression reproduced the loss by decoding a tool call followed by reasoning. + +## Root cause + +RCM assumed normalized assistant content preserved the provider's logical reasoning-before-tool order. Its decoder accumulated reasoning while iterating and attached only the blocks seen before each tool call. Rig's native DeepSeek adapter returned the same assistant turn in a different internal order, so the tool-call fragment retained no reasoning. The next request satisfied the structural checks because the legacy thinking path synthesized a placeholder, but DeepSeek requires the original reasoning content to be replayed unchanged. + +The exact-wire regression did not catch this because it began with a manually constructed tool-call fragment whose reasoning was already attached. The hermetic downstream model returned reasoning in the order the regression expected instead of the order produced by the pinned native adapter. + +## Guardrails + +- [Order-independent decode regression](../../crates/machine/tests/completion.rs) covers the pinned adapter's tool-before-reasoning order as well as reasoning-before-tool and parallel calls. +- [Completion decoder](../../crates/machine/src/completion.rs) collects reasoning across the complete assistant turn before creating tool-call fragments. +- [DeepSeek native transport specification](../specs/0004-deepseek-native-transport.md) now makes response-order independence an explicit compatibility contract. +- The downstream live multi-turn provider canary remains a release gate before any production stack mutation. diff --git a/docs/postmortems/README.md b/docs/postmortems/README.md index 072fe3f..6103959 100644 --- a/docs/postmortems/README.md +++ b/docs/postmortems/README.md @@ -26,3 +26,4 @@ Name files `NNNN-short-title.md` (sequential number) and put `Artifact-Version: - [Explicit thinking mode was not sent](0001-explicit-thinking-mode-was-not-sent.md) - [Thinking tool-call content was omitted](0002-thinking-tool-call-content-was-omitted.md) - [Content presence did not prove DeepSeek shape](0003-content-presence-did-not-prove-deepseek-shape.md) +- [Provider normalization order dropped reasoning](0004-provider-normalization-order-dropped-reasoning.md) diff --git a/docs/specs/0004-deepseek-native-transport.md b/docs/specs/0004-deepseek-native-transport.md index 6dd115a..5bb5217 100644 --- a/docs/specs/0004-deepseek-native-transport.md +++ b/docs/specs/0004-deepseek-native-transport.md @@ -10,9 +10,10 @@ DeepSeek workflows must use a provider-native transport whose serialized request ## Contract - `deepseek` is an accepted RCM model protocol in the CLI, server adapter, serialized model configuration, and built-in provider registry. -- The release artifact carrying this contract is versioned `0.2.24`; downstream pins must use its published archive checksum. +- The native transport was introduced in `0.2.24`; order-independent reasoning replay is carried by `0.2.25`. Downstream pins must use the selected release's published archive checksum. - A `deepseek` model uses Rig's DeepSeek client and sends requests to its configured endpoint or the provider default. - A replayed pure assistant tool-call turn serializes `content` as the empty JSON string, preserves the original `reasoning_content` unchanged, and preserves its tool calls and following tool results. +- Reasoning belongs to the complete assistant turn. Response normalization order must not affect replay: reasoning emitted before or after tool-call content is attached to every tool call from that turn. - Explicit `thinking = "true"` or `thinking = "false"` produces the matching DeepSeek `thinking.type` request object; omission leaves the parameter absent. - A configured output-token limit remains present as `max_tokens` on the DeepSeek wire request even though the pinned Rig adapter does not project the generic field itself. - Existing `openai`, `anthropic`, and `gemini` declarations retain their public behavior. RCM does not infer DeepSeek from an endpoint or model name. @@ -25,6 +26,7 @@ Add the protocol variant at the model boundary, route it through Rig's DeepSeek ## Verification - A local HTTP fixture captures the actual DeepSeek adapter request and asserts the exact `content`, `reasoning_content`, `tool_calls`, `thinking`, and `max_tokens` JSON shape. +- Decode regressions cover both reasoning-before-tool and the pinned adapter's tool-before-reasoning normalization order, including parallel tool calls. - Compiler, server, provider-registry, serde, and legacy OpenAI tests cover the additive protocol and N-1-compatible behavior. - Machine, CLI, server, and accelerator tests pass together with workspace nextest, doc tests, formatting, Clippy, and repository governance gates. - A downstream live DeepSeek multi-turn tool canary succeeds before production adoption. @@ -38,3 +40,4 @@ Add the protocol variant at the model boundary, route it through Rig's DeepSeek - [Built-in provider registry](../../crates/accelerator/src/provider.rs) - [Provider-native transport decision](../decisions/0007-use-provider-native-deepseek-transport.md) - [Escaped request-shape postmortem](../postmortems/0003-content-presence-did-not-prove-deepseek-shape.md) +- [Adapter ordering postmortem](../postmortems/0004-provider-normalization-order-dropped-reasoning.md)