Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .repo-seed/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@
},
{
"path": "docs/postmortems/README.md",
"sha256": "f6b4879a58d15e90f9c899073554673d616be160795cfa2004c0c2145e859a87",
"sha256": "d24c314f3d100d022d47e1d7716522522740d025d73b1cfee7ec4fcea4751a79",
"category": "docs",
"capability": "postmortems"
},
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions Formula/rcm.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "cli"
version = "0.2.24"
version = "0.2.25"
edition = "2024"
publish = false

Expand Down
33 changes: 21 additions & 12 deletions crates/machine/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -766,33 +766,42 @@ fn merge_adjacent_system_messages(messages: &[Message]) -> Vec<Message> {

pub fn decode<'a>(choice: impl Iterator<Item = &'a AssistantContent>) -> Vec<Fragment> {
use rig::completion::message::MimeType;
let choice = choice.collect::<Vec<_>>();
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
// message we resend from that turn. A turn may contain multiple parallel
// tool_calls — we replicate the same reasoning onto each of them, so the
// turn survives being split into one Fragment per call.
//
// A completion response is one assistant turn. Providers may emit visible
// text before a tool call in that same turn, so its reasoning must remain
// available until every tool call in the response has been decoded.
let mut pending_reasoning: Vec<String> = 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::<Vec<_>>()
.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);
}
Expand Down
19 changes: 19 additions & 0 deletions crates/machine/tests/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/postmortems/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 4 additions & 1 deletion docs/specs/0004-deepseek-native-transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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)
Loading