From b812cb2417115596e472b115a8778179ba2b0b78 Mon Sep 17 00:00:00 2001 From: c <37263590+Aphroq@users.noreply.github.com> Date: Sat, 9 May 2026 16:41:33 +0000 Subject: [PATCH 1/2] Preserve structured nested handoff history --- src/agents/handoffs/history.py | 101 ++++++++++++++++++++++++++++++-- tests/test_extension_filters.py | 84 ++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 6 deletions(-) diff --git a/src/agents/handoffs/history.py b/src/agents/handoffs/history.py index 8fda1b3a7f..c63368ec23 100644 --- a/src/agents/handoffs/history.py +++ b/src/agents/handoffs/history.py @@ -159,6 +159,24 @@ def _build_summary_message(transcript: list[TResponseInputItem]) -> TResponseInp def _format_transcript_item(item: TResponseInputItem) -> str: + role = item.get("role") + if isinstance(role, str): + content = item.get("content") + if content is None or isinstance(content, str): + return _format_transcript_item_legacy(item) + return _format_transcript_item_json(item) + + +def _format_transcript_item_json(item: TResponseInputItem) -> str: + payload = cast(dict[str, Any], deepcopy(item)) + payload.pop("provider_data", None) + try: + return json.dumps(payload, ensure_ascii=False, default=str) + except (TypeError, ValueError): + return _format_transcript_item_legacy(item) + + +def _format_transcript_item_legacy(item: TResponseInputItem) -> str: role = item.get("role") if isinstance(role, str): prefix = role @@ -209,27 +227,63 @@ def _extract_nested_history_transcript( return None start_marker, end_marker = get_conversation_history_wrappers() start_idx = content.find(start_marker) - end_idx = content.find(end_marker) + end_idx = content.rfind(end_marker) if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: return None start_idx += len(start_marker) body = content[start_idx:end_idx] - lines = [line.strip() for line in body.splitlines() if line.strip()] parsed: list[TResponseInputItem] = [] - for line in lines: + for line in _split_summary_records(body): parsed_item = _parse_summary_line(line) if parsed_item is not None: parsed.append(parsed_item) return parsed +def _split_summary_records(body: str) -> list[str]: + records: list[str] = [] + current: list[str] = [] + current_is_numbered = False + + for raw_line in body.splitlines(): + if not raw_line.strip(): + continue + + starts_numbered_record = _starts_numbered_summary_record(raw_line) + if not current: + current = [raw_line.strip()] + current_is_numbered = starts_numbered_record + continue + + if starts_numbered_record or not current_is_numbered: + records.append("\n".join(current)) + current = [raw_line.strip()] + current_is_numbered = starts_numbered_record + continue + + current.append(raw_line.rstrip()) + + if current: + records.append("\n".join(current)) + + return records + + +def _starts_numbered_summary_record(line: str) -> bool: + stripped = line.lstrip() + dot_index = stripped.find(".") + return dot_index != -1 and stripped[:dot_index].isdigit() + + def _parse_summary_line(line: str) -> TResponseInputItem | None: stripped = line.strip() if not stripped: return None - dot_index = stripped.find(".") - if dot_index != -1 and stripped[:dot_index].isdigit(): - stripped = stripped[dot_index + 1 :].lstrip() + stripped = _strip_summary_line_number(stripped) + parsed_json = _parse_summary_json_item(stripped) + if parsed_json is not None: + return parsed_json + role_part, sep, remainder = stripped.partition(":") if not sep: return None @@ -242,10 +296,45 @@ def _parse_summary_line(line: str) -> TResponseInputItem | None: reconstructed["name"] = name content = remainder.strip() if content: + legacy_typed_item = _parse_legacy_typed_item(role, content) + if legacy_typed_item is not None: + return legacy_typed_item reconstructed["content"] = content return cast(TResponseInputItem, reconstructed) +def _strip_summary_line_number(stripped: str) -> str: + dot_index = stripped.find(".") + if dot_index != -1 and stripped[:dot_index].isdigit(): + return stripped[dot_index + 1 :].lstrip() + return stripped + + +def _parse_summary_json_item(value: str) -> TResponseInputItem | None: + try: + parsed = json.loads(value) + except (json.JSONDecodeError, TypeError): + return None + if not isinstance(parsed, dict): + return None + parsed.pop("provider_data", None) + return cast(TResponseInputItem, parsed) + + +def _parse_legacy_typed_item(item_type: str, content: str) -> TResponseInputItem | None: + if item_type in {"assistant", "user", "system", "developer"}: + return None + try: + parsed = json.loads(content) + except (json.JSONDecodeError, TypeError): + return None + if not isinstance(parsed, dict): + return None + parsed.pop("provider_data", None) + parsed["type"] = item_type + return cast(TResponseInputItem, parsed) + + def _split_role_and_name(role_text: str) -> tuple[str, str | None]: if role_text.endswith(")") and "(" in role_text: open_idx = role_text.rfind("(") diff --git a/tests/test_extension_filters.py b/tests/test_extension_filters.py index 760cc2379d..4ec8849cf9 100644 --- a/tests/test_extension_filters.py +++ b/tests/test_extension_filters.py @@ -544,6 +544,90 @@ def test_nest_handoff_history_content_handling() -> None: assert "Hello" in summary_content2 or "text" in summary_content2 +def test_nest_handoff_history_flattens_multiline_content_without_truncation() -> None: + captured: list[TResponseInputItem] = [] + + def capture_transcript(transcript: list[TResponseInputItem]) -> list[TResponseInputItem]: + captured.extend(deepcopy(transcript)) + return transcript + + first_nested = nest_handoff_history( + handoff_data( + input_history=( + cast(TResponseInputItem, {"role": "user", "content": "first line\nsecond line"}), + ), + ) + ) + + nest_handoff_history( + handoff_data(input_history=first_nested.input_history), + history_mapper=capture_transcript, + ) + + assert captured == [ + cast(TResponseInputItem, {"role": "user", "content": "first line\nsecond line"}) + ] + + +def test_nest_handoff_history_flattens_structured_content_without_stringifying() -> None: + captured: list[TResponseInputItem] = [] + content = [ + {"type": "input_text", "text": "look at this"}, + {"type": "input_image", "image_url": "https://example.com/image.png"}, + ] + + def capture_transcript(transcript: list[TResponseInputItem]) -> list[TResponseInputItem]: + captured.extend(deepcopy(transcript)) + return transcript + + first_nested = nest_handoff_history( + handoff_data( + input_history=(cast(TResponseInputItem, {"role": "user", "content": content}),), + ) + ) + + nest_handoff_history( + handoff_data(input_history=first_nested.input_history), + history_mapper=capture_transcript, + ) + + assert captured == [cast(TResponseInputItem, {"role": "user", "content": content})] + captured_message = cast(dict[str, Any], captured[0]) + assert isinstance(captured_message["content"], list) + + +def test_nest_handoff_history_flattens_legacy_multiline_summary_records() -> None: + captured: list[TResponseInputItem] = [] + summary_item = cast( + TResponseInputItem, + { + "role": "assistant", + "content": ( + "For context, here is the conversation so far:\n" + "\n" + "1. user: first line\n" + "second line\n" + "2. assistant: reply\n" + "" + ), + }, + ) + + def capture_transcript(transcript: list[TResponseInputItem]) -> list[TResponseInputItem]: + captured.extend(deepcopy(transcript)) + return transcript + + nest_handoff_history( + handoff_data(input_history=(summary_item,)), + history_mapper=capture_transcript, + ) + + assert captured == [ + cast(TResponseInputItem, {"role": "user", "content": "first line\nsecond line"}), + cast(TResponseInputItem, {"role": "assistant", "content": "reply"}), + ] + + def test_nest_handoff_history_extract_nested_non_string_content() -> None: """Test that _extract_nested_history_transcript handles non-string content.""" # Create a summary message with non-string content (array) From 6b9de693c3bf5a67f2d003423fd5cbb762b23f03 Mon Sep 17 00:00:00 2001 From: c <37263590+Aphroq@users.noreply.github.com> Date: Sun, 10 May 2026 05:57:26 +0000 Subject: [PATCH 2/2] Serialize multiline handoff history strings --- src/agents/handoffs/history.py | 6 +++++- tests/test_extension_filters.py | 7 +++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/agents/handoffs/history.py b/src/agents/handoffs/history.py index c63368ec23..efea013523 100644 --- a/src/agents/handoffs/history.py +++ b/src/agents/handoffs/history.py @@ -162,11 +162,15 @@ def _format_transcript_item(item: TResponseInputItem) -> str: role = item.get("role") if isinstance(role, str): content = item.get("content") - if content is None or isinstance(content, str): + if content is None or (isinstance(content, str) and not _contains_newline(content)): return _format_transcript_item_legacy(item) return _format_transcript_item_json(item) +def _contains_newline(value: str) -> bool: + return "\n" in value or "\r" in value + + def _format_transcript_item_json(item: TResponseInputItem) -> str: payload = cast(dict[str, Any], deepcopy(item)) payload.pop("provider_data", None) diff --git a/tests/test_extension_filters.py b/tests/test_extension_filters.py index 4ec8849cf9..a4b95f792a 100644 --- a/tests/test_extension_filters.py +++ b/tests/test_extension_filters.py @@ -554,7 +554,10 @@ def capture_transcript(transcript: list[TResponseInputItem]) -> list[TResponseIn first_nested = nest_handoff_history( handoff_data( input_history=( - cast(TResponseInputItem, {"role": "user", "content": "first line\nsecond line"}), + cast( + TResponseInputItem, + {"role": "user", "content": "first line\n2. not a new record"}, + ), ), ) ) @@ -565,7 +568,7 @@ def capture_transcript(transcript: list[TResponseInputItem]) -> list[TResponseIn ) assert captured == [ - cast(TResponseInputItem, {"role": "user", "content": "first line\nsecond line"}) + cast(TResponseInputItem, {"role": "user", "content": "first line\n2. not a new record"}) ]