mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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
This commit is contained in:
committed by
GitHub
Unverified
parent
6930c0f0b6
commit
85d70f01f6
@@ -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 @@ class TextReasoningContent(BaseContent):
|
||||
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 @@ class TextReasoningContent(BaseContent):
|
||||
**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 @@ class TextReasoningContent(BaseContent):
|
||||
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 @@ class TextReasoningContent(BaseContent):
|
||||
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 @@ class TextReasoningContent(BaseContent):
|
||||
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:
|
||||
|
||||
@@ -34,6 +34,7 @@ from .._types import (
|
||||
FunctionResultContent,
|
||||
Role,
|
||||
TextContent,
|
||||
TextReasoningContent,
|
||||
UriContent,
|
||||
UsageContent,
|
||||
UsageDetails,
|
||||
@@ -234,6 +235,8 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
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 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
|
||||
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 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
}
|
||||
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 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
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"] = []
|
||||
|
||||
@@ -239,7 +239,8 @@ class OllamaChatClient(BaseChatClient):
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user