Merge af4aa61e0baf7ca9c4ca3653bca931c1bc29d045 into 935817c66f5d6e64838f419692545f06787e485f

This commit is contained in:
pood1e 2026-10-22 09:45:31 +08:00 committed by GitHub
commit f2e66ccec3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 228 additions and 3 deletions

View File

@ -0,0 +1,177 @@
"""Messages turn coalescing precedes tool-call boundary validation."""
import copy
import pytest
from deepseek_recipe import (
ConversionError,
ConversionOptions,
DeepseekV41Encoding,
MessagesRequest,
)
USES = [
{"type": "tool_use", "id": ident, "name": "lookup", "input": {"key": key}}
for ident, key in (("call_a", "a"), ("call_b", "b"))
]
RESULTS = [
{"type": "tool_result", "tool_use_id": ident, "content": value}
for ident, value in (("call_a", "1"), ("call_b", "2"))
]
def convert(messages):
return MessagesRequest(
{
"model": "DeepSeek-V4.1-Flash",
"thinking": {"type": "disabled"},
"messages": copy.deepcopy(messages),
"tools": [
{"name": "lookup", "input_schema": {"type": "object", "properties": {}}}
],
}
).convert(ConversionOptions())
def render(messages):
return (
DeepseekV41Encoding().render_conversation(convert(messages).conversation).prompt
)
def history():
return [
{"role": "user", "content": "Look up both keys."},
{"role": "assistant", "content": USES},
]
@pytest.mark.parametrize("reverse", [False, True])
@pytest.mark.parametrize("split_assistant", [False, True])
def test_parallel_tool_results_across_consecutive_user_messages(
reverse, split_assistant
):
results = RESULTS[::-1] if reverse else RESULTS
messages = history()
if split_assistant:
messages[1:] = [{"role": "assistant", "content": [use]} for use in USES]
split = messages + [{"role": "user", "content": [result]} for result in results]
combined = history() + [{"role": "user", "content": results}]
assert render(split) == render(combined)
converted = convert(split).conversation.messages
assert [message.role for message in converted] == [
"user",
"assistant",
"tool",
"tool",
]
assert [message.tool_call_id for message in converted[-2:]] == [
result["tool_use_id"] for result in results
]
@pytest.mark.parametrize("role", ["user", "assistant"])
@pytest.mark.parametrize("raw_first", [False, True])
@pytest.mark.parametrize("raw_second", [False, True])
def test_raw_and_block_content_coalesce(role, raw_first, raw_second):
first = "first" if raw_first else [{"type": "text", "text": "first"}]
second = "second" if raw_second else [{"type": "text", "text": "second"}]
split = [{"role": role, "content": first}, {"role": role, "content": second}]
combined = [
{
"role": role,
"content": [
{"type": "text", "text": "first"},
{"type": "text", "text": "second"},
],
}
]
assert render(split) == render(combined)
def test_text_can_follow_all_parallel_results_in_next_user_message():
split = history() + [
*[{"role": "user", "content": [result]} for result in RESULTS],
{"role": "user", "content": "Now sum the values."},
]
combined = history() + [
{
"role": "user",
"content": [*RESULTS, {"type": "text", "text": "Now sum the values."}],
}
]
assert render(split) == render(combined)
@pytest.mark.parametrize(
"tail,match",
[
([{"role": "user", "content": RESULTS[:1]}], "without `tool_result`"),
(
[
{"role": "user", "content": [RESULTS[0]]},
{"role": "user", "content": [RESULTS[0], RESULTS[1]]},
],
"multiple `tool_result`",
),
(
[
{"role": "user", "content": [RESULTS[0]]},
{"role": "user", "content": "interrupted"},
{"role": "user", "content": [RESULTS[1]]},
],
"without `tool_result`",
),
(
[
{"role": "user", "content": [RESULTS[0]]},
{"role": "assistant", "content": "interrupted"},
{"role": "user", "content": [RESULTS[1]]},
],
"without `tool_result`",
),
(
[
{"role": "user", "content": [RESULTS[0]]},
{"role": "system", "content": "boundary"},
{"role": "user", "content": [RESULTS[1]]},
],
"without `tool_result`",
),
(
[
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "unknown",
"content": "x",
}
],
}
],
"unexpected",
),
],
)
def test_invalid_tool_histories_still_fail(tail, match):
with pytest.raises(ConversionError, match=match):
convert(history() + tail)
def test_duplicate_tool_ids_across_assistant_messages_fail():
with pytest.raises(ConversionError, match="must be unique"):
convert(
[
{"role": "assistant", "content": [USES[0]]},
{"role": "assistant", "content": [USES[0]]},
{"role": "user", "content": [RESULTS[0]]},
]
)
@pytest.mark.parametrize("role", ["user", "assistant"])
def test_empty_block_message_is_not_hidden_by_coalescing(role):
with pytest.raises(ConversionError, match="messages.1: all messages"):
convert([{"role": role, "content": "valid"}, {"role": role, "content": []}])

View File

@ -36,6 +36,7 @@ use super::schema::{
MessagesMessage, MessagesRequest, MessagesSystemBlock, MessagesSystemContent, MessagesTool,
MessagesToolChoice, MessagesToolReference, MessagesToolResultBlock, MessagesToolResultContent,
};
use super::turns::coalesce_messages;
/// The tools of a request after name validation.
struct MessagesToolSet {
@ -415,11 +416,12 @@ fn ensure_no_pending_tool_uses(
/// message has an empty block list; when a `tool_use`, `server_tool_use`,
/// `thinking`, or `web_search_tool_result` block appears outside an
/// `assistant` message, or a `tool_result` block outside a `user` message;
/// when a `tool_use` ID repeats within one message, a `tool_result` answers no
/// when a `tool_use` ID repeats within one turn, a `tool_result` answers no
/// `tool_use` of the preceding message or repeats an answered ID, a text,
/// image, or document block follows a `tool_use` block in an `assistant`
/// message, or a `tool_use` is not answered by the `tool_result` blocks at
/// the start of the next message; when, for a model with image input, an
/// the start of the next turn; consecutive messages with the same `user` or
/// `assistant` role form one turn. When, for a model with image input, an
/// image source is invalid or an `assistant` message holds an image block;
/// or when an `assistant` message holds a `server_tool_use` or
/// `web_search_tool_result` block under [`WebSearchBehavior::Reject`].
@ -453,7 +455,7 @@ fn transform_messages(
.map(|tool| (tool.name.as_str(), tool))
.collect();
for (message_index, message) in messages.into_iter().enumerate() {
for (message_index, message) in coalesce_messages(messages)? {
match message {
MessagesMessage::User(MessagesContent::Raw(content)) => {
ensure_no_pending_tool_uses(message_index, &pending_tool_use_ids)?;

View File

@ -2,3 +2,4 @@ pub use schema::*;
mod convert;
mod schema;
mod turns;

View File

@ -0,0 +1,45 @@
use crate::request::ConversionError;
use super::schema::{MessagesContent, MessagesContentBlock, MessagesMessage};
/// Anthropic combines consecutive user or assistant messages into one turn.
/// Validate tool-call boundaries only after those turns have been assembled.
pub(super) fn coalesce_messages(
messages: Vec<MessagesMessage>,
) -> Result<Vec<(usize, MessagesMessage)>, ConversionError> {
let mut turns = Vec::with_capacity(messages.len());
for (index, message) in messages.into_iter().enumerate() {
if matches!(
&message,
MessagesMessage::User(MessagesContent::Blocks(blocks))
| MessagesMessage::Assistant(MessagesContent::Blocks(blocks))
if blocks.is_empty()
) {
return Err(ConversionError::bad_request(format!(
"messages.{index}: all messages must have non-empty content"
)));
}
match (turns.last_mut(), message) {
(Some((_, MessagesMessage::User(left))), MessagesMessage::User(right))
| (Some((_, MessagesMessage::Assistant(left))), MessagesMessage::Assistant(right)) => {
append_content(left, right);
}
(_, message) => turns.push((index, message)),
}
}
Ok(turns)
}
fn append_content(left: &mut MessagesContent, right: MessagesContent) {
let previous = std::mem::replace(left, MessagesContent::Blocks(Vec::new()));
let mut blocks = into_blocks(previous);
blocks.extend(into_blocks(right));
*left = MessagesContent::Blocks(blocks);
}
fn into_blocks(content: MessagesContent) -> Vec<MessagesContentBlock> {
match content {
MessagesContent::Raw(text) => vec![MessagesContentBlock::Text { text }],
MessagesContent::Blocks(blocks) => blocks,
}
}