diff --git a/ds4_server.c b/ds4_server.c index 45a53469c..7a77a703f 100644 --- a/ds4_server.c +++ b/ds4_server.c @@ -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 @@ -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[] = ""; + 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", + "", + "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) { @@ -3093,7 +3175,15 @@ 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( @@ -3101,12 +3191,16 @@ static bool parse_chat_request(ds4_engine *e, server *s, const char *body, int d 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; @@ -3472,7 +3566,9 @@ static bool parse_responses_content_array(const char **p, char **out) { * render_chat_prompt_text can wrap them in . */ 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)++; @@ -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"); @@ -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; @@ -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; } @@ -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( @@ -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); @@ -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, @@ -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"); @@ -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); @@ -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, @@ -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" + "\nold\n"); + 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}; @@ -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();