mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into feature/python-foundry-hosted-agent-vnext
This commit is contained in:
@@ -906,6 +906,9 @@ def _tools_to_dict( # pyright: ignore[reportUnusedFunction]
|
||||
if isinstance(tool_item, FunctionTool):
|
||||
results.append(tool_item.to_json_schema_spec())
|
||||
continue
|
||||
if isinstance(tool_item, BaseModel):
|
||||
results.append(tool_item.model_dump(exclude_none=True))
|
||||
continue
|
||||
if isinstance(tool_item, SerializationMixin):
|
||||
results.append(tool_item.to_dict())
|
||||
continue
|
||||
|
||||
@@ -1879,6 +1879,12 @@ def _process_update(response: ChatResponse | AgentResponse, update: ChatResponse
|
||||
response.finish_reason = update.finish_reason
|
||||
if update.model is not None:
|
||||
response.model = update.model
|
||||
if (
|
||||
isinstance(response, AgentResponse)
|
||||
and isinstance(update, AgentResponseUpdate)
|
||||
and update.finish_reason is not None
|
||||
):
|
||||
response.finish_reason = update.finish_reason
|
||||
response.continuation_token = update.continuation_token
|
||||
|
||||
|
||||
@@ -2435,6 +2441,7 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]):
|
||||
response_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
created_at: CreatedAtT | None = None,
|
||||
finish_reason: FinishReasonLiteral | FinishReason | None = None,
|
||||
usage_details: UsageDetails | None = None,
|
||||
value: ResponseModelT | None = None,
|
||||
response_format: StructuredResponseFormat = None,
|
||||
@@ -2450,6 +2457,9 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]):
|
||||
agent_id: The identifier of the agent that produced this response. Useful in multi-agent
|
||||
scenarios to track which agent generated the response.
|
||||
created_at: A timestamp for the chat response.
|
||||
finish_reason: The reason the model stopped generating. Common values include
|
||||
``"stop"`` (natural completion), ``"length"`` (token limit), and
|
||||
``"tool_calls"`` (the model invoked a tool).
|
||||
usage_details: The usage details for the chat response.
|
||||
value: The structured output of the agent run response, if applicable.
|
||||
response_format: Optional response format for the agent response.
|
||||
@@ -2476,6 +2486,7 @@ class AgentResponse(SerializationMixin, Generic[ResponseModelT]):
|
||||
self.response_id = response_id
|
||||
self.agent_id = agent_id
|
||||
self.created_at = created_at
|
||||
self.finish_reason = finish_reason
|
||||
self.usage_details = usage_details
|
||||
self._value: ResponseModelT | None = value
|
||||
self._response_format: type[BaseModel] | Mapping[str, Any] | None = response_format
|
||||
@@ -2688,6 +2699,7 @@ class AgentResponseUpdate(SerializationMixin):
|
||||
response_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
created_at: CreatedAtT | None = None,
|
||||
finish_reason: FinishReasonLiteral | FinishReason | None = None,
|
||||
continuation_token: ContinuationToken | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
raw_representation: Any | None = None,
|
||||
@@ -2703,6 +2715,9 @@ class AgentResponseUpdate(SerializationMixin):
|
||||
response_id: Optional ID of the response of which this update is a part.
|
||||
message_id: Optional ID of the message of which this update is a part.
|
||||
created_at: Optional timestamp for the chat response update.
|
||||
finish_reason: The reason the model stopped generating. Common values include
|
||||
``"stop"`` (natural completion), ``"length"`` (token limit), and
|
||||
``"tool_calls"`` (the model invoked a tool).
|
||||
continuation_token: Optional token for resuming a long-running background operation.
|
||||
When present, indicates the operation is still in progress.
|
||||
additional_properties: Optional additional properties associated with the chat response update.
|
||||
@@ -2729,6 +2744,7 @@ class AgentResponseUpdate(SerializationMixin):
|
||||
self.response_id = response_id
|
||||
self.message_id = message_id
|
||||
self.created_at = created_at
|
||||
self.finish_reason = finish_reason
|
||||
self.continuation_token = continuation_token
|
||||
self.additional_properties = _restore_compaction_annotation_in_additional_properties(
|
||||
additional_properties,
|
||||
@@ -2761,6 +2777,7 @@ def map_chat_to_agent_update(update: ChatResponseUpdate, agent_name: str | None)
|
||||
response_id=update.response_id,
|
||||
message_id=update.message_id,
|
||||
created_at=update.created_at,
|
||||
finish_reason=update.finish_reason, # type: ignore[arg-type]
|
||||
continuation_token=update.continuation_token,
|
||||
additional_properties=update.additional_properties,
|
||||
raw_representation=update,
|
||||
|
||||
@@ -59,6 +59,62 @@ class AgentExecutorResponse:
|
||||
agent_response: AgentResponse
|
||||
full_conversation: list[Message]
|
||||
|
||||
def with_text(self, text: str) -> "AgentExecutorResponse":
|
||||
"""Create a new AgentExecutorResponse with replaced text, preserving the conversation history.
|
||||
|
||||
Use this in custom executors that transform agent output text (e.g. upper-casing, summarising)
|
||||
when you need downstream AgentExecutors to still have access to the full prior conversation.
|
||||
|
||||
Without this helper, sending a plain ``str`` from a custom executor breaks the context chain:
|
||||
the downstream ``AgentExecutor.from_str`` handler only adds that one string to its cache and
|
||||
loses all prior messages. By using ``with_text`` the response type stays
|
||||
``AgentExecutorResponse``, so ``AgentExecutor.from_response`` is invoked instead and the full
|
||||
conversation is preserved.
|
||||
|
||||
Args:
|
||||
text: The replacement assistant message text.
|
||||
|
||||
Returns:
|
||||
A new ``AgentExecutorResponse`` whose ``agent_response`` contains a single assistant
|
||||
message with ``text``, and whose ``full_conversation`` is the prior conversation
|
||||
(everything before the original agent turn) followed by the new assistant message.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework import AgentExecutorResponse, WorkflowContext, executor
|
||||
|
||||
|
||||
@executor(
|
||||
id="upper_case_executor",
|
||||
input=AgentExecutorResponse,
|
||||
output=AgentExecutorResponse,
|
||||
workflow_output=str,
|
||||
)
|
||||
async def upper_case(
|
||||
response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[AgentExecutorResponse, str],
|
||||
) -> None:
|
||||
upper_text = response.agent_response.text.upper()
|
||||
await ctx.send_message(response.with_text(upper_text))
|
||||
await ctx.yield_output(upper_text)
|
||||
"""
|
||||
new_message = Message("assistant", [text])
|
||||
new_agent_response = AgentResponse(messages=[new_message])
|
||||
|
||||
# Strip off the original agent turn and replace with the new text.
|
||||
n_agent_messages = len(self.agent_response.messages)
|
||||
prior_messages = (
|
||||
self.full_conversation[:-n_agent_messages] if n_agent_messages else list(self.full_conversation)
|
||||
)
|
||||
new_full_conversation = [*prior_messages, new_message]
|
||||
|
||||
return AgentExecutorResponse(
|
||||
executor_id=self.executor_id,
|
||||
agent_response=new_agent_response,
|
||||
full_conversation=new_full_conversation,
|
||||
)
|
||||
|
||||
|
||||
class AgentExecutor(Executor):
|
||||
"""built-in executor that wraps an agent for handling messages.
|
||||
@@ -183,7 +239,25 @@ class AgentExecutor(Executor):
|
||||
"""Accept a raw user prompt string and run the agent.
|
||||
|
||||
The new string input will be added to the cache which is used as the conversation context for the agent run.
|
||||
|
||||
Warning:
|
||||
If the upstream executor received an ``AgentExecutorResponse`` but emits a plain
|
||||
``str``, this handler will be invoked instead of ``from_response``. This resets
|
||||
the conversation context because only the new string is added to the cache and
|
||||
all prior messages from the upstream agent are lost.
|
||||
|
||||
To preserve the full conversation when transforming agent output in a custom
|
||||
executor, use ``AgentExecutorResponse.with_text(...)`` so that the message type
|
||||
stays ``AgentExecutorResponse`` and ``from_response`` is called instead.
|
||||
"""
|
||||
if not self._cache and ctx.source_executor_ids != ["Workflow"]:
|
||||
logger.warning(
|
||||
"AgentExecutor '%s': from_str handler invoked with an empty cache. "
|
||||
"If you are chaining from an AgentExecutor, the upstream custom executor may be "
|
||||
"emitting a plain str instead of using AgentExecutorResponse.with_text(...), "
|
||||
"which causes the full conversation context to be lost.",
|
||||
self.id,
|
||||
)
|
||||
self._cache.extend(normalize_messages_input(text))
|
||||
await self._run_agent_and_emit(ctx)
|
||||
|
||||
|
||||
@@ -268,6 +268,19 @@ def executor(
|
||||
forward references. When provided, takes precedence over introspection from the
|
||||
``WorkflowContext`` second generic parameter (W_OutT).
|
||||
|
||||
Warning:
|
||||
When placing a custom ``@executor`` **between** two ``AgentExecutor`` nodes, be
|
||||
careful about the output type. If the custom executor receives an
|
||||
``AgentExecutorResponse`` but emits a plain ``str``, the downstream
|
||||
``AgentExecutor.from_str`` handler is invoked instead of ``from_response``.
|
||||
This resets the conversation context because only the new string is added to
|
||||
the cache and all prior messages from the upstream agent are lost.
|
||||
|
||||
To preserve the full conversation, use
|
||||
``AgentExecutorResponse.with_text(new_text)`` to create a new response that
|
||||
keeps the prior history, and set ``output=AgentExecutorResponse`` on the
|
||||
decorator.
|
||||
|
||||
Returns:
|
||||
A FunctionExecutor instance that can be wired into a Workflow.
|
||||
|
||||
|
||||
@@ -16,12 +16,26 @@ from agent_framework._middleware import FunctionInvocationContext
|
||||
from agent_framework._tools import (
|
||||
_parse_annotation,
|
||||
_parse_inputs,
|
||||
_tools_to_dict,
|
||||
)
|
||||
from agent_framework.observability import OtelAttr
|
||||
|
||||
# region FunctionTool and tool decorator tests
|
||||
|
||||
|
||||
def test_tools_to_dict_supports_pydantic_tool_models() -> None:
|
||||
"""Pydantic-based tool specs are serialized without logging parse warnings."""
|
||||
|
||||
class ProviderTool(BaseModel):
|
||||
kind: str
|
||||
enabled: bool = True
|
||||
note: str | None = None
|
||||
|
||||
result = _tools_to_dict([ProviderTool(kind="google_search")])
|
||||
|
||||
assert result == [{"kind": "google_search", "enabled": True}]
|
||||
|
||||
|
||||
def test_tool_decorator():
|
||||
"""Test the tool decorator."""
|
||||
|
||||
|
||||
@@ -40,8 +40,10 @@ from agent_framework._types import (
|
||||
_get_data_bytes_as_str,
|
||||
_parse_content_list,
|
||||
_parse_structured_response_value,
|
||||
_process_update,
|
||||
_validate_uri,
|
||||
add_usage_details,
|
||||
map_chat_to_agent_update,
|
||||
validate_tool_mode,
|
||||
)
|
||||
from agent_framework.exceptions import AdditionItemMismatch, ContentError
|
||||
@@ -4179,3 +4181,101 @@ def test_prepend_instructions_custom_role():
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region finish_reason
|
||||
|
||||
|
||||
def test_agent_response_init_with_finish_reason() -> None:
|
||||
"""Test that AgentResponse correctly initializes and stores finish_reason."""
|
||||
response = AgentResponse(
|
||||
messages=[Message("assistant", [Content.from_text("test")])],
|
||||
finish_reason="stop",
|
||||
)
|
||||
assert response.finish_reason == "stop"
|
||||
|
||||
|
||||
def test_agent_response_update_init_with_finish_reason() -> None:
|
||||
"""Test that AgentResponseUpdate correctly initializes and stores finish_reason."""
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_text("test")],
|
||||
role="assistant",
|
||||
finish_reason="stop",
|
||||
)
|
||||
assert update.finish_reason == "stop"
|
||||
|
||||
|
||||
def test_map_chat_to_agent_update_forwards_finish_reason() -> None:
|
||||
"""Test that mapping a ChatResponseUpdate with finish_reason forwards it."""
|
||||
chat_update = ChatResponseUpdate(
|
||||
contents=[Content.from_text("test")],
|
||||
finish_reason="length",
|
||||
)
|
||||
agent_update = map_chat_to_agent_update(chat_update, agent_name="test_agent")
|
||||
|
||||
assert agent_update.finish_reason == "length"
|
||||
assert agent_update.author_name == "test_agent"
|
||||
|
||||
|
||||
def test_process_update_propagates_finish_reason_to_agent_response() -> None:
|
||||
"""Test that _process_update correctly updates an AgentResponse from an AgentResponseUpdate."""
|
||||
response = AgentResponse(messages=[Message("assistant", [Content.from_text("test")])])
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_text("more text")],
|
||||
role="assistant",
|
||||
finish_reason="stop",
|
||||
)
|
||||
|
||||
# Process the update
|
||||
_process_update(response, update)
|
||||
|
||||
assert response.finish_reason == "stop"
|
||||
|
||||
|
||||
def test_process_update_does_not_overwrite_with_none() -> None:
|
||||
"""Test that _process_update does not overwrite an existing finish_reason with None."""
|
||||
response = AgentResponse(
|
||||
messages=[Message("assistant", [Content.from_text("test")])],
|
||||
finish_reason="length",
|
||||
)
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_text("more text")],
|
||||
role="assistant",
|
||||
finish_reason=None,
|
||||
)
|
||||
|
||||
# Process the update
|
||||
_process_update(response, update)
|
||||
|
||||
assert response.finish_reason == "length"
|
||||
|
||||
|
||||
def test_agent_response_serialization_includes_finish_reason() -> None:
|
||||
"""Test that AgentResponse serializes correctly, including finish_reason."""
|
||||
response = AgentResponse(
|
||||
messages=[Message("assistant", [Content.from_text("test")])],
|
||||
response_id="test_123",
|
||||
finish_reason="stop",
|
||||
)
|
||||
|
||||
# Serialize using the framework's API and verify finish_reason is included.
|
||||
data = response.to_dict()
|
||||
assert "finish_reason" in data
|
||||
assert data["finish_reason"] == "stop"
|
||||
|
||||
|
||||
def test_agent_response_update_serialization_includes_finish_reason() -> None:
|
||||
"""Test that AgentResponseUpdate serializes correctly, including finish_reason."""
|
||||
update = AgentResponseUpdate(
|
||||
contents=[Content.from_text("test")],
|
||||
role="assistant",
|
||||
response_id="test_456",
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
|
||||
data = update.to_dict()
|
||||
assert "finish_reason" in data
|
||||
assert data["finish_reason"] == "tool_calls"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -23,6 +23,7 @@ from agent_framework import (
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowRunState,
|
||||
executor,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.orchestrations import SequentialBuilder
|
||||
@@ -478,3 +479,90 @@ async def test_from_response_preserves_service_session_id() -> None:
|
||||
assert result.get_outputs() is not None
|
||||
|
||||
assert spy_agent._captured_service_session_id == "resp_PREVIOUS_RUN" # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
@executor(
|
||||
id="upper_case_executor",
|
||||
input=AgentExecutorResponse,
|
||||
output=AgentExecutorResponse,
|
||||
workflow_output=str,
|
||||
)
|
||||
async def _upper_case_executor(
|
||||
response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[AgentExecutorResponse, str],
|
||||
) -> None:
|
||||
upper_text = response.agent_response.text.upper()
|
||||
await ctx.send_message(response.with_text(upper_text))
|
||||
await ctx.yield_output(upper_text)
|
||||
|
||||
|
||||
async def test_with_text_preserves_full_conversation_through_custom_executor() -> None:
|
||||
"""Custom executor using with_text must preserve the full conversation chain."""
|
||||
# Mirrors the reproduction from issue #5246:
|
||||
# agent1 ("User likes sky red") -> agent2 ("User likes sky blue") -> upper_case -> agent3 ("User likes sky green")
|
||||
agent1 = AgentExecutor(
|
||||
_SimpleAgent(id="agent1", name="ContextAgent1", reply_text="User likes sky red"), id="agent1"
|
||||
)
|
||||
agent2 = AgentExecutor(
|
||||
_SimpleAgent(id="agent2", name="ContextAgent2", reply_text="User likes sky blue"), id="agent2"
|
||||
)
|
||||
agent3 = AgentExecutor(
|
||||
_SimpleAgent(id="agent3", name="ContextAgent3", reply_text="User likes sky green"), id="agent3"
|
||||
)
|
||||
capturer = _CaptureFullConversation(id="capture")
|
||||
|
||||
wf = (
|
||||
WorkflowBuilder(start_executor=agent1, output_executors=[capturer])
|
||||
.add_chain([agent1, agent2, _upper_case_executor, agent3, capturer])
|
||||
.build()
|
||||
)
|
||||
|
||||
result = await wf.run("")
|
||||
payload = next(o for o in result.get_outputs() if isinstance(o, dict))
|
||||
|
||||
# The final agent must see the full conversation: user, agent1, UPPER(agent2), agent3
|
||||
assert payload["roles"] == ["user", "assistant", "assistant", "assistant"]
|
||||
assert payload["texts"][1] == "User likes sky red"
|
||||
assert payload["texts"][2] == "USER LIKES SKY BLUE"
|
||||
assert payload["texts"][3] == "User likes sky green"
|
||||
|
||||
|
||||
async def test_with_text_does_not_mutate_original() -> None:
|
||||
"""with_text returns a new instance; the original must be unmodified."""
|
||||
original = AgentExecutorResponse(
|
||||
executor_id="test_exec",
|
||||
agent_response=AgentResponse(messages=[Message("assistant", ["original reply"])]),
|
||||
full_conversation=[Message("user", ["prompt"]), Message("assistant", ["original reply"])],
|
||||
)
|
||||
|
||||
new = original.with_text("transformed reply")
|
||||
|
||||
assert new is not original
|
||||
assert new.agent_response.text == "transformed reply"
|
||||
assert new.full_conversation[-1].text == "transformed reply"
|
||||
assert new.full_conversation[-1].role == "assistant"
|
||||
# Original unchanged
|
||||
assert original.agent_response.text == "original reply"
|
||||
assert original.full_conversation[-1].text == "original reply"
|
||||
|
||||
|
||||
async def test_with_text_strips_multi_message_agent_turn() -> None:
|
||||
"""When the agent turn has multiple messages (tool calls), with_text strips all of them."""
|
||||
tool_call = Message("assistant", ["<tool_call>"])
|
||||
tool_result = Message("tool", ["<result>"])
|
||||
final_reply = Message("assistant", ["actual answer"])
|
||||
user_msg = Message("user", ["question"])
|
||||
|
||||
original = AgentExecutorResponse(
|
||||
executor_id="exec",
|
||||
agent_response=AgentResponse(messages=[tool_call, tool_result, final_reply]),
|
||||
full_conversation=[user_msg, tool_call, tool_result, final_reply],
|
||||
)
|
||||
|
||||
new = original.with_text("summarised answer")
|
||||
|
||||
# Only the pre-agent-turn messages should remain, plus the replacement
|
||||
assert len(new.full_conversation) == 2
|
||||
assert new.full_conversation[0].text == "question"
|
||||
assert new.full_conversation[1].text == "summarised answer"
|
||||
assert new.agent_response.text == "summarised answer"
|
||||
|
||||
Reference in New Issue
Block a user