Skip to content

Commit 85d70f0

Browse files
authored
Python: Preserve reasoning blocks with OpenRouter (#2950)
* Preserve reasoning blocks with OpenRouter * Put encrypted reasoning in TextReasoningContent * Remove unneccessary change * Fix docs * Support streaming * Fix handling None in TextReasoningContent.text
1 parent 6930c0f commit 85d70f0

File tree

4 files changed

+41
-5
lines changed

4 files changed

+41
-5
lines changed

python/packages/core/agent_framework/_types.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -789,8 +789,9 @@ class TextReasoningContent(BaseContent):
789789

790790
def __init__(
791791
self,
792-
text: str,
792+
text: str | None,
793793
*,
794+
protected_data: str | None = None,
794795
additional_properties: dict[str, Any] | None = None,
795796
raw_representation: Any | None = None,
796797
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
@@ -802,6 +803,16 @@ def __init__(
802803
text: The text content represented by this instance.
803804
804805
Keyword Args:
806+
protected_data: This property is used to store data from a provider that should be roundtripped back to the
807+
provider but that is not intended for human consumption. It is often encrypted or otherwise redacted
808+
information that is only intended to be sent back to the provider and not displayed to the user. It's
809+
possible for a TextReasoningContent to contain only `protected_data` and have an empty `text` property.
810+
This data also may be associated with the corresponding `text`, acting as a validation signature for it.
811+
812+
Note that whereas `text` can be provider agnostic, `protected_data` is provider-specific, and is likely
813+
to only be understood by the provider that created it. The data is often represented as a more complex
814+
object, so it should be serialized to a string before storing so that the whole object is easily
815+
serializable without loss.
805816
additional_properties: Optional additional properties associated with the content.
806817
raw_representation: Optional raw representation of the content.
807818
annotations: Optional annotations associated with the content.
@@ -814,6 +825,7 @@ def __init__(
814825
**kwargs,
815826
)
816827
self.text = text
828+
self.protected_data = protected_data
817829
self.type: Literal["text_reasoning"] = "text_reasoning"
818830

819831
def __add__(self, other: "TextReasoningContent") -> "TextReasoningContent":
@@ -846,13 +858,18 @@ def __add__(self, other: "TextReasoningContent") -> "TextReasoningContent":
846858
else:
847859
annotations = self.annotations + other.annotations
848860

861+
# Replace protected data.
862+
# Discussion: https://github.com/microsoft/agent-framework/pull/2950#discussion_r2634345613
863+
protected_data = other.protected_data or self.protected_data
864+
849865
# Create new instance using from_dict for proper deserialization
850866
result_dict = {
851-
"text": self.text + other.text,
867+
"text": (self.text or "") + (other.text or "") if self.text is not None or other.text is not None else None,
852868
"type": "text_reasoning",
853869
"annotations": [ann.to_dict(exclude_none=False) for ann in annotations] if annotations else None,
854870
"additional_properties": {**(self.additional_properties or {}), **(other.additional_properties or {})},
855871
"raw_representation": raw_representation,
872+
"protected_data": protected_data,
856873
}
857874
return TextReasoningContent.from_dict(result_dict)
858875

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

871888
# Concatenate text
872-
self.text += other.text
889+
if self.text is not None or other.text is not None:
890+
self.text = (self.text or "") + (other.text or "")
891+
# if both are None, should keep as None
873892

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

910+
# Replace protected data.
911+
# Discussion: https://github.com/microsoft/agent-framework/pull/2950#discussion_r2634345613
912+
if other.protected_data is not None:
913+
self.protected_data = other.protected_data
914+
891915
# Merge annotations
892916
if other.annotations:
893917
if self.annotations is None:

python/packages/core/agent_framework/openai/_chat_client.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
FunctionResultContent,
3535
Role,
3636
TextContent,
37+
TextReasoningContent,
3738
UriContent,
3839
UsageContent,
3940
UsageDetails,
@@ -234,6 +235,8 @@ def _parse_response_from_openai(self, response: ChatCompletion, chat_options: Ch
234235
contents.append(text_content)
235236
if parsed_tool_calls := [tool for tool in self._parse_tool_calls_from_openai(choice)]:
236237
contents.extend(parsed_tool_calls)
238+
if reasoning_details := getattr(choice.message, "reasoning_details", None):
239+
contents.append(TextReasoningContent(None, protected_data=json.dumps(reasoning_details)))
237240
messages.append(ChatMessage(role="assistant", contents=contents))
238241
return ChatResponse(
239242
response_id=response.id,
@@ -271,6 +274,8 @@ def _parse_response_update_from_openai(
271274

272275
if text_content := self._parse_text_from_openai(choice):
273276
contents.append(text_content)
277+
if reasoning_details := getattr(choice.delta, "reasoning_details", None):
278+
contents.append(TextReasoningContent(None, protected_data=json.dumps(reasoning_details)))
274279
return ChatResponseUpdate(
275280
created_at=datetime.fromtimestamp(chunk.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
276281
contents=contents,
@@ -394,6 +399,10 @@ def _prepare_message_for_openai(self, message: ChatMessage) -> list[dict[str, An
394399
}
395400
if message.author_name and message.role != Role.TOOL:
396401
args["name"] = message.author_name
402+
if "reasoning_details" in message.additional_properties and (
403+
details := message.additional_properties["reasoning_details"]
404+
):
405+
args["reasoning_details"] = details
397406
match content:
398407
case FunctionCallContent():
399408
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
405414
args["tool_call_id"] = content.call_id
406415
if content.result is not None:
407416
args["content"] = prepare_function_call_results(content.result)
417+
case TextReasoningContent(protected_data=protected_data) if protected_data is not None:
418+
all_messages[-1]["reasoning_details"] = json.loads(protected_data)
408419
case _:
409420
if "content" not in args:
410421
args["content"] = []

python/packages/ollama/agent_framework_ollama/_chat_client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,8 @@ def _format_user_message(self, message: ChatMessage) -> list[OllamaMessage]:
239239

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

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

python/samples/getting_started/agents/ollama/ollama_agent_reasoning.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async def reasoning_example() -> None:
3030
print(f"User: {query}")
3131
# Enable Reasoning on per request level
3232
result = await agent.run(query)
33-
reasoning = "".join(c.text for c in result.messages[-1].contents if isinstance(c, TextReasoningContent))
33+
reasoning = "".join((c.text or "") for c in result.messages[-1].contents if isinstance(c, TextReasoningContent))
3434
print(f"Reasoning: {reasoning}")
3535
print(f"Answer: {result}\n")
3636

0 commit comments

Comments
 (0)