Skip to content

fix(responses): keep one assistant turn in history - #5053

Open
kodurd wants to merge 3 commits into
InternLM:mainfrom
kodurd:fix/responses-tool-call-history
Open

kodurd wants to merge 3 commits into
InternLM:mainfrom
kodurd:fix/responses-tool-call-history

Conversation

@kodurd

@kodurd kodurd commented Oct 22, 2026 •

Copy link
Copy Markdown

Motivation

Responses replies are returned with store=False, so continuing a conversation means sending the previous output items back in input. One assistant turn is reported as several output items — its text first, then its calls — and replaying that turn does not reconstruct it as one turn.

messages_from_input starts a new assistant message for every function_call item unless it directly follows another call, so a turn that contained text and a tool call comes back as two consecutive assistant messages:

# replaying the endpoint's own output: message item + function_call item
[{'role': 'user', 'content': 'weather?'},
 {'role': 'assistant', 'content': 'Let me check. '},            # <- item 1
 {'role': 'assistant', 'content': None, 'tool_calls': [...]},   # <- item 2
 {'role': 'tool', 'tool_call_id': 'call_abc', 'content': 'sunny'}]

/v1/messages and /v1/chat/completions represent that turn as a single assistant message carrying tool_calls.

Chat templates render one turn per message, so the prompt the model receives is wrong in a different way for each template. Rendered through MultimodalProcessor._get_text_prompt_input, same conversation, before this change:

deepseek-v4  …<|Assistant|></think>Let me check. <|end of sentence|>\n\n<|DSML|tool_calls>…
mistral      [INST] weather? [/INST]Let me check. </s></s>sunny
llama2       …weather? [/INST] Let me check. </s><s>[INST]  [/INST] </s><s>[INST] sunny [/INST]

The tool call arrives after an end-of-turn marker, in a turn that was never opened; mistral gets a doubled </s>; llama2 gains an empty user exchange. With this change all three templates produce exactly the prompt the equivalent /v1/messages history produces.

Merged #5044 fixed the neighbouring case, where two adjacent function_call items became two turns instead of one parallel invocation. A call that follows the assistant's own text is still split off.

Modification

  • Join a function_call to the preceding assistant message whenever the previous message is one, instead of only when the previous item was itself a call. The assistant message is the turn being continued, so this covers the adjacent-call case too and #5044's previous_item_type bookkeeping is no longer needed; its comment about keeping every id visible to templates is kept.
  • A function_call that does not follow an assistant message still starts its own content: None assistant message. That is the shape test_responses_maps_function_call_history_to_chat_messages asserts, and it is unchanged.
  • Two regression tests. The text-then-call case fails on main. The parallel-call case passes on main since fix(serve): preserve tool-result image ownership #5044 and is kept as a guard, so a future change to this branch cannot regroup those calls.

Not touched: /v1/messages and /v1/chat/completions already produce the single-message shape.

BC-breaking (Optional)

None at the protocol level. A replayed history that previously rendered as two assistant turns now renders as one, which is what the other two endpoints already did.

Use cases (Optional)

client = OpenAI(base_url='http://127.0.0.1:23333/v1', api_key='x')

first = client.responses.create(model=MODEL, input='weather in Beijing?', tools=TOOLS)
# the model answers with text and a tool call -> two output items

second = client.responses.create(
    model=MODEL,
    input=[*first.output, {'type': 'function_call_output', 'call_id': call_id, 'output': 'sunny'}],
    tools=TOOLS,
)

Before this change the prompt behind second carries an end-of-turn marker between the assistant text and its tool call.

Checklist

  • pytest tests/test_lmdeploy/serve — 666 passed against 664 on main, plus the 2 new tests
  • The same 3 failures appear with and without this change, on main as well: test_tool_images.py::test_internvl_tool_image_placeholders_across_protocols[internvl3-*]. tests/test_lmdeploy/conftest.py reads internvl3.jinja with Path.read_text() and no encoding, and that template contains non-ASCII text, so it raises UnicodeDecodeError on a Windows machine whose ANSI codepage is not UTF-8. Unrelated to this change and invisible on Linux CI.
  • pre-commit run --all-files — all hooks pass
  • pylint lmdeploy/serve/openai/responses/request.py — same message set and exit code as main; only line numbers move
  • interrogate --fail-under 70 lmdeploy — passes at 71.7%
  • No GPU available here, so the engine-level suites and the end-to-end tests were not run locally

`messages_from_input` starts a new assistant message for a `function_call`
item unless it directly follows another call. One assistant turn is
reported as several output items, its text first, so replaying the turn the
way the endpoint produced it splits that turn into two consecutive
assistant messages.

Chat templates render every message as its own turn, so the replayed prompt
gains an end-of-turn marker between the assistant text and its own tool
call. `/v1/messages` and `/v1/chat/completions` keep such a turn in a single
message carrying `tool_calls`, and the same conversation now renders
identically on all three endpoints.

Join the call to the preceding assistant message instead. That subsumes the
adjacent-call grouping added in InternLM#5044, whose separate bookkeeping is no
longer needed. A call that does not follow an assistant message still
starts its own message.
@kodurd
kodurd force-pushed the fix/responses-tool-call-history branch from c695272 to 39b4d9c Compare October 22, 2026 11:00
@kodurd

kodurd commented Oct 28, 2026

Copy link
Copy Markdown
Author

Updated the branch to current main. The diff is unchanged, and tests/test_lmdeploy/serve/openai/responses/ is 152 passed locally on the new base.

@RunningLeon @silencelamb could one of you approve the workflow run here and have a look? CI has never run on this PR, while #5058 got its runs authorized and came back green on all three, so a run is the only thing missing on this one.

The behaviour is still on main: a function_call item that follows assistant text starts a second assistant turn instead of joining the turn it was emitted in, so a model that returns text and then calls a tool gets its own history back split in two.

@kodurd

kodurd commented Oct 29, 2026

Copy link
Copy Markdown
Author

unit_test failed on the dependency fetch, not on the change: the runner could not clone fmtlib/fmt (fatal: could not read Username for 'https://github.com', three attempts), so the editable build never finished and no test ran. The job was over in 49 seconds. lint and pr_functions_test were green on the same commit, and unit_test passed on another branch six minutes later, so it looks like a transient fetch problem on the runner.

Updated the branch to current main, which also picks up #5074. Could you re-approve the run?

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant