Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
6 changes: 6 additions & 0 deletions python/packages/core/agent_framework/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,12 @@ def _add_text_reasoning_content(self, other: Content) -> Content:
# Concatenate text, handling None values
self_text = self.text or ""
other_text = other.text or ""
if (
self_text
and other_text
and ("reasoning_text" in self.additional_properties) != ("reasoning_text" in other.additional_properties)
):
raise AdditionItemMismatch("Cannot merge reasoning text with a reasoning summary")
Comment thread
moonbox3 marked this conversation as resolved.
combined_text = self_text + other_text if (self_text or other_text) else None

# Handle protected_data replacement
Expand Down
12 changes: 12 additions & 0 deletions python/packages/core/tests/core/test_function_invocation_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -3823,6 +3823,7 @@ def ai_func(arg1: str) -> str:
assert response.messages[1].role == "tool"
assert response.messages[1].contents[0].type == "function_result"
assert response.messages[1].contents[0].result == "terminated by middleware"
assert response.messages[1].contents[0].additional_properties == {}

# Verify the second response is still in the queue (wasn't consumed)
assert len(chat_client_base.run_responses) == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
Expand Down Expand Up @@ -3891,6 +3892,11 @@ def terminating_func(arg1: str) -> str:
assert response.messages[1].role == "tool"
# Both function results should be present
assert len(response.messages[1].contents) == 2
assert [result.result for result in response.messages[1].contents] == [
"Normal value1",
"terminated by middleware",
]
assert all(result.additional_properties == {} for result in response.messages[1].contents)

# Verify the second response is still in the queue (wasn't consumed)
assert len(chat_client_base.run_responses) == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
Expand Down Expand Up @@ -3939,6 +3945,12 @@ def ai_func(arg1: str) -> str:
# Should have function call update and function result update
# The loop should NOT have continued to call the LLM again
assert len(updates) == 2
function_results = [
content for update in updates for content in update.contents if content.type == "function_result"
]
assert len(function_results) == 1
assert function_results[0].result == "terminated by middleware"
assert function_results[0].additional_properties == {}

# Verify the second streaming response is still in the queue (wasn't consumed)
assert len(chat_client_base.streaming_responses) == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
Expand Down
179 changes: 179 additions & 0 deletions python/packages/foundry/tests/foundry/test_foundry_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,39 @@
from __future__ import annotations

import inspect
import json
import os
import sys
from collections.abc import Awaitable, Callable
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4

import httpx
import pytest
from agent_framework import (
Agent,
AgentExecutor,
AgentResponse,
AgentSession,
ChatContext,
ChatMiddleware,
ChatResponse,
ChatResponseUpdate,
FunctionInvocationContext,
FunctionMiddleware,
Message,
MiddlewareTermination,
WorkflowBuilder,
tool,
)
from agent_framework_openai._chat_client import RawOpenAIChatClient
from azure.ai.projects import models as projects_models
from azure.core.exceptions import ResourceNotFoundError
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from openai import AsyncOpenAI

from agent_framework_foundry._agent import (
FoundryAgent,
Expand Down Expand Up @@ -1194,6 +1203,176 @@ def _import_with_missing_azure_monitor(
await agent.configure_azure_monitor()


@pytest.mark.parametrize("terminate_tool_loop", [False, True])
async def test_foundry_agent_workflow_replays_parallel_reasoning_function_group(
terminate_tool_loop: bool,
) -> None:
"""Stateless cross-agent replay keeps a parallel reasoning/function batch atomic."""
summary_inputs: list[list[dict[str, Any]]] = []

def _message(message_id: str, text: str) -> dict[str, Any]:
return {
"id": message_id,
"content": [{"annotations": [], "text": text, "type": "output_text"}],
"role": "assistant",
"status": "completed",
"type": "message",
}

def _response(response_id: str, output: list[dict[str, Any]]) -> dict[str, Any]:
return {
"id": response_id,
"created_at": 0,
"model": "gpt-5.4",
"object": "response",
"output": output,
"parallel_tool_calls": True,
"status": "completed",
"tool_choice": "auto",
"tools": [],
}

async def foundry_responses_boundary(request: httpx.Request) -> httpx.Response:
payload = json.loads(request.content)
agent_name = payload["agent_reference"]["name"]

if agent_name == "research-agent" and "previous_response_id" not in payload:
return httpx.Response(
200,
json=_response(
"resp_research_tool",
[
{
"encrypted_content": "encrypted-reasoning",
"id": "rs_required",
"summary": [{"text": "I need both local tools.", "type": "summary_text"}],
"type": "reasoning",
},
{
"arguments": '{"query":"Agent Framework"}',
"call_id": "call_paired",
"id": "fc_paired",
"name": "lookup_docs",
"status": "completed",
"type": "function_call",
},
{
"arguments": '{"query":"stateless replay"}',
"call_id": "call_guarded",
"id": "fc_guarded",
"name": "guarded_lookup",
"status": "completed",
"type": "function_call",
},
],
),
)

if agent_name == "research-agent":
return httpx.Response(
200,
json=_response(
"resp_research_final",
[_message("msg_research", "Microsoft Agent Framework")],
),
)

input_items = payload["input"]
summary_inputs.append(input_items)
input_types = {item.get("type") for item in input_items if isinstance(item, dict)}
if "function_call" in input_types and "reasoning" not in input_types:
return httpx.Response(
400,
json={
"error": {
"message": (
"Item 'fc_paired' of type 'function_call' was provided without its "
"required 'reasoning' item: 'rs_required'."
),
"type": "invalid_request_error",
"code": "invalid_request_error",
}
},
)

return httpx.Response(
200,
json=_response(
"resp_summary",
[_message("msg_summary", "The research result was Microsoft Agent Framework.")],
),
)

@tool(name="lookup_docs", approval_mode="never_require")
def lookup_docs(query: str) -> str:
return f"Found documentation for {query}"

@tool(name="guarded_lookup", approval_mode="never_require")
def guarded_lookup(query: str) -> str:
return f"Found guarded documentation for {query}"

class TerminateToolLoopMiddleware(FunctionMiddleware):
async def process(
self,
context: FunctionInvocationContext,
call_next: Callable[[], Awaitable[None]],
) -> None:
if context.function.name == "guarded_lookup":
context.result = "Blocked by policy"
raise MiddlewareTermination("Policy blocked tool execution")
await call_next()

transport = httpx.MockTransport(foundry_responses_boundary)
responses_client = AsyncOpenAI(
api_key="test-key",
http_client=httpx.AsyncClient(transport=transport),
max_retries=0,
)
project_client = MagicMock()
project_client.get_openai_client.return_value = responses_client

research_agent = FoundryAgent(
project_client=project_client,
agent_name="research-agent",
tools=[lookup_docs, guarded_lookup],
middleware=[TerminateToolLoopMiddleware()] if terminate_tool_loop else None,
)
summary_agent = FoundryAgent(
project_client=project_client,
agent_name="summary-agent",
)
research_executor = AgentExecutor(research_agent, id="research")
summary_executor = AgentExecutor(summary_agent, id="summary")
workflow = (
WorkflowBuilder(start_executor=research_executor, output_from=[summary_executor])
.add_edge(research_executor, summary_executor)
.build()
)

result = await workflow.run("Research Agent Framework and summarize the result")

outputs = result.get_outputs()
assert outputs
assert outputs[-1].text == "The research result was Microsoft Agent Framework."
assert len(summary_inputs) == 1
assert [item["type"] for item in summary_inputs[0]] == [
"message",
"reasoning",
"function_call",
"function_call",
"function_call_output",
"function_call_output",
*(["message"] if not terminate_tool_loop else []),
]
assert summary_inputs[0][1]["encrypted_content"] == "encrypted-reasoning"
assert [item["call_id"] for item in summary_inputs[0][2:4]] == ["call_paired", "call_guarded"]
assert [item["call_id"] for item in summary_inputs[0][4:6]] == ["call_paired", "call_guarded"]
assert summary_inputs[0][4]["output"] == "Found documentation for Agent Framework"
assert summary_inputs[0][5]["output"] == (
"Blocked by policy" if terminate_tool_loop else "Found guarded documentation for stateless replay"
)


@pytest.mark.flaky
@pytest.mark.integration
@skip_if_foundry_agent_integration_tests_disabled
Expand Down
Loading
Loading