Skip to content

fix(sanitize): drop tool_calls key when dedup removes all calls (fixes empty-array 400 class) - #77377

Closed
andrexibiza wants to merge 4 commits into
NousResearch:mainfrom
andrexibiza:fix/sanitize-toolcalls-class
Closed

fix(sanitize): drop tool_calls key when dedup removes all calls (fixes empty-array 400 class)#77377
andrexibiza wants to merge 4 commits into
NousResearch:mainfrom
andrexibiza:fix/sanitize-toolcalls-class

Conversation

@andrexibiza

@andrexibiza andrexibiza commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Related #64335 #64345 #70126 #74101 #76862

What does this PR do?

Fixes the entire class of empty-tool_calls reintroduction in
sanitize_api_messages — not just one provider's symptom.

sanitize_api_messages() in agent/agent_runtime_helpers.py has a
Step-3 tool_call_id dedup pass that, when ALL tool calls in an assistant
message are duplicates of calls already seen, collapses kept_tcs to []
and writes tool_calls: [] back into the message. Strict OpenAI-compatible
providers reject the empty array with HTTP 400 — breaking every subsequent
request in the session until it scrolls out.

The fix (salvaged from #64345, authored by @liuhao1024, cherry-picked with
authorship preserved):

  • When kept_tcs is non-empty after dedup: write it back (unchanged behavior).
  • When dedup removes ALL calls: drop the tool_calls key entirely
    (semantically "no tool calls", and the same normalization the earlier
    empty-array pass at agent/agent_runtime_helpers.py:3204-3237 already
    applies) — instead of writing tool_calls: [].
  • The earlier empty-array pass already handles tool_calls: [] arriving at
    the chokepoint; this closes the dedup path that re-introduced it after
    that pass ran.

Regression test added: test_sanitize_dedup_drops_tool_calls_key_when_all_removed
asserts the first assistant keeps its tool call and the second (all-duplicate)
assistant has the key dropped, content preserved.

Addressing existing review commentary

How to test

pytest tests/run_agent/test_message_sequence_repair.py -q
# 16 passed

# repro (was: second assistant gets tool_calls: [])
python -c "
from agent.agent_runtime_helpers import sanitize_api_messages
msgs = [
    {'role': 'user', 'content': 's1'},
    {'role': 'assistant', 'content': 'a', 'tool_calls': [{'id': 'call_A', 'type': 'function', 'function': {'name': 'foo', 'arguments': '{}'}}]},
    {'role': 'tool', 'tool_call_id': 'call_A', 'content': 'r1'},
    {'role': 'assistant', 'content': 'dup', 'tool_calls': [{'id': 'call_A', 'type': 'function', 'function': {'name': 'foo', 'arguments': '{}'}}]},
]
out = sanitize_api_messages(msgs)
print([m.get('tool_calls') for m in out if m.get('role') == 'assistant'])
# -> [ [...call_A...], None ]  (key dropped, no empty array)
"

What platforms were tested?

  • Windows 11 native: 16 passed, git diff --check clean,
    check-windows-footguns.py clean on both changed files.

Why this matters to users

Before: a long conversation that re-emits a duplicate tool_call_id (retries,
crash/resume, compression-window re-emission) silently poisons the session —
the next request 400s with "empty tool_calls" on DeepSeek, Qwen, doubao, and
other strict providers, and every later message fails until history scrolls
past the bad turn. Users hit this as "conversation suddenly dead, restart
needed."

After: the sanitizer drops the empty key the way it already drops arriving
empty arrays — the session keeps working, no provider 400, no restart.

Fixes #64335
Closes #74101, #76862, #70126

  • Bug fix (non-breaking change that fixes an issue)
  • New feature
  • Breaking change

Checklist

Part of #70126
Part of #74101
Part of #76862

Part of #30405
Part of #53520

liuhao1024 and others added 4 commits August 3, 2026 00:41
The dedup pass in sanitize_api_messages (introduced by NousResearch#58327) can
produce an empty tool_calls array when all tool_call_ids in a message
are duplicates of earlier messages in a long conversation history.

DeepSeek v4 and newer OpenAI reject empty tool_calls with HTTP 400:
'Invalid messages[N].tool_calls: empty array'.

When kept_tcs is empty after dedup, drop the tool_calls key entirely
instead of writing tool_calls: [].

Fixes NousResearch#64335

Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>

# Conflicts:
#	tests/run_agent/test_message_sequence_repair.py
Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/delegate Subagent delegation provider/deepseek DeepSeek API duplicate This issue or pull request already exists labels Aug 3, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Duplicate of #64345 — both drop the tool_calls key when the dedup pass removes every call, preventing strict OpenAI-compatible providers from receiving tool_calls: []. #64345 is the earlier open canonical.

@andrexibiza

Copy link
Copy Markdown
Contributor Author

The core change here matches #64345's approach in agent/agent_runtime_helpers.py (drop tool_calls when the dedup pass removes every call), but #64345 is mergeable_state: dirty against current main — untouched since 2026-07-16 — and cannot land as-is. This PR carries the same fix on a merge-clean current-main base with expanded coverage (message-sequence-repair plus delegate tests). The bundled delegate_tool.py description refactor is separable if maintainers prefer it split out.

@teknium1

Copy link
Copy Markdown
Contributor

Heads-up: the agent/agent_runtime_helpers.py portion of this PR (drop tool_calls key when dedup removes all calls) landed on main via #86654, cherry-picked from #64345 with @liuhao1024's authorship as you originally credited. The bundled tools/delegate_tool.py refactor is unrelated to that class fix — if you'd like it considered, please rebase this PR down to just the delegate changes so it can be reviewed on its own merits.

@alt-glitch alt-glitch added type/refactor Code restructuring, no behavior change and removed type/bug Something isn't working duplicate This issue or pull request already exists provider/deepseek DeepSeek API P2 Medium — degraded but workaround exists labels Aug 15, 2026
@alt-glitch alt-glitch added P3 Low — cosmetic, nice to have comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working P2 Medium — degraded but workaround exists and removed comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint type/refactor Code restructuring, no behavior change P3 Low — cosmetic, nice to have labels Aug 15, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

The sanitize_api_messages all-deduped tool_calls fix has landed via #86654, including additional wire-level chokepoints. The remaining delegate schema-description compaction is unrelated to this PR's stated fix; please split or rebase it for independent review.

@andrexibiza

Copy link
Copy Markdown
Contributor Author

Closing this as redundant — every change this PR proposes is already merged on main.

Per your heads-up, the agent_runtime_helpers.py portion (drop tool_calls key when dedup removes all calls) landed on main via #86654. I also checked the tools/delegate_tool.py portion you asked to keep for review on its own merits, and it too is already on main:

  • tools/delegate_tool.py_build_top_level_description() already carries the static, parameter-description-based text ("Deliberately carries ONLY guidance that exists nowhere else in the schema") on main.
  • tests/tools/test_delegate.py — both test_top_level_description_compact_and_complete and test_dynamic_limits_moved_to_param_descriptions exist on main.
  • website/docs/reference/tools-reference.md — the delegate_task description already reflects the compact wording.

So there is no standalone delegate contribution left to rebase — it shipped with the delegation work. I'm closing this PR rather than re-filing already-merged changes. Credit for the original tool_calls fix remains with @liuhao1024 via #86654.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state tool/delegate Subagent delegation type/bug Something isn't working

Projects

None yet

4 participants