Skip to content
Open
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
219 changes: 207 additions & 12 deletions ds4_server.c
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@ typedef struct {
int cache_write_tokens;
ds4_think_mode think_mode;
bool has_tools;
bool compaction;
bool prompt_preserves_reasoning;
/* For /v1/responses: emit reasoning_summary_* events / fields only when the
* client opted in via reasoning.summary. Other APIs leave this false; the
Expand Down Expand Up @@ -2427,6 +2428,87 @@ static bool chat_history_uses_tool_context(const chat_msgs *msgs,
return false;
}

/* Remove raw DSML tool-call blocks that may have been stored in message content
* or generated by the model during compaction/summarization requests. */
static char *strip_dsml_markup(const char *s) {
if (!s) return NULL;
static const char start_tag[] = "<|DSML|tool_calls>";
static const char end_tag[] = "</|DSML|tool_calls>";
const size_t start_len = sizeof(start_tag) - 1;
const size_t end_len = sizeof(end_tag) - 1;
buf out = {0};
const char *p = s;
while (*p) {
const char *start = strstr(p, start_tag);
if (!start) {
buf_puts(&out, p);
break;
}
buf_append(&out, p, (size_t)(start - p));
const char *end = strstr(start + start_len, end_tag);
if (!end) {
/* Unterminated block: drop the remainder. */
break;
}
p = end + end_len;
}
return buf_take(&out);
}

/* opencode and other chat-completions clients send compaction/summarization
* requests through /v1/chat/completions, not the Responses API, so the
* last_was_compaction bookkeeping never fires for them. Detect those
* requests by their distinctive summarization instructions instead:
* opencode's compaction system prompt ("anchored context summarization
* assistant") or the trailing user instruction asking for the anchored
* summary. Without this, compaction requests arrive with full tool schemas
* and a DSML-saturated history, the model emits tool-call stanzas as its
* "summary", and the session stalls on a corrupted compaction. */
static bool chat_msgs_look_like_compaction(const chat_msgs *msgs) {
if (!msgs) return false;
static const char *const markers[] = {
"anchored context summarization assistant",
"<previous-summary>",
"Create a new anchored summary from the conversation history",
"Update the anchored summary below using the conversation history above",
};
for (int i = 0; i < msgs->len; i++) {
const chat_msg *m = &msgs->v[i];
if (!m->content) continue;
if (!role_is_system(m->role) && strcmp(m->role, "user")) continue;
for (size_t k = 0; k < sizeof(markers) / sizeof(markers[0]); k++) {
if (strstr(m->content, markers[k])) return true;
}
}
return false;
}

/* Build a compaction-safe copy of the conversation history:
* - adds an explicit plain-text instruction,
* - strips any raw DSML blocks stored in assistant content,
* - drops structured tool_calls so the renderer cannot emit DSML examples.
*/
static chat_msgs sanitize_messages_for_compaction(const chat_msgs *msgs) {
chat_msgs out = {0};
chat_msg instr = {0};
instr.role = xstrdup("system");
instr.content = xstrdup(
"This request is a conversation compaction/summarization. "
"Output plain text only. Do not emit tool calls, DSML markup, or XML tags.");
chat_msgs_push(&out, instr);
for (int i = 0; i < msgs->len; i++) {
const chat_msg *m = &msgs->v[i];
chat_msg copy = {0};
copy.role = xstrdup(m->role);
copy.content = strip_dsml_markup(m->content);
/* chat-completions messages usually have reasoning == NULL (only the
* Responses parser always populates it), and xstrdup is NULL-unsafe. */
copy.reasoning = m->reasoning ? xstrdup(m->reasoning) : NULL;
chat_msgs_push(&out, copy);
}
return out;
}

static char *render_deepseek_chat_prompt_text(const chat_msgs *msgs, const char *tool_schemas,
const tool_schema_orders *tool_orders,
ds4_think_mode think_mode) {
Expand Down Expand Up @@ -3093,20 +3175,32 @@ static bool parse_chat_request(ds4_engine *e, server *s, const char *body, int d
request_free(r);
return false;
}
r->has_tools = tool_schemas && tool_schemas[0] && !tool_choice_none;
/* Compaction/summarization requests must not be primed for tools: with
* schemas rendered and a DSML-heavy history, the model emits tool-call
* stanzas as its "summary", corrupting the compacted context. Mirror
* the Responses-path handling: drop the schemas, sanitize the history,
* and mark the request so any leaked DSML is stripped from the reply. */
const bool compaction_request = chat_msgs_look_like_compaction(&msgs);
r->has_tools = tool_schemas && tool_schemas[0] && !tool_choice_none &&
!compaction_request;
r->compaction = compaction_request;
if (!got_thinking && model_alias_disables_thinking(r->model)) thinking_enabled = false;
if (!got_thinking && model_alias_enables_thinking(r->model)) thinking_enabled = true;
r->think_mode = ds4_think_mode_for_context(
think_mode_from_enabled(thinking_enabled, reasoning_effort), ctx_size);
kv_cache_restore_tool_memory_for_messages(s, &msgs);
tool_memory_attach_to_messages(s, &msgs, &r->tool_replay);
const char *active_tool_schemas = r->has_tools ? tool_schemas : NULL;
chat_msgs prompt_msgs = compaction_request
? sanitize_messages_for_compaction(&msgs)
: msgs;
r->prompt_preserves_reasoning =
chat_history_uses_tool_context(&msgs, active_tool_schemas);
chat_history_uses_tool_context(&prompt_msgs, active_tool_schemas);
r->prompt_text = render_chat_prompt_text_for_syntax(
r->model_syntax, &msgs, active_tool_schemas,
r->model_syntax, &prompt_msgs, active_tool_schemas,
&r->tool_orders, r->think_mode);
ds4_tokenize_rendered_chat(e, r->prompt_text, &r->prompt);
if (compaction_request) chat_msgs_free(&prompt_msgs);
chat_msgs_free(&msgs);
free(tool_schemas);
return true;
Expand Down Expand Up @@ -3472,7 +3566,9 @@ static bool parse_responses_content_array(const char **p, char **out) {
* render_chat_prompt_text can wrap them in <think>. */
static bool parse_responses_input(const char **p, chat_msgs *msgs,
buf *loaded_tool_schemas,
tool_schema_orders *orders) {
tool_schema_orders *orders,
bool *last_was_compaction) {
if (last_was_compaction) *last_was_compaction = false;
json_ws(p);
if (**p != '[') return false;
(*p)++;
Expand Down Expand Up @@ -3720,6 +3816,7 @@ static bool parse_responses_input(const char **p, chat_msgs *msgs,
!strcmp(t, "tool_search_call") || !strcmp(t, "image_generation_call");
bool is_bookkeeping =
!strcmp(t, "compaction") || !strcmp(t, "context_compaction");
if (last_was_compaction) *last_was_compaction = is_bookkeeping;
if (!consumes_reasoning && !is_bookkeeping && pending_reasoning.len) {
chat_msg flush_msg = {0};
flush_msg.role = xstrdup("assistant");
Expand Down Expand Up @@ -4021,6 +4118,7 @@ static bool parse_responses_request(ds4_engine *e, server *s, const char *body,
const char *p = body;
bool got_input = false;
bool tool_choice_none = false;
bool last_was_compaction = false;
bool got_thinking = false;
bool thinking_enabled = true;
ds4_think_mode reasoning_effort = DS4_THINK_HIGH;
Expand Down Expand Up @@ -4058,7 +4156,7 @@ static bool parse_responses_request(ds4_engine *e, server *s, const char *body,
msg.content = plain;
chat_msgs_push(&msgs, msg);
} else if (!parse_responses_input(&p, &msgs, &loaded_tool_schemas,
&r->tool_orders)) {
&r->tool_orders, &last_was_compaction)) {
free(key);
goto bad;
}
Expand Down Expand Up @@ -4237,10 +4335,15 @@ static bool parse_responses_request(ds4_engine *e, server *s, const char *body,
buf_append(&combined_tool_schemas, loaded_tool_schemas.ptr,
loaded_tool_schemas.len);
}
/* Compaction requests must not prime the model for tools at all: with
* schemas still in the prompt the model keeps emitting DSML stanzas, and
* with has_tools disabled nothing parses them, so they leak raw into the
* summary text. Drop the schemas too, like the tool-less chat path. */
const char *active_tool_schemas =
(!tool_choice_none && combined_tool_schemas.len) ?
(!tool_choice_none && !last_was_compaction && combined_tool_schemas.len) ?
combined_tool_schemas.ptr : NULL;
r->has_tools = active_tool_schemas && active_tool_schemas[0];
r->compaction = last_was_compaction;
if (!got_thinking && model_alias_disables_thinking(r->model)) thinking_enabled = false;
if (!got_thinking && model_alias_enables_thinking(r->model)) thinking_enabled = true;
r->think_mode = ds4_think_mode_for_context(
Expand All @@ -4259,13 +4362,17 @@ static bool parse_responses_request(ds4_engine *e, server *s, const char *body,
}
kv_cache_restore_tool_memory_for_messages(s, &msgs);
tool_memory_attach_to_messages(s, &msgs, &r->tool_replay);
r->prompt_preserves_reasoning =
chat_history_uses_tool_context(&msgs, active_tool_schemas);
responses_prepare_live_continuation(r, &msgs);
chat_msgs prompt_msgs = last_was_compaction
? sanitize_messages_for_compaction(&msgs)
: msgs;
r->prompt_preserves_reasoning =
chat_history_uses_tool_context(&prompt_msgs, active_tool_schemas);
r->prompt_text = render_chat_prompt_text_for_syntax(
r->model_syntax, &msgs, active_tool_schemas,
r->model_syntax, &prompt_msgs, active_tool_schemas,
&r->tool_orders, r->think_mode);
ds4_tokenize_rendered_chat(e, r->prompt_text, &r->prompt);
if (last_was_compaction) chat_msgs_free(&prompt_msgs);
chat_msgs_free(&msgs);
buf_free(&combined_tool_schemas);
buf_free(&loaded_tool_schemas);
Expand Down Expand Up @@ -11730,6 +11837,14 @@ static void generate_job(server *s, server_slot *slot, job *j) {
char *parsed_reasoning = NULL;
const char *final_finish = finish;
bool recovered_tool_parse_failure = false;
if (j->req.kind == REQ_CHAT && j->req.compaction && text.ptr) {
/* Defense in depth: if the model still emits DSML during a compaction
* request, strip it before the response is finalized. */
char *stripped = strip_dsml_markup(text.ptr);
free(text.ptr);
text.ptr = stripped ? stripped : xstrdup("");
text.len = strlen(text.ptr);
}
if (j->req.kind == REQ_CHAT) {
bool parsed_ok = parse_generated_message_for_response_for_syntax(
j->req.model_syntax,
Expand Down Expand Up @@ -13371,7 +13486,7 @@ static void test_responses_input_tool_search_output_loads_tools(void) {
chat_msgs msgs = {0};
buf loaded = {0};
tool_schema_orders orders = {0};
TEST_ASSERT(parse_responses_input(&p, &msgs, &loaded, &orders));
TEST_ASSERT(parse_responses_input(&p, &msgs, &loaded, &orders, NULL));
TEST_ASSERT(loaded.ptr && strstr(loaded.ptr, "\"name\":\"mcp__perplexity__perplexity_search\""));
const tool_schema_order *order =
tool_schema_orders_find(&orders, "mcp__perplexity__perplexity_search");
Expand All @@ -13396,7 +13511,7 @@ static void test_responses_input_tool_search_output_rejects_bad_tools(void) {
chat_msgs msgs = {0};
buf loaded = {0};
tool_schema_orders orders = {0};
TEST_ASSERT(!parse_responses_input(&p, &msgs, &loaded, &orders));
TEST_ASSERT(!parse_responses_input(&p, &msgs, &loaded, &orders, NULL));
buf_free(&loaded);
tool_schema_orders_free(&orders);
chat_msgs_free(&msgs);
Expand All @@ -13419,7 +13534,7 @@ static void test_responses_input_function_call_namespace_round_trips_to_dsml(voi
"\"arguments\":{\"query\":\"deepseek\"}}]";
const char *input_p = input_json;
chat_msgs msgs = {0};
TEST_ASSERT(parse_responses_input(&input_p, &msgs, NULL, NULL));
TEST_ASSERT(parse_responses_input(&input_p, &msgs, NULL, NULL, NULL));
TEST_ASSERT(msgs.len == 1);
TEST_ASSERT(msgs.v[0].calls.len == 1);
TEST_ASSERT(!strcmp(msgs.v[0].calls.v[0].name,
Expand All @@ -13437,6 +13552,84 @@ static void test_responses_input_function_call_namespace_round_trips_to_dsml(voi
tool_schema_orders_free(&orders);
}

static void test_responses_input_compaction_flag_tracks_last_item(void) {
const char *ends_with_compaction =
"[{\"type\":\"message\",\"role\":\"user\",\"content\":\"hi\"},"
"{\"type\":\"compaction\"}]";
const char *p = ends_with_compaction;
chat_msgs msgs = {0};
bool last_was_compaction = false;
TEST_ASSERT(parse_responses_input(&p, &msgs, NULL, NULL, &last_was_compaction));
TEST_ASSERT(last_was_compaction);
chat_msgs_free(&msgs);

/* A compaction item earlier in the history must not disable tools for
* this request: only the last item decides. Regression test for the
* latch that kept tools off for every turn after the first compaction. */
const char *compaction_then_user =
"[{\"type\":\"compaction\"},"
"{\"type\":\"message\",\"role\":\"user\",\"content\":\"continue\"}]";
p = compaction_then_user;
memset(&msgs, 0, sizeof(msgs));
last_was_compaction = false;
TEST_ASSERT(parse_responses_input(&p, &msgs, NULL, NULL, &last_was_compaction));
TEST_ASSERT(!last_was_compaction);
chat_msgs_free(&msgs);
}

static void test_chat_request_detects_opencode_compaction(void) {
/* opencode sends compaction through /v1/chat/completions with a
* distinctive summarization system prompt and full tool schemas; the
* chat path must detect it independently of the Responses bookkeeping. */
chat_msgs msgs = {0};
chat_msg sys = {0};
sys.role = xstrdup("system");
sys.content = xstrdup(
"You are an anchored context summarization assistant for coding sessions.\n"
"Summarize only the conversation history you are given.");
chat_msgs_push(&msgs, sys);
chat_msg user = {0};
user.role = xstrdup("user");
user.content = xstrdup("Create a new anchored summary from the conversation history.");
chat_msgs_push(&msgs, user);
TEST_ASSERT(chat_msgs_look_like_compaction(&msgs));
chat_msgs_free(&msgs);

/* A previous-summary update prompt is also compaction. */
memset(&msgs, 0, sizeof(msgs));
chat_msg upd = {0};
upd.role = xstrdup("user");
upd.content = xstrdup(
"Update the anchored summary below using the conversation history above.\n"
"<previous-summary>\nold\n</previous-summary>");
chat_msgs_push(&msgs, upd);
TEST_ASSERT(chat_msgs_look_like_compaction(&msgs));
chat_msgs_free(&msgs);

/* Ordinary chat that merely mentions summarizing must not be flagged. */
memset(&msgs, 0, sizeof(msgs));
chat_msg plain = {0};
plain.role = xstrdup("user");
plain.content = xstrdup("please summarize this file for me");
chat_msgs_push(&msgs, plain);
TEST_ASSERT(!chat_msgs_look_like_compaction(&msgs));
chat_msgs_free(&msgs);

/* sanitize_messages_for_compaction must survive reasoning == NULL, which
* is the norm for chat-completions messages (xstrdup is NULL-unsafe). */
memset(&msgs, 0, sizeof(msgs));
chat_msg bare = {0};
bare.role = xstrdup("user");
bare.content = xstrdup("no reasoning field here");
chat_msgs_push(&msgs, bare);
chat_msgs clean = sanitize_messages_for_compaction(&msgs);
TEST_ASSERT(clean.len == 2);
TEST_ASSERT(!strcmp(clean.v[1].role, "user"));
TEST_ASSERT(clean.v[1].reasoning == NULL);
chat_msgs_free(&clean);
chat_msgs_free(&msgs);
}

static void test_responses_output_sends_tool_search_call_item(void) {
tool_calls calls = {0};
tool_call tc = {0};
Expand Down Expand Up @@ -17433,6 +17626,8 @@ static void ds4_server_unit_tests_run(void) {
test_responses_input_tool_search_output_loads_tools();
test_responses_input_tool_search_output_rejects_bad_tools();
test_responses_input_function_call_namespace_round_trips_to_dsml();
test_responses_input_compaction_flag_tracks_last_item();
test_chat_request_detects_opencode_compaction();
test_responses_output_sends_tool_search_call_item();
test_dsml_tool_args_preserve_call_order();
test_openai_tool_args_preserve_call_order();
Expand Down