diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index ab68382a83..637794b1ca 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -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, @@ -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. @@ -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": @@ -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) @@ -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: @@ -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: diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index 940858b670..b7cac3ba20 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -34,6 +34,7 @@ FunctionResultContent, Role, TextContent, + TextReasoningContent, UriContent, UsageContent, UsageDetails, @@ -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, @@ -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, @@ -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]: @@ -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"] = [] diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index d8856143b8..f047a5d4b3 100644 --- a/python/packages/ollama/agent_framework_ollama/_chat_client.py +++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py @@ -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) diff --git a/python/samples/getting_started/agents/ollama/ollama_agent_reasoning.py b/python/samples/getting_started/agents/ollama/ollama_agent_reasoning.py index e123ca04b5..e0ce24bb85 100644 --- a/python/samples/getting_started/agents/ollama/ollama_agent_reasoning.py +++ b/python/samples/getting_started/agents/ollama/ollama_agent_reasoning.py @@ -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")