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
30 changes: 27 additions & 3 deletions python/packages/core/agent_framework/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,8 +789,9 @@ class TextReasoningContent(BaseContent):

def __init__(
self,
text: str,
text: str | None,
*,
protected_data: str | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
Expand All @@ -802,6 +803,16 @@ def __init__(
text: The text content represented by this instance.

Keyword Args:
protected_data: This property is used to store data from a provider that should be roundtripped back to the
provider but that is not intended for human consumption. It is often encrypted or otherwise redacted
information that is only intended to be sent back to the provider and not displayed to the user. It's
possible for a TextReasoningContent to contain only `protected_data` and have an empty `text` property.
This data also may be associated with the corresponding `text`, acting as a validation signature for it.

Note that whereas `text` can be provider agnostic, `protected_data` is provider-specific, and is likely
to only be understood by the provider that created it. The data is often represented as a more complex
object, so it should be serialized to a string before storing so that the whole object is easily
serializable without loss.
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
annotations: Optional annotations associated with the content.
Expand All @@ -814,6 +825,7 @@ def __init__(
**kwargs,
)
self.text = text
self.protected_data = protected_data
self.type: Literal["text_reasoning"] = "text_reasoning"

def __add__(self, other: "TextReasoningContent") -> "TextReasoningContent":
Expand Down Expand Up @@ -846,13 +858,18 @@ def __add__(self, other: "TextReasoningContent") -> "TextReasoningContent":
else:
annotations = self.annotations + other.annotations

# Replace protected data.
# Discussion: https://github.com/microsoft/agent-framework/pull/2950#discussion_r2634345613
protected_data = other.protected_data or self.protected_data

# Create new instance using from_dict for proper deserialization
result_dict = {
"text": self.text + other.text,
"text": (self.text or "") + (other.text or "") if self.text is not None or other.text is not None else None,
"type": "text_reasoning",
"annotations": [ann.to_dict(exclude_none=False) for ann in annotations] if annotations else None,
"additional_properties": {**(self.additional_properties or {}), **(other.additional_properties or {})},
"raw_representation": raw_representation,
"protected_data": protected_data,
}
return TextReasoningContent.from_dict(result_dict)

Expand All @@ -869,7 +886,9 @@ def __iadd__(self, other: "TextReasoningContent") -> Self:
raise TypeError("Incompatible type")

# Concatenate text
self.text += other.text
if self.text is not None or other.text is not None:
self.text = (self.text or "") + (other.text or "")
# if both are None, should keep as None

# Merge additional properties (self takes precedence)
if self.additional_properties is None:
Expand All @@ -888,6 +907,11 @@ def __iadd__(self, other: "TextReasoningContent") -> Self:
self.raw_representation if isinstance(self.raw_representation, list) else [self.raw_representation]
) + (other.raw_representation if isinstance(other.raw_representation, list) else [other.raw_representation])

# Replace protected data.
# Discussion: https://github.com/microsoft/agent-framework/pull/2950#discussion_r2634345613
if other.protected_data is not None:
self.protected_data = other.protected_data

# Merge annotations
if other.annotations:
if self.annotations is None:
Expand Down
11 changes: 11 additions & 0 deletions python/packages/core/agent_framework/openai/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
FunctionResultContent,
Role,
TextContent,
TextReasoningContent,
UriContent,
UsageContent,
UsageDetails,
Expand Down Expand Up @@ -234,6 +235,8 @@ def _parse_response_from_openai(self, response: ChatCompletion, chat_options: Ch
contents.append(text_content)
if parsed_tool_calls := [tool for tool in self._parse_tool_calls_from_openai(choice)]:
contents.extend(parsed_tool_calls)
if reasoning_details := getattr(choice.message, "reasoning_details", None):
contents.append(TextReasoningContent(None, protected_data=json.dumps(reasoning_details)))
messages.append(ChatMessage(role="assistant", contents=contents))
return ChatResponse(
response_id=response.id,
Expand Down Expand Up @@ -271,6 +274,8 @@ def _parse_response_update_from_openai(

if text_content := self._parse_text_from_openai(choice):
contents.append(text_content)
if reasoning_details := getattr(choice.delta, "reasoning_details", None):
contents.append(TextReasoningContent(None, protected_data=json.dumps(reasoning_details)))
return ChatResponseUpdate(
created_at=datetime.fromtimestamp(chunk.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
contents=contents,
Expand Down Expand Up @@ -394,6 +399,10 @@ def _prepare_message_for_openai(self, message: ChatMessage) -> list[dict[str, An
}
if message.author_name and message.role != Role.TOOL:
args["name"] = message.author_name
if "reasoning_details" in message.additional_properties and (
details := message.additional_properties["reasoning_details"]
):
args["reasoning_details"] = details
match content:
case FunctionCallContent():
if all_messages and "tool_calls" in all_messages[-1]:
Expand All @@ -405,6 +414,8 @@ def _prepare_message_for_openai(self, message: ChatMessage) -> list[dict[str, An
args["tool_call_id"] = content.call_id
if content.result is not None:
args["content"] = prepare_function_call_results(content.result)
case TextReasoningContent(protected_data=protected_data) if protected_data is not None:
all_messages[-1]["reasoning_details"] = json.loads(protected_data)
case _:
if "content" not in args:
args["content"] = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,8 @@ def _format_user_message(self, message: ChatMessage) -> list[OllamaMessage]:

def _format_assistant_message(self, message: ChatMessage) -> list[OllamaMessage]:
text_content = message.text
reasoning_contents = "".join(c.text for c in message.contents if isinstance(c, TextReasoningContent))
# Ollama shouldn't have encrypted reasoning, so we just process text.
reasoning_contents = "".join((c.text or "") for c in message.contents if isinstance(c, TextReasoningContent))

assistant_message = OllamaMessage(role="assistant", content=text_content, thinking=reasoning_contents)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async def reasoning_example() -> None:
print(f"User: {query}")
# Enable Reasoning on per request level
result = await agent.run(query)
reasoning = "".join(c.text for c in result.messages[-1].contents if isinstance(c, TextReasoningContent))
reasoning = "".join((c.text or "") for c in result.messages[-1].contents if isinstance(c, TextReasoningContent))
print(f"Reasoning: {reasoning}")
print(f"Answer: {result}\n")

Expand Down
Loading