Skip to content

Commit 46ab47b

Browse files
authored
Python: Fix file_search citations breaking assistant history roundtrip (microsoft#5557)
* Python: Fix file_search citations breaking assistant history roundtrip The Responses API rejects 'input_file' inside an assistant message, but the SDK was emitting it whenever an assistant Message contained a hosted_file content (which is what file_search citations become). Three coordinated fixes: 1. _prepare_content_for_openai now skips hosted_file for the assistant role instead of mapping to input_file (which the API rejects there). 2. The streaming response.output_text.annotation.added handler attaches file_citation, container_file_citation, and file_path as annotations on text content, matching the non-streaming path. Previously streaming produced standalone HostedFileContent items that always tripped (1). 3. output_text serialization preserves Annotation objects on roundtrip via a new _annotations_to_output_text helper instead of hardcoding 'annotations' to []. file_search citations now survive multi-agent forwarding. Closes microsoft#5556. * Address PR review - _annotations_to_output_text: fan out one entry per annotated_region for url_citation/container_file_citation (Annotation.annotated_regions is a Sequence; the API form carries one start/end per entry). - Validate region span bounds are ints before emitting; skip otherwise. - Add test for the file_path branch (annotation with file_id only). - Add test verifying streamed citation events coalesce onto surrounding text via _finalize_response so span indices reference the merged text, not the empty-text streaming carrier.
1 parent 094f990 commit 46ab47b

2 files changed

Lines changed: 450 additions & 49 deletions

File tree

python/packages/openai/agent_framework_openai/_chat_client.py

Lines changed: 133 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,85 @@ class OpenAIChatOptions(ChatOptions[ResponseFormatT], Generic[ResponseFormatT],
241241
# endregion
242242

243243

244+
# region Helpers
245+
246+
247+
def _annotations_to_output_text(annotations: Sequence[Annotation] | None) -> list[dict[str, Any]]:
248+
"""Convert framework `Annotation` objects to Responses API `output_text` annotation dicts.
249+
250+
Citations from `file_search`, `code_interpreter` file paths, and url citations all collapse
251+
to `Annotation(type="citation", ...)` in the framework. The original API form is recovered
252+
here so assistant messages roundtrip cleanly through history forwarding.
253+
254+
Each Responses API annotation dict carries at most one `start_index`/`end_index` pair, so an
255+
`Annotation` with multiple `annotated_regions` is fanned out into one entry per region.
256+
Regions missing valid integer span bounds are skipped.
257+
"""
258+
if not annotations:
259+
return []
260+
out: list[dict[str, Any]] = []
261+
for annotation in annotations:
262+
if annotation.get("type") != "citation":
263+
continue
264+
props = annotation.get("additional_properties") or {}
265+
regions = annotation.get("annotated_regions") or []
266+
file_id = annotation.get("file_id")
267+
url = annotation.get("url")
268+
title = annotation.get("title")
269+
container_id = props.get("container_id")
270+
271+
if container_id and file_id:
272+
for region in regions:
273+
start = region.get("start_index")
274+
end = region.get("end_index")
275+
if not (isinstance(start, int) and isinstance(end, int)):
276+
continue
277+
entry: dict[str, Any] = {
278+
"type": "container_file_citation",
279+
"container_id": container_id,
280+
"file_id": file_id,
281+
"start_index": start,
282+
"end_index": end,
283+
}
284+
if url:
285+
entry["filename"] = url
286+
out.append(entry)
287+
elif url and not file_id and regions:
288+
for region in regions:
289+
start = region.get("start_index")
290+
end = region.get("end_index")
291+
if not (isinstance(start, int) and isinstance(end, int)):
292+
continue
293+
out.append({
294+
"type": "url_citation",
295+
"url": url,
296+
"title": title or "",
297+
"start_index": start,
298+
"end_index": end,
299+
})
300+
elif file_id and url:
301+
entry = {
302+
"type": "file_citation",
303+
"file_id": file_id,
304+
"filename": url,
305+
}
306+
if (idx := props.get("index")) is not None:
307+
entry["index"] = idx
308+
out.append(entry)
309+
elif file_id:
310+
entry = {
311+
"type": "file_path",
312+
"file_id": file_id,
313+
}
314+
if (idx := props.get("index")) is not None:
315+
entry["index"] = idx
316+
out.append(entry)
317+
return out
318+
319+
320+
# endregion
321+
322+
244323
# region ResponsesClient
245324

246325

@@ -1374,7 +1453,7 @@ def _prepare_content_for_openai(
13741453
return {
13751454
"type": "output_text",
13761455
"text": content.text,
1377-
"annotations": [],
1456+
"annotations": _annotations_to_output_text(getattr(content, "annotations", None)),
13781457
}
13791458
return {
13801459
"type": "input_text",
@@ -1522,6 +1601,13 @@ def _prepare_content_for_openai(
15221601
"approve": content.approved,
15231602
}
15241603
case "hosted_file":
1604+
# `input_file` is an input-only content type in the Responses API and is rejected
1605+
# inside an assistant message. Hosted-file content on an assistant message
1606+
# represents a citation produced by a hosted tool (e.g., file_search) and cannot be
1607+
# meaningfully replayed as input — drop it. The accompanying text annotations carry
1608+
# the citation context for round-tripping.
1609+
if role == "assistant":
1610+
return {}
15251611
return {
15261612
"type": "input_file",
15271613
"file_id": content.file_id,
@@ -2502,45 +2588,63 @@ def _get_ann_value(key: str) -> Any:
25022588

25032589
ann_type = _get_ann_value("type")
25042590
ann_file_id = _get_ann_value("file_id")
2591+
# Hosted-file citations attach as text annotations (matching the non-streaming path)
2592+
# so they don't roundtrip as standalone `input_file` items in assistant history.
25052593
if ann_type == "file_path":
25062594
if ann_file_id:
2595+
annotation_obj = Annotation(
2596+
type="citation",
2597+
file_id=str(ann_file_id),
2598+
additional_properties={
2599+
"annotation_index": event.annotation_index,
2600+
"index": _get_ann_value("index"),
2601+
},
2602+
raw_representation=annotation,
2603+
)
25072604
contents.append(
2508-
Content.from_hosted_file(
2509-
file_id=str(ann_file_id),
2510-
additional_properties={
2511-
"annotation_index": event.annotation_index,
2512-
"index": _get_ann_value("index"),
2513-
},
2514-
raw_representation=event,
2515-
)
2605+
Content.from_text(text="", annotations=[annotation_obj], raw_representation=event)
25162606
)
25172607
elif ann_type == "file_citation":
25182608
if ann_file_id:
2609+
ann_filename = _get_ann_value("filename")
2610+
annotation_obj = Annotation(
2611+
type="citation",
2612+
file_id=str(ann_file_id),
2613+
url=ann_filename,
2614+
additional_properties={
2615+
"annotation_index": event.annotation_index,
2616+
"index": _get_ann_value("index"),
2617+
},
2618+
raw_representation=annotation,
2619+
)
25192620
contents.append(
2520-
Content.from_hosted_file(
2521-
file_id=str(ann_file_id),
2522-
additional_properties={
2523-
"annotation_index": event.annotation_index,
2524-
"filename": _get_ann_value("filename"),
2525-
"index": _get_ann_value("index"),
2526-
},
2527-
raw_representation=event,
2528-
)
2621+
Content.from_text(text="", annotations=[annotation_obj], raw_representation=event)
25292622
)
25302623
elif ann_type == "container_file_citation":
25312624
if ann_file_id:
2625+
ann_filename = _get_ann_value("filename")
2626+
ann_start = _get_ann_value("start_index")
2627+
ann_end = _get_ann_value("end_index")
2628+
annotation_obj = Annotation(
2629+
type="citation",
2630+
file_id=str(ann_file_id),
2631+
url=ann_filename,
2632+
additional_properties={
2633+
"annotation_index": event.annotation_index,
2634+
"container_id": _get_ann_value("container_id"),
2635+
},
2636+
raw_representation=annotation,
2637+
)
2638+
if ann_start is not None and ann_end is not None:
2639+
annotation_obj["annotated_regions"] = [
2640+
TextSpanRegion(
2641+
type="text_span",
2642+
start_index=ann_start,
2643+
end_index=ann_end,
2644+
)
2645+
]
25322646
contents.append(
2533-
Content.from_hosted_file(
2534-
file_id=str(ann_file_id),
2535-
additional_properties={
2536-
"annotation_index": event.annotation_index,
2537-
"container_id": _get_ann_value("container_id"),
2538-
"filename": _get_ann_value("filename"),
2539-
"start_index": _get_ann_value("start_index"),
2540-
"end_index": _get_ann_value("end_index"),
2541-
},
2542-
raw_representation=event,
2543-
)
2647+
Content.from_text(text="", annotations=[annotation_obj], raw_representation=event)
25442648
)
25452649
elif ann_type == "url_citation":
25462650
ann_url = _get_ann_value("url")

0 commit comments

Comments
 (0)