Skip to content
Merged
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
105 changes: 99 additions & 6 deletions src/agents/handoffs/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,28 @@ 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) 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)
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
Expand Down Expand Up @@ -209,27 +231,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
Expand All @@ -242,10 +300,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("(")
Expand Down
87 changes: 87 additions & 0 deletions tests/test_extension_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,93 @@ 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\n2. not a new record"},
),
),
)
)

nest_handoff_history(
handoff_data(input_history=first_nested.input_history),
history_mapper=capture_transcript,
)

assert captured == [
cast(TResponseInputItem, {"role": "user", "content": "first line\n2. not a new record"})
]


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"
"<CONVERSATION HISTORY>\n"
"1. user: first line\n"
"second line\n"
"2. assistant: reply\n"
"</CONVERSATION HISTORY>"
),
},
)

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)
Expand Down
Loading