Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions swift/dataset/preprocessor/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,18 +568,39 @@ def _anthropic_block_content(cls, content: Any, media: Dict[str, List[Any]]) ->
raise ValueError(f'Unsupported Anthropic content block type: {block_type}')
return ''.join(parts)

@staticmethod
def _align_anthropic_tool_results(tool_uses: List[Dict[str, Any]],
content: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# Canonical tool responses are positional, so align a complete result batch
# before the tool_use IDs are discarded. Keep incomplete or ID-less batches as-is.
call_order = {block.get('id'): i for i, block in enumerate(tool_uses)}
end = 0
while end < len(content) and content[end].get('type') == 'tool_result':
end += 1
results = content[:end]
if (None not in call_order and len(call_order) == len(tool_uses) == len(results)
and set(call_order) == {result.get('tool_use_id')
for result in results}):
content = sorted(results, key=lambda result: call_order[result['tool_use_id']]) + content[end:]
return content

@classmethod
def anthropic_to_messages(cls,
messages: List[Dict[str, Any]],
media: Optional[Dict[str, List[Any]]] = None) -> List[Dict[str, Any]]:
"""Convert Anthropic content blocks to the SWIFT canonical roles."""
media = media if media is not None else {'images': []}
new_messages = []
tool_uses = []
for message in messages:
content = message.get('content')
if not isinstance(content, list):
new_messages.append(message)
tool_uses = []
continue
if tool_uses:
content = cls._align_anthropic_tool_results(tool_uses, content)
tool_uses = [block for block in content if block.get('type') == 'tool_use']

pending_content = []
message_metadata = {key: message[key] for key in ['loss', 'loss_scale'] if key in message}
Expand Down
124 changes: 124 additions & 0 deletions tests/general/test_data_preprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,130 @@ def test_anthropic_multimodal_content_blocks(self):
self.assertEqual(template_inputs.images, result['images'])
self.assertEqual(template_inputs.messages[-1]['content'], '<image>A sunny beach.')

def test_anthropic_parallel_tool_results_out_of_order(self):
# Anthropic pairs `tool_result` blocks with `tool_use` blocks by `tool_use_id`, not by
# position, so results may arrive in a different order from the calls. Canonical
# `tool_response` messages are positional (the IDs are discarded), so the results must
# be aligned to the call order first, as `normalize_openai_tool_calls` does for OpenAI
# `tool_calls` (#10174).
tool_uses = [{
'type': 'tool_use',
'id': 'toolu_beijing',
'name': 'get_weather',
'input': {
'city': 'Beijing'
},
}, {
'type': 'tool_use',
'id': 'toolu_shanghai',
'name': 'get_weather',
'input': {
'city': 'Shanghai'
},
}]
results = {
'toolu_beijing': {
'type': 'tool_result',
'tool_use_id': 'toolu_beijing',
'content': 'sunny'
},
'toolu_shanghai': {
'type': 'tool_result',
'tool_use_id': 'toolu_shanghai',
'content': 'rainy'
},
}
expected = [{
'role': 'tool_call',
'content': {
'name': 'get_weather',
'arguments': {
'city': 'Beijing'
}
},
}, {
'role': 'tool_call',
'content': {
'name': 'get_weather',
'arguments': {
'city': 'Shanghai'
}
},
}, {
'role': 'tool_response',
'content': 'sunny'
}, {
'role': 'tool_response',
'content': 'rainy'
}]
for order in (['toolu_beijing', 'toolu_shanghai'], ['toolu_shanghai', 'toolu_beijing']):
with self.subTest(result_order=order):
row = {
'messages': [{
'role': 'assistant',
'content': tool_uses,
}, {
'role': 'user',
'content': [results[tool_use_id] for tool_use_id in order],
}]
}
result = AnthropicMessagesPreprocessor().preprocess(row)
self.assertEqual(result['messages'], expected)

def test_anthropic_out_of_order_tool_results_keep_images_aligned(self):
# Reordering the result blocks (not the emitted messages) keeps `images` consistent
# with the `<image>` placeholders, and a trailing user text block stays after the results.
def image_result(name):
return {
'type':
'tool_result',
'tool_use_id':
f'toolu_{name}',
'content': [{
'type': 'image',
'source': {
'type': 'url',
'url': f'https://example.com/{name}.png'
}
}, {
'type': 'text',
'text': name
}],
}

row = {
'messages': [{
'role':
'assistant',
'content': [{
'type': 'tool_use',
'id': f'toolu_{name}',
'name': 'inspect_image',
'input': {}
} for name in ['first', 'second']],
}, {
'role':
'user',
'content': [image_result('second'),
image_result('first'), {
'type': 'text',
'text': 'Thanks.'
}],
}]
}
result = AnthropicMessagesPreprocessor().preprocess(row)
self.assertEqual(result['messages'][2:], [{
'role': 'tool_response',
'content': '<image>first',
}, {
'role': 'tool_response',
'content': '<image>second',
}, {
'role': 'user',
'content': 'Thanks.',
}])
self.assertEqual(result['images'], ['https://example.com/first.png', 'https://example.com/second.png'])


if __name__ == '__main__':
unittest.main()