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
15 changes: 15 additions & 0 deletions python/packages/core/agent_framework/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ def _parse_content_list(contents_data: Sequence[Any]) -> list[Content]:
return contents


def normalize_function_call_arguments(
arguments: str | Mapping[str, Any] | None,
) -> str | dict[str, Any] | None:
"""Normalize provider tool-call arguments to a mapping when possible."""
if not isinstance(arguments, str):
return dict(arguments) if isinstance(arguments, Mapping) else arguments

try:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The normalization belongs inside Content.from_function_call() rather than as a standalone helper. All current and future call sites automatically benefit, and the existing parse_arguments() method's JSON-parsing logic is no longer duplicated. Applying it only at selected call sites leads to a Content whose arguments type depends on which parsing path produced it.

loaded = json.loads(arguments)
except (json.JSONDecodeError, TypeError):
return arguments

return loaded if isinstance(loaded, dict) else arguments


# region Internal Helper functions for unified Content


Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This utility function has five distinct code paths (None, non-str Mapping, non-str non-Mapping, valid JSON dict string, and non-dict-JSON/invalid-JSON string) but no direct unit tests. Add a parametrized test covering at least: NoneNone, '{"a":1}'{"a":1}, 'not json''not json', '[1,2]''[1,2]', {"a":1} (dict) → {"a":1}, and an OrderedDict → plain dict.

Expand Down
3 changes: 2 additions & 1 deletion python/packages/core/agent_framework/openai/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
Message,
ResponseStream,
UsageDetails,
normalize_function_call_arguments,
)
from ..exceptions import (
ChatClientException,
Expand Down Expand Up @@ -556,7 +557,7 @@ def _parse_tool_calls_from_openai(self, choice: Choice | ChunkChoice) -> list[Co
fcc = Content.from_function_call(
call_id=tool.id if tool.id else "",
name=tool.function.name if tool.function.name else "",
arguments=tool.function.arguments if tool.function.arguments else "",
arguments=normalize_function_call_arguments(tool.function.arguments if tool.function.arguments else ""),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is called for both non-streaming Choice and streaming ChunkChoice. Normalizing here means streaming chunks with partial JSON stay as strings (fine), but if a chunk contains complete valid JSON it becomes a dict, and a subsequent string chunk causes TypeError('Incompatible argument types') in _add_function_call_content. Consider gating normalization on the non-streaming path (isinstance(choice, Choice)) or applying it after accumulation.

raw_representation=tool.function,
)
resp.append(fcc)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
TextSpanRegion,
UsageDetails,
detect_media_type_from_base64,
normalize_function_call_arguments,
prepend_instructions_to_messages,
validate_tool_mode,
)
Expand Down Expand Up @@ -1523,7 +1524,7 @@ def _parse_response_from_openai(
Content.from_function_call(
call_id=item.call_id,
name=item.name,
arguments=item.arguments,
arguments=normalize_function_call_arguments(item.arguments),
additional_properties={"fc_id": item.id, "status": item.status},
raw_representation=item,
)
Expand All @@ -1535,7 +1536,7 @@ def _parse_response_from_openai(
function_call=Content.from_function_call(
call_id=item.id,
name=item.name,
arguments=item.arguments,
arguments=normalize_function_call_arguments(item.arguments),
additional_properties={"server_label": item.server_label},
raw_representation=item,
),
Expand Down Expand Up @@ -1915,7 +1916,7 @@ def _parse_chunk_from_openai(
function_call=Content.from_function_call(
call_id=event_item.id,
name=event_item.name,
arguments=event_item.arguments,
arguments=normalize_function_call_arguments(event_item.arguments),
additional_properties={"server_label": event_item.server_label},
raw_representation=event_item,
),
Expand Down
26 changes: 26 additions & 0 deletions python/packages/core/tests/openai/test_openai_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,32 @@ def test_function_approval_content_is_skipped_in_preparation(
assert prepared_mixed[0]["content"] == "I need approval for this action."


def test_parse_tool_calls_from_openai_normalizes_json_object_arguments(
openai_unit_test_env: dict[str, str],
) -> None:
client = OpenAIChatClient()

mock_tool_function = MagicMock()
mock_tool_function.name = "get_weather"
mock_tool_function.arguments = '{"city": "Seattle"}'

mock_tool = MagicMock()
mock_tool.id = "call_123"
mock_tool.function = mock_tool_function

mock_message = MagicMock()
mock_message.tool_calls = [mock_tool]

mock_choice = MagicMock()
mock_choice.delta = mock_message

contents = client._parse_tool_calls_from_openai(mock_choice)

assert len(contents) == 1
assert contents[0].type == "function_call"
assert contents[0].arguments == {"city": "Seattle"}


def test_usage_content_in_streaming_response(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test only covers the JSON-object-string → dict path. Consider adding cases for non-JSON arguments (e.g., arguments = 'plain text' should pass through as-is) and empty string (arguments = '') to verify the fallback behavior.

openai_unit_test_env: dict[str, str],
) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1037,7 +1037,7 @@ def test_response_content_creation_with_function_call() -> None:
function_call = response.messages[0].contents[0]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only responses-client test covering function_call argument normalization in the non-streaming path. Consider adding a case where arguments is not valid JSON (e.g., partial/truncated) to confirm passthrough behavior.

assert function_call.call_id == "call_123"
assert function_call.name == "get_weather"
assert function_call.arguments == '{"location": "Seattle"}'
assert function_call.arguments == {"location": "Seattle"}


def test_prepare_content_for_opentool_approval_response() -> None:
Expand Down Expand Up @@ -3581,7 +3581,7 @@ def test_parse_response_from_openai_function_call_includes_status() -> None:
assert function_call.type == "function_call"
assert function_call.call_id == "call_123"
assert function_call.name == "get_weather"
assert function_call.arguments == '{"location": "Seattle"}'
assert function_call.arguments == {"location": "Seattle"}
# Verify status is included in additional_properties
assert function_call.additional_properties is not None
assert function_call.additional_properties.get("status") == "completed"
Expand Down