diff --git a/ds4_server.c b/ds4_server.c
index 45a53469c..2979a001b 100644
--- a/ds4_server.c
+++ b/ds4_server.c
@@ -10614,6 +10614,42 @@ static void remember_thinking_checkpoint(server *s, server_slot *slot,
free(visible);
}
+/* Thinking + tool calls over chat/completions. The sampled KV holds this turn's
+ * hidden reasoning, but the next request re-renders the turn with an empty
+ * block: clients such as Hermes replay the tool call bytes but
+ * not the reasoning bytes. Exact DSML tool replay realigns the tool-call tokens,
+ * yet the live checkpoint still diverges from the next prompt at the very first
+ * reasoning token, so every follow-up turn misses the cache (memory_miss_reason
+ * token-mismatch with live_prompt_common capped at the user boundary).
+ *
+ * Mirror the tool-less thinking path: remember the reasoning-stripped visible
+ * transcript (prompt_text + + content + DSML + eos, i.e. exactly what
+ * render_chat_prompt_text() emits for this completed turn on the next request) as
+ * a key for the live frontier. The next request then continues from the verbatim
+ * live KV -- hidden reasoning included -- while tokenizing only the new suffix.
+ * thinking_live_visible_prefix_prompt() validates the key with an exact byte
+ * prefix match and a live-token-count check, so a stale or mismatched key simply
+ * falls through to the existing cold-prefill behaviour. */
+static void remember_tool_thinking_checkpoint(server *s, server_slot *slot,
+ const job *j,
+ const char *content,
+ const tool_calls *calls) {
+ if (!s || !j->req.prompt_text || !ds4_think_mode_enabled(j->req.think_mode)) {
+ thinking_live_clear(s, slot);
+ return;
+ }
+ /* calls may be empty: a tools-enabled request whose thinking turn ended in a
+ * final answer with no tool call renders as prompt + + content + eos,
+ * which build_tool_checkpoint_suffix() produces with an empty call list. */
+ char *suffix = build_tool_checkpoint_suffix(&j->req, content, "", calls);
+ buf visible = {0};
+ buf_puts(&visible, j->req.prompt_text);
+ buf_puts(&visible, suffix ? suffix : "");
+ thinking_live_remember(s, slot, visible.ptr ? visible.ptr : "");
+ buf_free(&visible);
+ free(suffix);
+}
+
/* After a successful tool-call finish, make the live checkpoint match what the
* next request will render. Usually that is just the exact DSML remembered by
* tool id. If a client sends a tool call without an id we know, the fallback
@@ -11881,8 +11917,7 @@ static void generate_job(server *s, server_slot *slot, job *j) {
}
if (j->req.kind == REQ_CHAT && parsed_calls.len &&
- j->req.api != API_RESPONSES &&
- should_canonicalize_tool_checkpoint(s, &parsed_calls))
+ j->req.api != API_RESPONSES)
{
/* Chat/completions has no protocol object that binds the next request
* to this live KV state. Canonicalize only the fallback tool-call
@@ -11890,17 +11925,50 @@ static void generate_job(server *s, server_slot *slot, job *j) {
* replaying those bytes keeps future prompts aligned without rebuilding
* hidden reasoning. Responses deliberately skips this path because its
* previous_response_id contract binds the next turn to live state. */
- canonicalize_tool_checkpoint(s, slot, j, ctx_span, trace_id,
- parsed_content ? parsed_content : "",
- parsed_reasoning, &parsed_calls);
- thinking_live_clear(s, slot);
+ if (should_canonicalize_tool_checkpoint(s, &parsed_calls)) {
+ canonicalize_tool_checkpoint(s, slot, j, ctx_span, trace_id,
+ parsed_content ? parsed_content : "",
+ parsed_reasoning, &parsed_calls);
+ }
+ /* Whether the tool-call bytes are realigned next turn by exact DSML
+ * replay or by canonicalization, a thinking model still leaves this
+ * turn's hidden reasoning in the live KV. The next request re-renders
+ * the turn with an empty (clients replay tool-call bytes
+ * but not reasoning bytes), so the live checkpoint diverges at the first
+ * reasoning token -- and reasoning-inclusive canonicalization cannot
+ * reconcile that either. Remember a reasoning-stripped visible key for
+ * the live frontier so the next request continues from live KV. This is
+ * self-validating: thinking_live_visible_prefix_prompt() only reuses it
+ * on an exact byte-prefix match, otherwise it is a no-op. */
+ remember_tool_thinking_checkpoint(s, slot, j,
+ parsed_content ? parsed_content : "",
+ &parsed_calls);
} else if (parsed_calls.len) {
thinking_live_clear(s, slot);
- } else if (!parsed_calls.len &&
- should_remember_thinking_checkpoint(&j->req, &thinking, final_finish)) {
+ } else if (should_remember_thinking_checkpoint(&j->req, &thinking, final_finish)) {
remember_thinking_checkpoint(s, slot, j, ctx_span, trace_id,
parsed_content ? parsed_content : "");
- } else if (!parsed_calls.len) {
+ } else if (j->req.kind == REQ_CHAT && j->req.api != API_RESPONSES &&
+ j->req.has_tools &&
+ ds4_think_mode_enabled(j->req.think_mode) && !thinking.inside &&
+ strcmp(final_finish, "error") && strcmp(final_finish, "length")) {
+ /* Tools-enabled thinking turn that ended in a final answer with no tool
+ * call. should_remember_thinking_checkpoint() excludes has_tools, and
+ * the tool-call remember paths above require parsed_calls -- so without
+ * this the live frontier is cleared and the next request re-prefills from
+ * the user boundary even though only the hidden reasoning diverges. The
+ * empty-call remember produces prompt + + content + eos, which
+ * is exactly what render_chat_prompt_text() emits for this completed turn
+ * in a tool-using conversation (tool_context forces on
+ * every assistant turn). Do NOT gate on prompt_preserves_reasoning: that
+ * flag is really "history uses tools" (always true here) and would
+ * disable this path for the entire agent workload. The reuse is still
+ * validated by an exact byte-prefix match, so a client that genuinely
+ * replays reasoning simply no-ops instead of mismatching. */
+ remember_tool_thinking_checkpoint(s, slot, j,
+ parsed_content ? parsed_content : "",
+ &parsed_calls);
+ } else {
thinking_live_clear(s, slot);
}
@@ -17388,6 +17456,185 @@ static void test_thinking_canonical_with_tools_preserves_reasoning(void) {
chat_msgs_free(&history);
}
+/* The reasoning-stripped visible key that remember_tool_thinking_checkpoint()
+ * builds for a tool-call turn -- prompt_text + build_tool_checkpoint_suffix(
+ * content, "", calls) -- must byte-match what render_chat_prompt_text() produces
+ * on the next request, where the client replays the tool call but drops the
+ * hidden reasoning. If it does not, thinking-visible reuse silently misses and
+ * every follow-up turn re-prefills from the user boundary (the original bug). */
+static void test_tool_thinking_checkpoint_tool_call_matches_future_prompt(void) {
+ tool_schema_orders orders = {0};
+ tool_schema_orders_add_json(&orders,
+ "{\"name\":\"read_file\",\"parameters\":{\"type\":\"object\",\"properties\":{"
+ "\"path\":{}}}}");
+ const char *tool_schemas =
+ "{\"name\":\"read_file\",\"parameters\":{\"type\":\"object\",\"properties\":{"
+ "\"path\":{}}}}";
+
+ chat_msgs prefix_msgs = {0};
+ chat_msg user = {0};
+ user.role = xstrdup("user");
+ user.content = xstrdup("read the file");
+ chat_msgs_push(&prefix_msgs, user);
+ char *prompt_text = render_chat_prompt_text(&prefix_msgs, tool_schemas,
+ &orders, DS4_THINK_HIGH);
+ size_t pt_len = strlen(prompt_text);
+ TEST_ASSERT(pt_len >= 7 && !memcmp(prompt_text + pt_len - 7, "", 7));
+
+ /* The model generates reasoning + a tool call. */
+ const char *generated =
+ "let me read it\n\n"
+ DS4_TOOL_CALLS_START "\n"
+ "<|DSML|invoke name=\"read_file\">\n"
+ "<|DSML|parameter name=\"path\" string=\"true\">/tmp/x|DSML|parameter>\n"
+ "|DSML|invoke>\n"
+ "|DSML|tool_calls>";
+ char *content = NULL;
+ char *reasoning = NULL;
+ tool_calls calls = {0};
+ TEST_ASSERT(parse_generated_message_ex(generated, false, &content, &reasoning, &calls));
+ TEST_ASSERT(calls.len == 1);
+ TEST_ASSERT(reasoning && strstr(reasoning, "let me read it"));
+
+ request r;
+ request_init(&r, REQ_CHAT, 128);
+ r.think_mode = DS4_THINK_HIGH;
+ r.tool_orders = orders;
+ memset(&orders, 0, sizeof(orders));
+
+ /* remember_tool_thinking_checkpoint() strips reasoning (passes ""). */
+ char *suffix = build_tool_checkpoint_suffix(&r, content, "", &calls);
+ buf key = {0};
+ buf_puts(&key, prompt_text);
+ buf_puts(&key, suffix);
+
+ /* Next request: the same turn with reasoning dropped by the client. */
+ chat_msgs history = {0};
+ chat_msg h_user = {0};
+ h_user.role = xstrdup("user");
+ h_user.content = xstrdup("read the file");
+ chat_msgs_push(&history, h_user);
+ chat_msg h_asst = {0};
+ h_asst.role = xstrdup("assistant");
+ h_asst.reasoning = xstrdup(""); /* client omits reasoning */
+ h_asst.content = xstrdup(content ? content : "");
+ h_asst.calls = calls;
+ memset(&calls, 0, sizeof(calls));
+ chat_msgs_push(&history, h_asst);
+
+ char *future = render_chat_prompt_text(&history, tool_schemas,
+ &r.tool_orders, DS4_THINK_HIGH);
+
+ TEST_ASSERT(!strcmp(key.ptr, future));
+ /* The empty think block is kept (tool_context), and the generated reasoning
+ * is absent from the replayed transcript even though it stays in live KV. */
+ TEST_ASSERT(strstr(future, "") != NULL);
+ TEST_ASSERT(strstr(future, "let me read it") == NULL);
+
+ free(future);
+ buf_free(&key);
+ free(suffix);
+ free(prompt_text);
+ free(content);
+ free(reasoning);
+ chat_msgs_free(&prefix_msgs);
+ chat_msgs_free(&history);
+ tool_calls_free(&calls);
+ request_free(&r);
+ tool_schema_orders_free(&orders);
+}
+
+/* Regression for trace4 req8/req16: a tools-enabled thinking turn that ends in a
+ * final answer with NO tool call. should_remember_thinking_checkpoint() excludes
+ * has_tools and the tool-call paths require parsed_calls, so this turn is handled
+ * by remember_tool_thinking_checkpoint() with an empty call list. The key must be
+ * a byte-prefix of the next prompt, where the assistant turn is now historical
+ * (a follow-up user turn follows) and tool_context keeps the empty
+ * block. */
+static void test_tool_thinking_checkpoint_final_answer_matches_future_prompt(void) {
+ const char *tool_schemas = "{\"name\":\"bash\"}";
+
+ chat_msgs prefix_msgs = {0};
+ chat_msg user = {0};
+ user.role = xstrdup("user");
+ user.content = xstrdup("explain the fix");
+ chat_msgs_push(&prefix_msgs, user);
+ char *prompt_text = render_chat_prompt_text(&prefix_msgs, tool_schemas, NULL,
+ DS4_THINK_HIGH);
+ size_t pt_len = strlen(prompt_text);
+ TEST_ASSERT(pt_len >= 7 && !memcmp(prompt_text + pt_len - 7, "", 7));
+
+ const char *content = "Done. Two changes made.";
+
+ request r;
+ request_init(&r, REQ_CHAT, 128);
+ r.think_mode = DS4_THINK_HIGH;
+
+ /* Empty call list: the final-answer key is prompt + + content + eos. */
+ tool_calls no_calls = {0};
+ char *suffix = build_tool_checkpoint_suffix(&r, content, "", &no_calls);
+ buf key = {0};
+ buf_puts(&key, prompt_text);
+ buf_puts(&key, suffix);
+
+ /* Next request: the final answer became historical (a new user follows), with
+ * reasoning dropped by the client. */
+ chat_msgs history = {0};
+ chat_msg h_user = {0};
+ h_user.role = xstrdup("user");
+ h_user.content = xstrdup("explain the fix");
+ chat_msgs_push(&history, h_user);
+ chat_msg h_asst = {0};
+ h_asst.role = xstrdup("assistant");
+ h_asst.reasoning = xstrdup("");
+ h_asst.content = xstrdup(content);
+ chat_msgs_push(&history, h_asst);
+ chat_msg h_user2 = {0};
+ h_user2.role = xstrdup("user");
+ h_user2.content = xstrdup("now continue");
+ chat_msgs_push(&history, h_user2);
+
+ char *future = render_chat_prompt_text(&history, tool_schemas, NULL,
+ DS4_THINK_HIGH);
+
+ TEST_ASSERT(strlen(future) > key.len);
+ TEST_ASSERT(!memcmp(future, key.ptr, key.len));
+ /* Historical final-answer turn keeps the empty think block under tool_context;
+ * the follow-up user turn opens a fresh . */
+ TEST_ASSERT(strstr(future, "Done. Two changes made.") != NULL);
+ TEST_ASSERT(strstr(future + key.len, "now continue") != NULL);
+
+ free(future);
+ buf_free(&key);
+ free(suffix);
+ free(prompt_text);
+ chat_msgs_free(&prefix_msgs);
+ chat_msgs_free(&history);
+ request_free(&r);
+}
+
+/* build_tool_checkpoint_suffix() with reasoning="" is what removes the hidden
+ * thinking from the remembered key; a non-empty reasoning would leave it in and
+ * never match a reasoning-stripping client. Lock that contract directly. */
+static void test_tool_checkpoint_suffix_strips_reasoning(void) {
+ request r;
+ request_init(&r, REQ_CHAT, 128);
+ r.think_mode = DS4_THINK_HIGH;
+ tool_calls no_calls = {0};
+
+ char *stripped = build_tool_checkpoint_suffix(&r, "the answer", "", &no_calls);
+ TEST_ASSERT(!strncmp(stripped, "the answer", strlen("the answer")));
+ TEST_ASSERT(strstr(stripped, "") == NULL); /* no open tag, no reasoning */
+ TEST_ASSERT(strstr(stripped, DS4_TOOL_CALLS_START) == NULL); /* no DSML for empty calls */
+
+ char *kept = build_tool_checkpoint_suffix(&r, "the answer", "hidden why", &no_calls);
+ TEST_ASSERT(strstr(kept, "hidden why") != NULL); /* contrast: reasoning retained */
+
+ free(stripped);
+ free(kept);
+ request_free(&r);
+}
+
static void test_thinking_canonical_non_thinking_mode_noop(void) {
/* When thinking is disabled (deepseek-chat), prompt_text ends with
* not . The toolless thinking live binding is a no-op
@@ -17488,6 +17735,9 @@ static void ds4_server_unit_tests_run(void) {
test_thinking_canonical_empty_content();
test_thinking_canonical_multi_turn();
test_thinking_canonical_with_tools_preserves_reasoning();
+ test_tool_thinking_checkpoint_tool_call_matches_future_prompt();
+ test_tool_thinking_checkpoint_final_answer_matches_future_prompt();
+ test_tool_checkpoint_suffix_strips_reasoning();
test_thinking_canonical_non_thinking_mode_noop();
test_tool_separator_whitespace_is_not_content();
test_dsml_prompt_escapes_tool_supplied_text();