From 3c248dca26ac6bf8d6ca289aed84c550efa6ef44 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 26 May 2026 01:03:10 +0000 Subject: [PATCH] Drop anchored orphan tools to preserve template validity for PR #5710 The final invariant sweep skipped anchored (multimodal) tool messages whose tcid had no matching prior assistant tool_calls. The anchor survived but the chat template did not -- a tool_call_id pointing at nothing returns 400 from llama-server / OpenAI on the next call. Pair-validity is the hard invariant (the module exists to enforce it upstream of llama-server) and the multimodal-anchor rule is the soft quality preference. Drop the orphan rather than violate the template: lose the image content for that one turn, keep the conversation alive. --- .../core/inference/context_compaction.py | 11 ++++++-- .../tests/test_context_compaction_edge.py | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/studio/backend/core/inference/context_compaction.py b/studio/backend/core/inference/context_compaction.py index 4572e702b4..e9a5d0ca46 100644 --- a/studio/backend/core/inference/context_compaction.py +++ b/studio/backend/core/inference/context_compaction.py @@ -317,20 +317,25 @@ class SlidingWindowCompact(CompactStrategy): # Final invariant sweep, two passes so the asst-strip decision # sees the post-orphan-drop tool set: # pass 1: drop tools whose tcid has no matching assistant - # ``tool_calls`` earlier in the kept output (anchored tools - # stay; same leak-rather-than-violate-anchor rule); + # ``tool_calls`` earlier in the kept output; # pass 2: recompute responded_ids from the surviving tools and # mark assts whose tool_calls aren't all responded for strip. # Computing responded_ids before pass 1 would let a stale tcid # of a just-dropped orphan tool satisfy `ids <= responded_ids`, # leaving the asst with dangling tool_calls (OpenAI 400). + # Anchored (multimodal) orphan tools are still dropped here: + # keeping them violates the chat-template pair invariant (a hard + # invariant that 400s upstream) to honor the multimodal-anchor + # quality preference, which the docstring describes as a soft + # quality rule. The pair-validity wins; the image content is + # lost rather than the entire turn. seen_ids: set[str] = set() for i, m in enumerate(messages): if i in dropped: continue if m.get("role") == "assistant": seen_ids |= _assistant_tool_call_ids(m) - elif m.get("role") == "tool" and i not in anchor_idx: + elif m.get("role") == "tool": tcid = m.get("tool_call_id") if isinstance(tcid, str) and tcid and tcid not in seen_ids: dropped.add(i) diff --git a/studio/backend/tests/test_context_compaction_edge.py b/studio/backend/tests/test_context_compaction_edge.py index 601c96b544..75fa1f0345 100644 --- a/studio/backend/tests/test_context_compaction_edge.py +++ b/studio/backend/tests/test_context_compaction_edge.py @@ -907,3 +907,31 @@ def test_orphan_tool_before_asst_does_not_leave_dangling_tool_calls(): out = SlidingWindowCompact(keep_recent = 5).compact(msgs, budget_tokens = 1000) asst_ids, tool_ids = _surviving_tool_ids(out) assert asst_ids == tool_ids, (asst_ids, tool_ids) + + +def test_anchored_multimodal_orphan_tool_dropped(): + """Anchored multimodal tool message references a tool_call_id no + surviving assistant declares. Keeping the anchor would leave a + dangling `tool_call_id` in the output and 400 upstream. Pair + validity is the hard invariant; the multimodal anchor is the soft + quality preference, so we drop the orphan rather than violate the + template. Earlier behavior preserved the anchor and produced an + invalid chat template. + """ + msgs = [ + _msg("system", "sys"), + _msg("user", "first task"), + { + "role": "tool", + "tool_call_id": "stale_call", + "content": [ + {"type": "text", "text": "image description"}, + {"type": "image_url", "image_url": {"url": "x"}}, + ], + }, + _msg("user", "follow up"), + _long("assistant", 100), + ] + out = SlidingWindowCompact(keep_recent = 2).compact(msgs, budget_tokens = 5) + asst_ids, tool_ids = _surviving_tool_ids(out) + assert asst_ids == tool_ids, (asst_ids, tool_ids)