[BUG] preprocess_messages reorders multi-round tool history on every backend
Required prerequisites
- I have read the documentation and searched existing issues and pull requests.
preprocess_messagesreturns #4283, #3487, #2215 and #1704 in issues and #4163, #3892, #3827 and #213 in pull requests, none about ordering.tool call orderreturns #4162, #3687, #4263, #4241, #2600 and #2075, none about this.
What version of camel are you using?
8c791b7 on master.
System information
Python 3.12.3, Linux. No provider credentials needed; the reproduction drives preprocess_messages directly.
Problem description
preprocess_messages reorders multi-round tool history. The flush gate at camel/models/base_model.py:567 is an if where it needs a while, so it emits only one buffered assistant tool_calls message before a plain message. It pops a single entry at :586 and appends the plain message at :589. The rest of the buffer drains only at the trailing while on :592.
That splices the turn's answer between tool round 1 and round 2, and round 2 then follows the answer it produced.
ModelBackendMeta wraps every run and arun with this, at :213 and :226, so it reaches all backends. ChatAgent records one assistant message per tool round, so two sequential rounds in one turn produce exactly this input, and the damage shows up on the next step().
The array stays structurally legal OpenAI, so no API error fires. It corrupts causality silently.
:592 already uses while to drain the same buffer, which is the asymmetry that makes the if look deliberate.
Reproducible example code
from camel.models.base_model import BaseModelBackend
class Stub(BaseModelBackend):
_extract_thinking_from_response = False
def __init__(self):
pass
@property
def token_counter(self):
raise NotImplementedError
def _run(self, *a, **k):
raise NotImplementedError
async def _arun(self, *a, **k):
raise NotImplementedError
msgs = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "q1"},
{"role": "assistant", "tool_calls": [{"id": "A", "function": {"name": "f"}}]},
{"role": "tool", "tool_call_id": "A", "content": "rA"},
{"role": "assistant", "tool_calls": [{"id": "B", "function": {"name": "f"}}]},
{"role": "tool", "tool_call_id": "B", "content": "rB"},
{"role": "assistant", "content": "answer"},
{"role": "user", "content": "q2"},
]
def label(m):
if m.get("role") == "assistant" and "tool_calls" in m:
return "asst_call[" + ",".join(c["id"] for c in m["tool_calls"]) + "]"
if m.get("role") == "tool":
return f"tool({m['tool_call_id']})"
return f"{m['role']}({m.get('content')})"
out = Stub().preprocess_messages(msgs)
print("IN : " + " | ".join(label(m) for m in msgs))
print("OUT: " + " | ".join(label(m) for m in out))Output on 8c791b7:
IN : system(sys) | user(q1) | asst_call[A] | tool(A) | asst_call[B] | tool(B) | assistant(answer) | user(q2)
OUT: system(sys) | user(q1) | asst_call[A] | tool(A) | assistant(answer) | asst_call[B] | tool(B) | user(q2)Expected behavior
The output preserves the input order: asst_call[A] | tool(A) | asst_call[B] | tool(B) | assistant(answer).
Additional context
Changing :567 from if to while gives that, and leaves the two working cases byte-identical. Measured over three inputs:
| case | output after the change |
|---|---|
| two sequential rounds | user(q1) | asst_call[A] | tool(A) | asst_call[B] | tool(B) | assistant(answer) |
| single round | user(q1) | asst_call[A] | tool(A) | assistant(answer) |
| parallel calls in one round | user(q1) | asst_call[A,B] | tool(A) | tool(B) | assistant(answer) |
test/models/test_base_model.py:629 has six scenarios and 21 tests over this function, and all of them still pass under that change. Test 4 at :745 comes closest, and its comment says "Verify correct ordering of tool calls and responses", but it puts a user message between the two rounds, so the buffer never holds two entries. No test covers consecutive rounds.
Not checked: whether the Responses-API delta path amplifies this. No live provider call ran.
A pull request with the one-word change and a regression test for consecutive rounds can follow this issue, if that is wanted.
Source: camel-ai/camel