Files
agent-framework/python/packages/azure-ai/tests/test_foundry_evals.py
alliscode 45527eed29 Foundry Evals integration for Python
Merged and refactored eval module per Eduard's PR review:

- Merge _eval.py + _local_eval.py into single _evaluation.py
- Convert EvalItem from dataclass to regular class
- Rename to_dict() to to_eval_data()
- Convert _AgentEvalData to TypedDict
- Simplify check system: unified async pattern with isawaitable
- Parallelize checks and evaluators with asyncio.gather
- Add all/any mode to tool_called_check
- Fix bool(passed) truthy bug in _coerce_result
- Remove deprecated function_evaluator/async_function_evaluator aliases
- Remove _MinimalAgent, tighten evaluate_agent signature
- Set self.name in __init__ (LocalEvaluator, FoundryEvals)
- Limit FoundryEvals to AsyncOpenAI only
- Type project_client as AIProjectClient
- Remove NotImplementedError continuous eval code
- Add evaluation samples in 02-agents/ and 03-workflows/
- Update all imports and tests (167 passing)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 14:24:21 -07:00

2046 lines
79 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
"""Tests for the AgentEvalConverter, FoundryEvals, and eval helper functions."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from agent_framework import AgentExecutorResponse, AgentResponse, Content, FunctionTool, Message, WorkflowEvent
from agent_framework._evaluation import (
AgentEvalConverter,
ConversationSplit,
EvalItem,
EvalResults,
_extract_agent_eval_data,
_extract_overall_query,
evaluate_agent,
evaluate_workflow,
)
from agent_framework._workflows._workflow import WorkflowRunResult
from agent_framework_azure_ai._foundry_evals import (
FoundryEvals,
_build_item_schema,
_build_testing_criteria,
_filter_tool_evaluators,
_resolve_default_evaluators,
_resolve_evaluator,
_resolve_openai_client,
)
def _make_tool(name: str) -> MagicMock:
"""Create a mock FunctionTool for use in tests."""
t = MagicMock()
t.name = name
t.description = f"{name} tool"
t.parameters = MagicMock(return_value={"type": "object"})
return t
# ---------------------------------------------------------------------------
# _resolve_evaluator
# ---------------------------------------------------------------------------
class TestResolveEvaluator:
def test_short_name(self) -> None:
assert _resolve_evaluator("relevance") == "builtin.relevance"
assert _resolve_evaluator("tool_call_accuracy") == "builtin.tool_call_accuracy"
assert _resolve_evaluator("violence") == "builtin.violence"
def test_already_qualified(self) -> None:
assert _resolve_evaluator("builtin.relevance") == "builtin.relevance"
assert _resolve_evaluator("builtin.custom") == "builtin.custom"
def test_unknown_raises(self) -> None:
with pytest.raises(ValueError, match="Unknown evaluator 'bogus'"):
_resolve_evaluator("bogus")
# ---------------------------------------------------------------------------
# AgentEvalConverter.convert_message
# ---------------------------------------------------------------------------
class TestConvertMessage:
def test_user_text_message(self) -> None:
msg = Message("user", ["Hello, world!"])
result = AgentEvalConverter.convert_message(msg)
assert len(result) == 1
assert result[0] == {"role": "user", "content": [{"type": "text", "text": "Hello, world!"}]}
def test_system_message(self) -> None:
msg = Message("system", ["You are helpful."])
result = AgentEvalConverter.convert_message(msg)
assert result[0] == {"role": "system", "content": [{"type": "text", "text": "You are helpful."}]}
def test_assistant_text_message(self) -> None:
msg = Message("assistant", ["Here is the answer."])
result = AgentEvalConverter.convert_message(msg)
assert len(result) == 1
assert result[0]["role"] == "assistant"
assert result[0]["content"] == [{"type": "text", "text": "Here is the answer."}]
assert len(result[0]["content"]) == 1
def test_assistant_with_tool_call(self) -> None:
msg = Message(
"assistant",
[
Content.from_function_call(
call_id="call_1",
name="get_weather",
arguments=json.dumps({"location": "Seattle"}),
),
],
)
result = AgentEvalConverter.convert_message(msg)
assert len(result) == 1
assert result[0]["role"] == "assistant"
tc = result[0]["content"][0]
assert tc["type"] == "tool_call"
assert tc["tool_call_id"] == "call_1"
assert tc["name"] == "get_weather"
assert tc["arguments"] == {"location": "Seattle"}
def test_assistant_text_and_tool_call(self) -> None:
msg = Message(
"assistant",
[
Content.from_text("Let me check that."),
Content.from_function_call(
call_id="call_2",
name="search",
arguments={"query": "flights"},
),
],
)
result = AgentEvalConverter.convert_message(msg)
assert len(result) == 1
assert result[0]["content"][0] == {"type": "text", "text": "Let me check that."}
tc = result[0]["content"][1]
assert tc["type"] == "tool_call"
assert tc["arguments"] == {"query": "flights"}
def test_tool_result_message(self) -> None:
msg = Message(
"tool",
[
Content.from_function_result(
call_id="call_1",
result="72°F, sunny",
),
],
)
result = AgentEvalConverter.convert_message(msg)
assert len(result) == 1
assert result[0]["role"] == "tool"
assert result[0]["tool_call_id"] == "call_1"
assert result[0]["content"] == [{"type": "tool_result", "tool_result": "72°F, sunny"}]
def test_multiple_tool_results(self) -> None:
msg = Message(
"tool",
[
Content.from_function_result(call_id="call_1", result="r1"),
Content.from_function_result(call_id="call_2", result="r2"),
],
)
result = AgentEvalConverter.convert_message(msg)
assert len(result) == 2
assert result[0]["tool_call_id"] == "call_1"
assert result[1]["tool_call_id"] == "call_2"
def test_non_string_result_kept_as_object(self) -> None:
msg = Message(
"tool",
[
Content.from_function_result(
call_id="call_1",
result={"temp": 72, "unit": "F"},
),
],
)
result = AgentEvalConverter.convert_message(msg)
tr = result[0]["content"][0]
assert tr["type"] == "tool_result"
assert tr["tool_result"] == {"temp": 72, "unit": "F"}
def test_empty_message(self) -> None:
msg = Message("user", [])
result = AgentEvalConverter.convert_message(msg)
assert result[0] == {"role": "user", "content": [{"type": "text", "text": ""}]}
# ---------------------------------------------------------------------------
# AgentEvalConverter.convert_messages
# ---------------------------------------------------------------------------
class TestConvertMessages:
def test_full_conversation(self) -> None:
messages = [
Message("user", ["What's the weather?"]),
Message(
"assistant",
[Content.from_function_call(call_id="c1", name="get_weather", arguments='{"loc": "SEA"}')],
),
Message("tool", [Content.from_function_result(call_id="c1", result="Sunny")]),
Message("assistant", ["It's sunny in Seattle!"]),
]
result = AgentEvalConverter.convert_messages(messages)
assert len(result) == 4
assert result[0]["role"] == "user"
assert result[1]["role"] == "assistant"
assert result[1]["content"][0]["type"] == "tool_call"
assert result[1]["content"][0]["name"] == "get_weather"
assert result[2]["role"] == "tool"
assert result[2]["content"][0]["type"] == "tool_result"
assert result[3]["role"] == "assistant"
assert result[3]["content"] == [{"type": "text", "text": "It's sunny in Seattle!"}]
# ---------------------------------------------------------------------------
# AgentEvalConverter.extract_tools
# ---------------------------------------------------------------------------
class TestExtractTools:
def test_extracts_function_tools(self) -> None:
tool = FunctionTool(
name="get_weather",
description="Get weather for a location",
func=lambda location: f"Sunny in {location}",
)
agent = MagicMock()
agent.default_options = {"tools": [tool]}
result = AgentEvalConverter.extract_tools(agent)
assert len(result) == 1
assert result[0]["name"] == "get_weather"
assert result[0]["description"] == "Get weather for a location"
assert "parameters" in result[0]
def test_skips_non_function_tools(self) -> None:
agent = MagicMock()
agent.default_options = {"tools": [{"type": "web_search"}, "some_string"]}
result = AgentEvalConverter.extract_tools(agent)
assert len(result) == 0
def test_no_tools(self) -> None:
agent = MagicMock()
agent.default_options = {}
assert AgentEvalConverter.extract_tools(agent) == []
def test_no_default_options(self) -> None:
agent = MagicMock(spec=[]) # No attributes
assert AgentEvalConverter.extract_tools(agent) == []
# ---------------------------------------------------------------------------
# AgentEvalConverter.to_eval_item (now returns EvalItem)
# ---------------------------------------------------------------------------
class TestToEvalItem:
def test_string_query(self) -> None:
response = AgentResponse(messages=[Message("assistant", ["The weather is sunny."])])
item = AgentEvalConverter.to_eval_item(query="What's the weather?", response=response)
assert isinstance(item, EvalItem)
assert item.query == "What's the weather?"
assert item.response == "The weather is sunny."
assert len(item.conversation) == 2
assert item.conversation[0].role == "user"
assert item.conversation[1].role == "assistant"
def test_message_query(self) -> None:
input_msgs = [
Message("system", ["Be helpful."]),
Message("user", ["Hello"]),
]
response = AgentResponse(messages=[Message("assistant", ["Hi there!"])])
item = AgentEvalConverter.to_eval_item(query=input_msgs, response=response)
assert item.query == "Hello" # Only user messages
assert len(item.conversation) == 3 # system + user + assistant
def test_with_context(self) -> None:
response = AgentResponse(messages=[Message("assistant", ["Answer."])])
item = AgentEvalConverter.to_eval_item(
query="Question?",
response=response,
context="Some reference document.",
)
assert item.context == "Some reference document."
def test_with_explicit_tools(self) -> None:
tool = FunctionTool(
name="search",
description="Search the web",
func=lambda q: f"Results for {q}",
)
response = AgentResponse(messages=[Message("assistant", ["Found it."])])
item = AgentEvalConverter.to_eval_item(
query="Find info",
response=response,
tools=[tool],
)
assert item.tools is not None
assert len(item.tools) == 1
assert item.tools[0].name == "search"
def test_with_agent_tools(self) -> None:
tool = FunctionTool(name="calc", description="Calculate", func=lambda x: str(x))
agent = MagicMock()
agent.default_options = {"tools": [tool]}
response = AgentResponse(messages=[Message("assistant", ["42"])])
item = AgentEvalConverter.to_eval_item(
query="What is 6*7?",
response=response,
agent=agent,
)
assert item.tools is not None
assert item.tools[0].name == "calc"
def test_explicit_tools_override_agent(self) -> None:
agent_tool = FunctionTool(name="agent_tool", description="from agent", func=lambda: "")
explicit_tool = FunctionTool(name="explicit_tool", description="explicit", func=lambda: "")
agent = MagicMock()
agent.default_options = {"tools": [agent_tool]}
response = AgentResponse(messages=[Message("assistant", ["Done"])])
item = AgentEvalConverter.to_eval_item(
query="Test",
response=response,
agent=agent,
tools=[explicit_tool],
)
assert item.tools is not None
assert len(item.tools) == 1
assert item.tools[0].name == "explicit_tool"
def test_to_dict_format(self) -> None:
"""EvalItem.to_eval_data() should split conversation at last user message."""
response = AgentResponse(messages=[Message("assistant", ["Answer"])])
item = AgentEvalConverter.to_eval_item(
query="Q",
response=response,
tools=[FunctionTool(name="t", description="d", func=lambda: "")],
)
d = item.to_eval_data()
assert isinstance(d["query_messages"], list)
assert isinstance(d["response_messages"], list)
# Single-turn: query_messages has just the user msg, response_messages has the assistant msg
assert len(d["query_messages"]) == 1
assert d["query_messages"][0]["role"] == "user"
assert len(d["response_messages"]) == 1
assert d["response_messages"][0]["role"] == "assistant"
assert isinstance(d["tool_definitions"], list)
assert len(d["tool_definitions"]) == 1
assert d["tool_definitions"][0]["name"] == "t"
assert "conversation" not in d
def test_to_dict_multiturn_preserves_interleaving(self) -> None:
"""Multi-turn to_dict() splits at last user message, preserving interleaving."""
conversation = [
Message("user", ["What's the weather?"]),
Message("assistant", ["It's sunny in Seattle."]),
Message("user", ["And tomorrow?"]),
Message("assistant", [Content(type="function_call", name="get_forecast")]),
Message("tool", [Content(type="function_result", result="Rain expected")]),
Message("assistant", ["Rain is expected tomorrow."]),
]
item = EvalItem(conversation=conversation)
d = item.to_eval_data()
# query_messages: everything up to and including the last user message
assert len(d["query_messages"]) == 3 # user, assistant, user
assert d["query_messages"][0]["role"] == "user"
assert d["query_messages"][1]["role"] == "assistant" # interleaved!
assert d["query_messages"][2]["role"] == "user"
# response_messages: everything after the last user message
assert len(d["response_messages"]) == 3 # assistant(tool_call), tool, assistant
assert d["response_messages"][0]["role"] == "assistant"
assert d["response_messages"][1]["role"] == "tool"
assert d["response_messages"][2]["role"] == "assistant"
def test_to_dict_full_split(self) -> None:
"""ConversationSplit.FULL splits after the first user message."""
conversation = [
Message("user", ["What's the weather?"]),
Message("assistant", ["It's 62°F in Seattle."]),
Message("user", ["And tomorrow?"]),
Message("assistant", ["Rain is expected tomorrow."]),
]
item = EvalItem(conversation=conversation)
d = item.to_eval_data(split=ConversationSplit.FULL)
# query_messages: just the first user message
assert len(d["query_messages"]) == 1
assert d["query_messages"][0]["role"] == "user"
assert d["query_messages"][0]["content"] == [{"type": "text", "text": "What's the weather?"}]
# response_messages: everything after the first user message
assert len(d["response_messages"]) == 3
assert d["response_messages"][0]["role"] == "assistant"
assert d["response_messages"][1]["role"] == "user"
assert d["response_messages"][2]["role"] == "assistant"
def test_to_dict_full_split_with_system(self) -> None:
"""FULL split includes system messages before the first user message in query."""
conversation = [
Message("system", ["You are a weather assistant."]),
Message("user", ["What's the weather?"]),
Message("assistant", ["It's sunny."]),
]
item = EvalItem(conversation=conversation)
d = item.to_eval_data(split=ConversationSplit.FULL)
# query includes system + first user
assert len(d["query_messages"]) == 2
assert d["query_messages"][0]["role"] == "system"
assert d["query_messages"][1]["role"] == "user"
assert len(d["response_messages"]) == 1
def test_to_dict_full_split_with_tools(self) -> None:
"""FULL split puts all tool interactions in response_messages."""
conversation = [
Message("user", ["What's the weather?"]),
Message("assistant", [Content(type="function_call", name="get_weather")]),
Message("tool", [Content(type="function_result", result="62°F")]),
Message("assistant", ["It's 62°F."]),
Message("user", ["Thanks!"]),
Message("assistant", ["You're welcome!"]),
]
item = EvalItem(conversation=conversation)
d = item.to_eval_data(split=ConversationSplit.FULL)
assert len(d["query_messages"]) == 1
assert len(d["response_messages"]) == 5
def test_to_dict_last_turn_is_default(self) -> None:
"""Default to_dict() uses LAST_TURN split."""
conversation = [
Message("user", ["Hello"]),
Message("assistant", ["Hi there"]),
Message("user", ["Bye"]),
Message("assistant", ["Goodbye"]),
]
item = EvalItem(conversation=conversation)
d_default = item.to_eval_data()
d_explicit = item.to_eval_data(split=ConversationSplit.LAST_TURN)
assert d_default["query_messages"] == d_explicit["query_messages"]
assert d_default["response_messages"] == d_explicit["response_messages"]
def test_per_turn_items_simple(self) -> None:
"""per_turn_items produces one EvalItem per user message."""
conversation = [
Message("user", ["What's the weather?"]),
Message("assistant", ["It's 62°F."]),
Message("user", ["And tomorrow?"]),
Message("assistant", ["Rain expected."]),
]
items = EvalItem.per_turn_items(conversation)
assert len(items) == 2
# Turn 1
assert items[0].query == "What's the weather?"
assert items[0].response == "It's 62°F."
assert len(items[0].conversation) == 2
# Turn 2 — includes cumulative context; query joins all user texts in query split
assert items[1].query == "What's the weather? And tomorrow?"
assert items[1].response == "Rain expected."
assert len(items[1].conversation) == 4
def test_per_turn_items_with_tools(self) -> None:
"""per_turn_items handles tool calls within a turn."""
conversation = [
Message("user", ["Check weather"]),
Message("assistant", [Content(type="function_call", name="get_weather")]),
Message("tool", [Content(type="function_result", result="sunny")]),
Message("assistant", ["It's sunny."]),
Message("user", ["Thanks"]),
Message("assistant", ["You're welcome!"]),
]
tool_objs = [_make_tool("get_weather")]
items = EvalItem.per_turn_items(conversation, tools=tool_objs)
assert len(items) == 2
# Turn 1: response includes tool_call, tool_result, and final assistant
assert items[0].response == "It's sunny."
assert items[0].tools == tool_objs
assert len(items[0].conversation) == 4 # user, assistant(tool), tool, assistant
# Turn 2
assert items[1].response == "You're welcome!"
assert len(items[1].conversation) == 6 # full conversation
def test_per_turn_items_empty(self) -> None:
"""per_turn_items returns empty list when no user messages."""
items = EvalItem.per_turn_items([Message("assistant", ["Hello"])])
assert items == []
def test_per_turn_items_single_turn(self) -> None:
"""per_turn_items with single turn produces one item."""
conversation = [
Message("user", ["Hi"]),
Message("assistant", ["Hello!"]),
]
items = EvalItem.per_turn_items(conversation)
assert len(items) == 1
assert items[0].query == "Hi"
assert items[0].response == "Hello!"
def test_custom_splitter_callable(self) -> None:
"""Custom callable splitter is used by to_dict()."""
conversation = [
Message("user", ["Remember my name is Alice"]),
Message("assistant", ["Got it, Alice!"]),
Message("user", ["What's the capital of France?"]),
Message("assistant", [Content(type="function_call", name="retrieve_memory", call_id="m1")]),
Message("tool", [Content(type="function_result", call_id="m1", result="User name: Alice")]),
Message("assistant", ["The capital of France is Paris, Alice!"]),
]
def split_before_memory(conv):
"""Split just before the memory retrieval tool call."""
for i, msg in enumerate(conv):
for c in msg.contents:
if c.name == "retrieve_memory":
return conv[:i], conv[i:]
return EvalItem._split_last_turn_static(conv)
item = EvalItem(conversation=conversation)
d = item.to_eval_data(split=split_before_memory)
# split_before_memory finds "retrieve_memory" at conv[3] (assistant tool_call msg)
# query = conv[:3] = [user, assistant, user]
# response = conv[3:] = [assistant(tool_call), tool, assistant]
assert len(d["query_messages"]) == 3
assert d["query_messages"][-1]["role"] == "user"
assert len(d["response_messages"]) == 3
assert d["response_messages"][0]["role"] == "assistant" # the tool_call msg
def test_custom_splitter_with_fallback(self) -> None:
"""Custom splitter falls back to _split_last_turn_static when pattern not found."""
conversation = [
Message("user", ["Hello"]),
Message("assistant", ["Hi there!"]),
]
def split_before_memory(conv):
for i, msg in enumerate(conv):
for c in msg.contents:
if c.name == "retrieve_memory":
return conv[:i], conv[i:]
return EvalItem._split_last_turn_static(conv)
item = EvalItem(conversation=conversation)
d = item.to_eval_data(split=split_before_memory)
# Falls back to last-turn split
assert len(d["query_messages"]) == 1
assert d["query_messages"][0]["role"] == "user"
assert len(d["response_messages"]) == 1
assert d["response_messages"][0]["role"] == "assistant"
def test_custom_splitter_lambda(self) -> None:
"""A lambda works as a custom splitter."""
conversation = [
Message("user", ["A"]),
Message("assistant", ["B"]),
Message("user", ["C"]),
Message("assistant", ["D"]),
]
# Split at index 2 (arbitrary)
item = EvalItem(conversation=conversation)
d = item.to_eval_data(split=lambda conv: (conv[:2], conv[2:]))
assert len(d["query_messages"]) == 2
assert len(d["response_messages"]) == 2
def test_split_strategy_on_item_used_by_to_dict(self) -> None:
"""split_strategy field on EvalItem is used as default by to_dict()."""
conversation = [
Message("user", ["First"]),
Message("assistant", ["Response 1"]),
Message("user", ["Second"]),
Message("assistant", ["Response 2"]),
]
item = EvalItem(
conversation=conversation,
split_strategy=ConversationSplit.FULL,
)
# to_dict() with no split arg should use item.split_strategy
d = item.to_eval_data()
assert len(d["query_messages"]) == 1 # FULL: just first user msg
assert d["query_messages"][0]["content"] == [{"type": "text", "text": "First"}]
assert len(d["response_messages"]) == 3
def test_explicit_split_overrides_item_split_strategy(self) -> None:
"""Explicit split= arg to to_dict() overrides item.split_strategy."""
conversation = [
Message("user", ["First"]),
Message("assistant", ["Response 1"]),
Message("user", ["Second"]),
Message("assistant", ["Response 2"]),
]
item = EvalItem(
conversation=conversation,
split_strategy=ConversationSplit.FULL,
)
# Explicit split= should override split_strategy
d = item.to_eval_data(split=ConversationSplit.LAST_TURN)
assert len(d["query_messages"]) == 3 # LAST_TURN: up to last user
assert d["query_messages"][-1]["content"] == [{"type": "text", "text": "Second"}]
assert len(d["response_messages"]) == 1
def test_no_split_defaults_to_last_turn(self) -> None:
"""When neither split= nor split_strategy is set, defaults to LAST_TURN."""
conversation = [
Message("user", ["Hello"]),
Message("assistant", ["Hi"]),
]
item = EvalItem(conversation=conversation)
assert item.split_strategy is None
d = item.to_eval_data()
assert len(d["query_messages"]) == 1
assert d["query_messages"][0]["role"] == "user"
# ---------------------------------------------------------------------------
# _build_testing_criteria
# ---------------------------------------------------------------------------
class TestBuildTestingCriteria:
def test_without_data_mapping(self) -> None:
criteria = _build_testing_criteria(["relevance", "coherence"], "gpt-4o")
assert len(criteria) == 2
assert criteria[0]["evaluator_name"] == "builtin.relevance"
assert criteria[0]["initialization_parameters"] == {"deployment_name": "gpt-4o"}
assert "data_mapping" not in criteria[0]
def test_with_data_mapping(self) -> None:
criteria = _build_testing_criteria(["relevance", "groundedness"], "gpt-4o", include_data_mapping=True)
assert "data_mapping" in criteria[0]
# Quality evaluators should NOT have conversation
assert criteria[0]["data_mapping"] == {
"query": "{{item.query}}",
"response": "{{item.response}}",
}
# Groundedness has an extra context mapping
assert "context" in criteria[1]["data_mapping"]
assert "conversation" not in criteria[1]["data_mapping"]
def test_tool_evaluator_includes_tool_definitions(self) -> None:
criteria = _build_testing_criteria(["relevance", "tool_call_accuracy"], "gpt-4o", include_data_mapping=True)
# relevance: string query/response
assert criteria[0]["data_mapping"]["query"] == "{{item.query}}"
assert criteria[0]["data_mapping"]["response"] == "{{item.response}}"
assert "tool_definitions" not in criteria[0]["data_mapping"]
# tool_call_accuracy: array query/response + tool_definitions
assert criteria[1]["data_mapping"]["query"] == "{{item.query_messages}}"
assert criteria[1]["data_mapping"]["response"] == "{{item.response_messages}}"
assert criteria[1]["data_mapping"]["tool_definitions"] == "{{item.tool_definitions}}"
def test_agent_evaluators_use_message_arrays(self) -> None:
agent_evals = ["task_adherence", "intent_resolution", "task_completion"]
criteria = _build_testing_criteria(agent_evals, "gpt-4o", include_data_mapping=True)
for c in criteria:
assert c["data_mapping"]["query"] == "{{item.query_messages}}", f"{c['name']}"
assert c["data_mapping"]["response"] == "{{item.response_messages}}", f"{c['name']}"
def test_quality_evaluators_use_strings(self) -> None:
quality_evals = ["coherence", "relevance", "fluency"]
criteria = _build_testing_criteria(quality_evals, "gpt-4o", include_data_mapping=True)
for c in criteria:
assert c["data_mapping"]["query"] == "{{item.query}}", f"{c['name']}"
assert c["data_mapping"]["response"] == "{{item.response}}", f"{c['name']}"
def test_all_tool_evaluators_include_tool_definitions(self) -> None:
tool_evals = [
"tool_call_accuracy",
"tool_selection",
"tool_input_accuracy",
"tool_output_utilization",
"tool_call_success",
]
criteria = _build_testing_criteria(tool_evals, "gpt-4o", include_data_mapping=True)
for c in criteria:
assert "tool_definitions" in c["data_mapping"], f"{c['name']} missing tool_definitions"
# ---------------------------------------------------------------------------
# _build_item_schema
# ---------------------------------------------------------------------------
class TestBuildItemSchema:
def test_without_context(self) -> None:
schema = _build_item_schema(has_context=False)
assert "context" not in schema["properties"]
assert schema["required"] == ["query", "response"]
def test_with_context(self) -> None:
schema = _build_item_schema(has_context=True)
assert "context" in schema["properties"]
def test_with_tools(self) -> None:
schema = _build_item_schema(has_tools=True)
assert "tool_definitions" in schema["properties"]
def test_with_context_and_tools(self) -> None:
schema = _build_item_schema(has_context=True, has_tools=True)
assert "context" in schema["properties"]
assert "tool_definitions" in schema["properties"]
# ---------------------------------------------------------------------------
# FoundryEvals (constructor, name, select, evaluate via dataset)
# ---------------------------------------------------------------------------
class TestFoundryEvals:
def test_constructor_with_openai_client(self) -> None:
mock_client = MagicMock()
fe = FoundryEvals(openai_client=mock_client, model_deployment="gpt-4o")
assert fe.name == "Microsoft Foundry"
def test_constructor_with_project_client(self) -> None:
mock_oai = MagicMock()
mock_project = MagicMock()
mock_project.get_openai_client.return_value = mock_oai
fe = FoundryEvals(project_client=mock_project, model_deployment="gpt-4o")
assert fe.name == "Microsoft Foundry"
mock_project.get_openai_client.assert_called_once()
def test_constructor_no_client_raises(self) -> None:
with pytest.raises(ValueError, match="Provide either"):
FoundryEvals(model_deployment="gpt-4o")
def test_name_property(self) -> None:
fe = FoundryEvals(openai_client=MagicMock(), model_deployment="gpt-4o")
assert fe.name == "Microsoft Foundry"
def test_evaluators_passed_in_constructor(self) -> None:
fe = FoundryEvals(
openai_client=MagicMock(),
model_deployment="gpt-4o",
evaluators=["relevance", "coherence"],
)
assert fe._evaluators == ["relevance", "coherence"]
@pytest.mark.asyncio
async def test_evaluate_calls_evals_api(self) -> None:
mock_client = MagicMock()
mock_eval = MagicMock()
mock_eval.id = "eval_123"
mock_client.evals.create.return_value = mock_eval
mock_run = MagicMock()
mock_run.id = "run_456"
mock_client.evals.runs.create.return_value = mock_run
mock_completed = MagicMock()
mock_completed.status = "completed"
mock_completed.result_counts = {"passed": 2, "failed": 0}
mock_completed.report_url = "https://portal.azure.com/eval/run_456"
mock_completed.per_testing_criteria_results = None
mock_client.evals.runs.retrieve.return_value = mock_completed
items = [
EvalItem(conversation=[Message("user", ["Hello"]), Message("assistant", ["Hi there!"])]),
EvalItem(conversation=[Message("user", ["Weather?"]), Message("assistant", ["Sunny."])]),
]
fe = FoundryEvals(
openai_client=mock_client,
model_deployment="gpt-4o",
evaluators=[FoundryEvals.RELEVANCE],
)
results = await fe.evaluate(items)
assert isinstance(results, EvalResults)
assert results.status == "completed"
assert results.eval_id == "eval_123"
assert results.run_id == "run_456"
assert results.report_url == "https://portal.azure.com/eval/run_456"
assert results.all_passed
assert results.passed == 2
assert results.failed == 0
# Verify evals.create was called with correct structure
create_call = mock_client.evals.create.call_args
assert create_call.kwargs["name"] == "Agent Framework Eval"
assert create_call.kwargs["data_source_config"]["type"] == "custom"
# Verify evals.runs.create was called with JSONL data source
run_call = mock_client.evals.runs.create.call_args
assert run_call.kwargs["data_source"]["type"] == "jsonl"
content = run_call.kwargs["data_source"]["source"]["content"]
assert len(content) == 2
@pytest.mark.asyncio
async def test_evaluate_uses_default_evaluators(self) -> None:
mock_client = MagicMock()
mock_eval = MagicMock()
mock_eval.id = "eval_1"
mock_client.evals.create.return_value = mock_eval
mock_run = MagicMock()
mock_run.id = "run_1"
mock_client.evals.runs.create.return_value = mock_run
mock_completed = MagicMock()
mock_completed.status = "completed"
mock_completed.result_counts = {"passed": 1, "failed": 0}
mock_completed.report_url = None
mock_completed.per_testing_criteria_results = None
mock_client.evals.runs.retrieve.return_value = mock_completed
fe = FoundryEvals(openai_client=mock_client, model_deployment="gpt-4o")
await fe.evaluate([EvalItem(conversation=[Message("user", ["Hi"]), Message("assistant", ["Hello"])])])
# Verify default evaluators were used
create_call = mock_client.evals.create.call_args
criteria = create_call.kwargs["testing_criteria"]
names = {c["name"] for c in criteria}
assert "relevance" in names
assert "coherence" in names
assert "task_adherence" in names
@pytest.mark.asyncio
async def test_evaluate_uses_dataset_path(self) -> None:
"""Items use the JSONL dataset path."""
mock_client = MagicMock()
mock_eval = MagicMock()
mock_eval.id = "eval_ds"
mock_client.evals.create.return_value = mock_eval
mock_run = MagicMock()
mock_run.id = "run_ds"
mock_client.evals.runs.create.return_value = mock_run
mock_completed = MagicMock()
mock_completed.status = "completed"
mock_completed.result_counts = {"passed": 1, "failed": 0}
mock_completed.report_url = None
mock_completed.per_testing_criteria_results = None
mock_client.evals.runs.retrieve.return_value = mock_completed
items = [
EvalItem(
conversation=[Message("user", ["What's the weather?"]), Message("assistant", ["Sunny"])],
),
]
fe = FoundryEvals(openai_client=mock_client, model_deployment="gpt-4o")
await fe.evaluate(items)
run_call = mock_client.evals.runs.create.call_args
ds = run_call.kwargs["data_source"]
assert ds["type"] == "jsonl"
content = ds["source"]["content"]
assert content[0]["item"]["query"] == "What's the weather?"
@pytest.mark.asyncio
async def test_evaluate_with_tool_items_uses_dataset_path(self) -> None:
"""Items with tool_definitions use the dataset path."""
mock_client = MagicMock()
mock_eval = MagicMock()
mock_eval.id = "eval_tool"
mock_client.evals.create.return_value = mock_eval
mock_run = MagicMock()
mock_run.id = "run_tool"
mock_client.evals.runs.create.return_value = mock_run
mock_completed = MagicMock()
mock_completed.status = "completed"
mock_completed.result_counts = {"passed": 1, "failed": 0}
mock_completed.report_url = None
mock_completed.per_testing_criteria_results = None
mock_client.evals.runs.retrieve.return_value = mock_completed
items = [
EvalItem(
conversation=[Message("user", ["Do the thing"]), Message("assistant", ["Done"])],
tools=[_make_tool("my_tool")],
),
]
fe = FoundryEvals(
openai_client=mock_client,
model_deployment="gpt-4o",
evaluators=[FoundryEvals.TOOL_CALL_ACCURACY],
)
await fe.evaluate(items)
run_call = mock_client.evals.runs.create.call_args
ds = run_call.kwargs["data_source"]
assert ds["type"] == "jsonl"
assert "tool_definitions" in ds["source"]["content"][0]["item"]
@pytest.mark.asyncio
async def test_evaluate_with_project_client(self) -> None:
mock_oai = MagicMock()
mock_project = MagicMock()
mock_project.get_openai_client.return_value = mock_oai
mock_eval = MagicMock()
mock_eval.id = "eval_pc"
mock_oai.evals.create.return_value = mock_eval
mock_run = MagicMock()
mock_run.id = "run_pc"
mock_oai.evals.runs.create.return_value = mock_run
mock_completed = MagicMock()
mock_completed.status = "completed"
mock_completed.result_counts = {"passed": 1, "failed": 0}
mock_completed.report_url = None
mock_completed.per_testing_criteria_results = None
mock_oai.evals.runs.retrieve.return_value = mock_completed
fe = FoundryEvals(project_client=mock_project, model_deployment="gpt-4o")
results = await fe.evaluate([EvalItem(conversation=[Message("user", ["Hi"]), Message("assistant", ["Hello"])])])
assert results.status == "completed"
mock_project.get_openai_client.assert_called_once()
# ---------------------------------------------------------------------------
# FoundryEvals constants
# ---------------------------------------------------------------------------
class TestEvaluators:
def test_constants_resolve(self) -> None:
assert _resolve_evaluator(FoundryEvals.RELEVANCE) == "builtin.relevance"
assert _resolve_evaluator(FoundryEvals.TOOL_CALL_ACCURACY) == "builtin.tool_call_accuracy"
assert _resolve_evaluator(FoundryEvals.VIOLENCE) == "builtin.violence"
assert _resolve_evaluator(FoundryEvals.INTENT_RESOLUTION) == "builtin.intent_resolution"
def test_all_constants_are_valid(self) -> None:
for attr in dir(FoundryEvals):
if attr.startswith("_"):
continue
value = getattr(FoundryEvals, attr)
if isinstance(value, str):
_resolve_evaluator(value) # should not raise
# ---------------------------------------------------------------------------
# _resolve_default_evaluators
# ---------------------------------------------------------------------------
class TestResolveDefaultEvaluators:
def test_explicit_evaluators_passthrough(self) -> None:
result = _resolve_default_evaluators([FoundryEvals.VIOLENCE])
assert result == [FoundryEvals.VIOLENCE]
def test_none_gives_defaults(self) -> None:
result = _resolve_default_evaluators(None)
assert FoundryEvals.RELEVANCE in result
assert FoundryEvals.COHERENCE in result
assert FoundryEvals.TASK_ADHERENCE in result
assert FoundryEvals.TOOL_CALL_ACCURACY not in result
def test_none_with_tool_items_adds_tool_eval(self) -> None:
items = [
EvalItem(
conversation=[Message("user", ["search for stuff"]), Message("assistant", ["found it"])],
tools=[_make_tool("search")],
),
]
result = _resolve_default_evaluators(None, items=items)
assert FoundryEvals.TOOL_CALL_ACCURACY in result
def test_explicit_evaluators_ignore_tool_items(self) -> None:
items = [
EvalItem(
conversation=[Message("user", ["search"]), Message("assistant", ["found"])],
tools=[_make_tool("search")],
),
]
result = _resolve_default_evaluators([FoundryEvals.RELEVANCE], items=items)
assert result == [FoundryEvals.RELEVANCE]
# ---------------------------------------------------------------------------
# _filter_tool_evaluators
# ---------------------------------------------------------------------------
class TestFilterToolEvaluators:
def test_keeps_tool_evaluators_when_items_have_tools(self) -> None:
items = [
EvalItem(conversation=[Message("user", ["q"]), Message("assistant", ["r"])], tools=[_make_tool("t")]),
]
result = _filter_tool_evaluators(
["relevance", "tool_call_accuracy"],
items,
)
assert "relevance" in result
assert "tool_call_accuracy" in result
def test_removes_tool_evaluators_when_no_tools(self) -> None:
items = [
EvalItem(conversation=[Message("user", ["q"]), Message("assistant", ["r"])]),
]
result = _filter_tool_evaluators(
["relevance", "tool_call_accuracy"],
items,
)
assert "relevance" in result
assert "tool_call_accuracy" not in result
def test_falls_back_to_defaults_when_all_filtered(self) -> None:
items = [
EvalItem(conversation=[Message("user", ["q"]), Message("assistant", ["r"])]),
]
result = _filter_tool_evaluators(
["tool_call_accuracy", "tool_selection"],
items,
)
# Should fall back to defaults since all evaluators were tool evaluators
assert FoundryEvals.RELEVANCE in result
# ---------------------------------------------------------------------------
# EvalResults
# ---------------------------------------------------------------------------
class TestEvalResults:
def test_all_passed_true(self) -> None:
r = EvalResults(
provider="test",
eval_id="e",
run_id="r",
status="completed",
result_counts={"passed": 3, "failed": 0, "errored": 0},
)
assert r.all_passed
assert r.passed == 3
assert r.failed == 0
assert r.errored == 0
assert r.total == 3
def test_all_passed_false_on_failure(self) -> None:
r = EvalResults(
provider="test",
eval_id="e",
run_id="r",
status="completed",
result_counts={"passed": 2, "failed": 1, "errored": 0},
)
assert not r.all_passed
assert r.failed == 1
def test_all_passed_false_on_error(self) -> None:
r = EvalResults(
provider="test",
eval_id="e",
run_id="r",
status="completed",
result_counts={"passed": 2, "failed": 0, "errored": 1},
)
assert not r.all_passed
def test_all_passed_false_on_non_completed(self) -> None:
r = EvalResults(
provider="test",
eval_id="e",
run_id="r",
status="timeout",
result_counts={"passed": 2, "failed": 0, "errored": 0},
)
assert not r.all_passed
def test_all_passed_false_on_empty(self) -> None:
r = EvalResults(
provider="test",
eval_id="e",
run_id="r",
status="completed",
result_counts={"passed": 0, "failed": 0, "errored": 0},
)
assert not r.all_passed
def test_assert_passed_succeeds(self) -> None:
r = EvalResults(
provider="test",
eval_id="e",
run_id="r",
status="completed",
result_counts={"passed": 1, "failed": 0, "errored": 0},
)
r.assert_passed() # should not raise
def test_assert_passed_raises(self) -> None:
r = EvalResults(
provider="test",
eval_id="e",
run_id="r",
status="completed",
result_counts={"passed": 1, "failed": 1, "errored": 0},
)
with pytest.raises(AssertionError, match="1 passed, 1 failed"):
r.assert_passed()
def test_assert_passed_custom_message(self) -> None:
r = EvalResults(provider="test", eval_id="e", run_id="r", status="failed")
with pytest.raises(AssertionError, match="custom error"):
r.assert_passed("custom error")
def test_none_result_counts(self) -> None:
r = EvalResults(provider="test", eval_id="e", run_id="r", status="completed")
assert r.passed == 0
assert r.failed == 0
assert r.total == 0
assert not r.all_passed
# ---------------------------------------------------------------------------
# _resolve_openai_client
# ---------------------------------------------------------------------------
class TestResolveOpenAIClient:
def test_explicit_client(self) -> None:
mock_client = MagicMock()
assert _resolve_openai_client(openai_client=mock_client) is mock_client
def test_project_client(self) -> None:
mock_oai = MagicMock()
mock_project = MagicMock()
mock_project.get_openai_client.return_value = mock_oai
result = _resolve_openai_client(project_client=mock_project)
assert result is mock_oai
mock_project.get_openai_client.assert_called_once()
def test_explicit_takes_precedence(self) -> None:
mock_client = MagicMock()
mock_project = MagicMock()
result = _resolve_openai_client(openai_client=mock_client, project_client=mock_project)
assert result is mock_client
mock_project.get_openai_client.assert_not_called()
def test_neither_raises(self) -> None:
with pytest.raises(ValueError, match="Provide either"):
_resolve_openai_client()
# ---------------------------------------------------------------------------
# evaluate_agent with responses= (core function, uses FoundryEvals as evaluator)
# ---------------------------------------------------------------------------
class TestEvaluateAgentWithResponses:
@pytest.mark.asyncio
async def test_responses_without_queries_raises(self) -> None:
mock_oai = MagicMock()
response = AgentResponse(messages=[Message("assistant", ["Hello"])])
with pytest.raises(ValueError, match="Provide 'queries' alongside 'responses'"):
await evaluate_agent(
responses=response,
evaluators=FoundryEvals(openai_client=mock_oai, model_deployment="gpt-4o"),
)
@pytest.mark.asyncio
async def test_fallback_to_dataset_with_query(self) -> None:
"""Non-Responses-API: falls back to dataset path when query is provided."""
mock_oai = MagicMock()
mock_eval = MagicMock()
mock_eval.id = "eval_fb"
mock_oai.evals.create.return_value = mock_eval
mock_run = MagicMock()
mock_run.id = "run_fb"
mock_oai.evals.runs.create.return_value = mock_run
mock_completed = MagicMock()
mock_completed.status = "completed"
mock_completed.result_counts = {"passed": 1, "failed": 0}
mock_completed.report_url = "https://portal.azure.com/eval"
mock_completed.per_testing_criteria_results = None
mock_oai.evals.runs.retrieve.return_value = mock_completed
response = AgentResponse(messages=[Message("assistant", ["It's sunny."])])
results = await evaluate_agent(
responses=response,
queries=["What's the weather?"],
evaluators=FoundryEvals(openai_client=mock_oai, model_deployment="gpt-4o"),
)
assert results[0].status == "completed"
assert results[0].all_passed
# Should use jsonl data source (dataset path), not azure_ai_responses
run_call = mock_oai.evals.runs.create.call_args
ds = run_call.kwargs["data_source"]
assert ds["type"] == "jsonl"
content = ds["source"]["content"]
assert len(content) == 1
assert content[0]["item"]["query"] == "What's the weather?"
assert content[0]["item"]["response"] == "It's sunny."
@pytest.mark.asyncio
async def test_fallback_with_agent_extracts_tools(self) -> None:
"""Non-Responses-API with agent: tool definitions are included in the eval item."""
mock_oai = MagicMock()
mock_eval = MagicMock()
mock_eval.id = "eval_tools"
mock_oai.evals.create.return_value = mock_eval
mock_run = MagicMock()
mock_run.id = "run_tools"
mock_oai.evals.runs.create.return_value = mock_run
mock_completed = MagicMock()
mock_completed.status = "completed"
mock_completed.result_counts = {"passed": 1, "failed": 0}
mock_completed.report_url = None
mock_completed.per_testing_criteria_results = None
mock_oai.evals.runs.retrieve.return_value = mock_completed
mock_agent = MagicMock()
mock_agent.default_options = {
"tools": [FunctionTool(name="my_tool", description="A test tool", func=lambda x: x)]
}
response = AgentResponse(messages=[Message("assistant", ["Result."])])
results = await evaluate_agent(
responses=response,
queries=["Do the thing"],
agent=mock_agent,
evaluators=FoundryEvals(openai_client=mock_oai, model_deployment="gpt-4o"),
)
assert results[0].status == "completed"
run_call = mock_oai.evals.runs.create.call_args
ds = run_call.kwargs["data_source"]
content = ds["source"]["content"]
item = content[0]["item"]
assert "tool_definitions" in item
tool_defs = item["tool_definitions"]
assert any(t["name"] == "my_tool" for t in tool_defs)
@pytest.mark.asyncio
async def test_fallback_multiple_responses_with_queries(self) -> None:
"""Non-Responses-API with multiple responses requires matching queries."""
mock_oai = MagicMock()
mock_eval = MagicMock()
mock_eval.id = "eval_multi_fb"
mock_oai.evals.create.return_value = mock_eval
mock_run = MagicMock()
mock_run.id = "run_multi_fb"
mock_oai.evals.runs.create.return_value = mock_run
mock_completed = MagicMock()
mock_completed.status = "completed"
mock_completed.result_counts = {"passed": 2, "failed": 0}
mock_completed.report_url = None
mock_completed.per_testing_criteria_results = None
mock_oai.evals.runs.retrieve.return_value = mock_completed
responses = [
AgentResponse(messages=[Message("assistant", ["Answer 1"])]),
AgentResponse(messages=[Message("assistant", ["Answer 2"])]),
]
results = await evaluate_agent(
responses=responses,
queries=["Question 1", "Question 2"],
evaluators=FoundryEvals(openai_client=mock_oai, model_deployment="gpt-4o"),
)
assert results[0].passed == 2
run_call = mock_oai.evals.runs.create.call_args
content = run_call.kwargs["data_source"]["source"]["content"]
assert len(content) == 2
assert content[0]["item"]["query"] == "Question 1"
assert content[1]["item"]["query"] == "Question 2"
@pytest.mark.asyncio
async def test_query_response_count_mismatch_raises(self) -> None:
"""Mismatched query and response counts should raise."""
mock_oai = MagicMock()
responses = [
AgentResponse(messages=[Message("assistant", ["A1"])]),
AgentResponse(messages=[Message("assistant", ["A2"])]),
]
with pytest.raises(ValueError, match="queries but"):
await evaluate_agent(
responses=responses,
queries=["Q1", "Q2", "Q3"],
evaluators=FoundryEvals(openai_client=mock_oai, model_deployment="gpt-4o"),
)
@pytest.mark.asyncio
async def test_tool_evaluators_with_query_and_agent_uses_dataset_path(self) -> None:
"""Tool evaluators with query+agent uses dataset path."""
mock_oai = MagicMock()
mock_eval = MagicMock()
mock_eval.id = "eval_tool"
mock_oai.evals.create.return_value = mock_eval
mock_run = MagicMock()
mock_run.id = "run_tool"
mock_oai.evals.runs.create.return_value = mock_run
mock_completed = MagicMock()
mock_completed.status = "completed"
mock_completed.result_counts = {"passed": 1, "failed": 0}
mock_completed.report_url = None
mock_completed.per_testing_criteria_results = None
mock_oai.evals.runs.retrieve.return_value = mock_completed
response = AgentResponse(
messages=[Message("assistant", ["It's sunny"])],
)
agent = MagicMock()
agent.default_options = {
"tools": [
FunctionTool(name="get_weather", description="Get weather", func=lambda: None),
]
}
fe = FoundryEvals(
openai_client=mock_oai,
model_deployment="gpt-4o",
evaluators=[FoundryEvals.TOOL_CALL_ACCURACY],
)
await evaluate_agent(
responses=response,
queries=["What's the weather?"],
agent=agent,
evaluators=fe,
)
# Verify it used the dataset path (jsonl), not Responses API path
run_call = mock_oai.evals.runs.create.call_args
ds = run_call.kwargs["data_source"]
assert ds["type"] == "jsonl"
# Verify tool_definitions are in the data items
items = ds["source"]["content"]
assert "tool_definitions" in items[0]["item"]
# ---------------------------------------------------------------------------
# EvalResults.sub_results
# ---------------------------------------------------------------------------
class TestEvalResultsSubResults:
def test_sub_results_default_empty(self) -> None:
r = EvalResults(
provider="test",
eval_id="e1",
run_id="r1",
status="completed",
result_counts={"passed": 1, "failed": 0},
)
assert r.sub_results == {}
assert r.all_passed
def test_all_passed_checks_sub_results(self) -> None:
parent = EvalResults(
provider="test",
eval_id="e1",
run_id="r1",
status="completed",
result_counts={"passed": 2, "failed": 0},
sub_results={
"agent-a": EvalResults(
provider="test",
eval_id="e2",
run_id="r2",
status="completed",
result_counts={"passed": 1, "failed": 0},
),
"agent-b": EvalResults(
provider="test",
eval_id="e3",
run_id="r3",
status="completed",
result_counts={"passed": 1, "failed": 1},
),
},
)
assert not parent.all_passed # agent-b has a failure
def test_all_passed_with_all_sub_passing(self) -> None:
parent = EvalResults(
provider="test",
eval_id="e1",
run_id="r1",
status="completed",
result_counts={"passed": 2, "failed": 0},
sub_results={
"agent-a": EvalResults(
provider="test",
eval_id="e2",
run_id="r2",
status="completed",
result_counts={"passed": 1, "failed": 0},
),
},
)
assert parent.all_passed
def test_assert_passed_includes_failed_agents(self) -> None:
parent = EvalResults(
provider="test",
eval_id="e1",
run_id="r1",
status="completed",
result_counts={"passed": 2, "failed": 0},
sub_results={
"good-agent": EvalResults(
provider="test",
eval_id="e2",
run_id="r2",
status="completed",
result_counts={"passed": 1, "failed": 0},
),
"bad-agent": EvalResults(
provider="test",
eval_id="e3",
run_id="r3",
status="completed",
result_counts={"passed": 0, "failed": 1},
),
},
)
with pytest.raises(AssertionError, match="bad-agent"):
parent.assert_passed()
# ---------------------------------------------------------------------------
# _extract_agent_eval_data
# ---------------------------------------------------------------------------
def _make_agent_exec_response(
executor_id: str,
response_text: str,
user_messages: list[str] | None = None,
) -> AgentExecutorResponse:
"""Helper to build an AgentExecutorResponse for testing."""
agent_response = AgentResponse(messages=[Message("assistant", [response_text])])
full_conv: list[Message] = []
if user_messages:
for m in user_messages:
full_conv.append(Message("user", [m]))
full_conv.extend(agent_response.messages)
return AgentExecutorResponse(
executor_id=executor_id,
agent_response=agent_response,
full_conversation=full_conv,
)
class TestExtractAgentEvalData:
def test_extracts_single_agent(self) -> None:
aer = _make_agent_exec_response("planner", "Plan is ready", ["Plan a trip"])
events = [
WorkflowEvent.executor_invoked("planner", "Plan a trip"),
WorkflowEvent.executor_completed("planner", [aer]),
]
result = WorkflowRunResult(events, [])
data = _extract_agent_eval_data(result)
assert len(data) == 1
assert data[0]["executor_id"] == "planner"
assert data[0]["response"].text == "Plan is ready"
def test_extracts_multiple_agents(self) -> None:
aer1 = _make_agent_exec_response("planner", "Plan done", ["Plan a trip"])
aer2 = _make_agent_exec_response("booker", "Booked!", ["Book flight"])
events = [
WorkflowEvent.executor_invoked("planner", "Plan a trip"),
WorkflowEvent.executor_completed("planner", [aer1]),
WorkflowEvent.executor_invoked("booker", "Book flight"),
WorkflowEvent.executor_completed("booker", [aer2]),
]
result = WorkflowRunResult(events, [])
data = _extract_agent_eval_data(result)
assert len(data) == 2
assert data[0]["executor_id"] == "planner"
assert data[1]["executor_id"] == "booker"
def test_skips_internal_executors(self) -> None:
aer = _make_agent_exec_response("planner", "Done", ["Go"])
events = [
WorkflowEvent.executor_invoked("input-conversation", "hello"),
WorkflowEvent.executor_completed("input-conversation", ["hello"]),
WorkflowEvent.executor_invoked("planner", "Go"),
WorkflowEvent.executor_completed("planner", [aer]),
WorkflowEvent.executor_invoked("end", []),
WorkflowEvent.executor_completed("end", None),
]
result = WorkflowRunResult(events, [])
data = _extract_agent_eval_data(result)
assert len(data) == 1
assert data[0]["executor_id"] == "planner"
def test_resolves_agent_from_workflow(self) -> None:
aer = _make_agent_exec_response("my-agent", "Done", ["Do it"])
events = [
WorkflowEvent.executor_invoked("my-agent", "Do it"),
WorkflowEvent.executor_completed("my-agent", [aer]),
]
result = WorkflowRunResult(events, [])
# Build a mock workflow with AgentExecutor
from agent_framework import AgentExecutor
mock_agent = MagicMock()
mock_agent.default_options = {"tools": []}
mock_executor = MagicMock(spec=AgentExecutor)
mock_executor.agent = mock_agent
mock_workflow = MagicMock()
mock_workflow.executors = {"my-agent": mock_executor}
data = _extract_agent_eval_data(result, mock_workflow)
assert len(data) == 1
assert data[0]["agent"] is mock_agent
class TestExtractOverallQuery:
def test_extracts_string_query(self) -> None:
events = [WorkflowEvent.executor_invoked("input", "Plan a trip")]
result = WorkflowRunResult(events, [])
assert _extract_overall_query(result) == "Plan a trip"
def test_extracts_message_query(self) -> None:
msgs = [Message("user", ["What's the weather?"])]
events = [WorkflowEvent.executor_invoked("input", msgs)]
result = WorkflowRunResult(events, [])
assert "What's the weather?" in (_extract_overall_query(result) or "")
def test_returns_none_for_empty(self) -> None:
result = WorkflowRunResult([], [])
assert _extract_overall_query(result) is None
# ---------------------------------------------------------------------------
# evaluate_workflow (core function, uses FoundryEvals as evaluator)
# ---------------------------------------------------------------------------
class TestEvaluateWorkflow:
def _mock_oai_client(self, eval_id: str = "eval_wf", run_id: str = "run_wf") -> MagicMock:
mock_oai = MagicMock()
mock_eval = MagicMock()
mock_eval.id = eval_id
mock_oai.evals.create.return_value = mock_eval
mock_run = MagicMock()
mock_run.id = run_id
mock_oai.evals.runs.create.return_value = mock_run
mock_completed = MagicMock()
mock_completed.status = "completed"
mock_completed.result_counts = {"passed": 1, "failed": 0}
mock_completed.report_url = "https://portal.azure.com/eval"
mock_completed.per_testing_criteria_results = None
mock_oai.evals.runs.retrieve.return_value = mock_completed
return mock_oai
@pytest.mark.asyncio
async def test_post_hoc_with_workflow_result(self) -> None:
"""Evaluate a workflow result that was already produced."""
mock_oai = self._mock_oai_client()
aer1 = _make_agent_exec_response("writer", "Draft written", ["Write about Paris"])
aer2 = _make_agent_exec_response("reviewer", "Looks good!", ["Review: Draft written"])
final_output = [Message("assistant", ["Final reviewed output"])]
events = [
WorkflowEvent.executor_invoked("input-conversation", "Write about Paris"),
WorkflowEvent.executor_completed("input-conversation", None),
WorkflowEvent.executor_invoked("writer", "Write about Paris"),
WorkflowEvent.executor_completed("writer", [aer1]),
WorkflowEvent.executor_invoked("reviewer", [aer1]),
WorkflowEvent.executor_completed("reviewer", [aer2]),
WorkflowEvent.output("end", final_output),
]
wf_result = WorkflowRunResult(events, [])
mock_workflow = MagicMock()
mock_workflow.executors = {}
results = await evaluate_workflow(
workflow=mock_workflow,
workflow_result=wf_result,
evaluators=FoundryEvals(openai_client=mock_oai, model_deployment="gpt-4o"),
include_overall=False,
)
assert results[0].status == "completed"
assert "writer" in results[0].sub_results
assert "reviewer" in results[0].sub_results
assert len(results[0].sub_results) == 2
@pytest.mark.asyncio
async def test_with_queries_runs_workflow(self) -> None:
"""Passing queries= runs the workflow and evaluates."""
mock_oai = self._mock_oai_client()
aer = _make_agent_exec_response("agent", "Response", ["Query"])
final_output = [Message("assistant", ["Final"])]
events = [
WorkflowEvent.executor_invoked("agent", "Test query"),
WorkflowEvent.executor_completed("agent", [aer]),
WorkflowEvent.output("end", final_output),
]
wf_result = WorkflowRunResult(events, [])
mock_workflow = MagicMock()
mock_workflow.executors = {}
mock_workflow.run = AsyncMock(return_value=wf_result)
results = await evaluate_workflow(
workflow=mock_workflow,
queries=["Test query"],
evaluators=FoundryEvals(openai_client=mock_oai, model_deployment="gpt-4o"),
include_overall=False,
)
mock_workflow.run.assert_called_once_with("Test query")
assert "agent" in results[0].sub_results
@pytest.mark.asyncio
async def test_overall_plus_per_agent(self) -> None:
"""Both overall and per-agent evals run by default."""
mock_oai = self._mock_oai_client()
aer = _make_agent_exec_response("planner", "Plan done", ["Plan trip"])
final_output = [Message("assistant", ["Trip planned!"])]
events = [
WorkflowEvent.executor_invoked("input-conversation", "Plan trip"),
WorkflowEvent.executor_completed("input-conversation", None),
WorkflowEvent.executor_invoked("planner", "Plan trip"),
WorkflowEvent.executor_completed("planner", [aer]),
WorkflowEvent.output("end", final_output),
]
wf_result = WorkflowRunResult(events, [])
mock_workflow = MagicMock()
mock_workflow.executors = {}
results = await evaluate_workflow(
workflow=mock_workflow,
workflow_result=wf_result,
evaluators=FoundryEvals(openai_client=mock_oai, model_deployment="gpt-4o"),
)
# Should have per-agent sub_results AND overall
assert "planner" in results[0].sub_results
assert results[0].status == "completed"
# FoundryEvals.evaluate called twice: once for planner, once for overall
assert mock_oai.evals.create.call_count == 2
@pytest.mark.asyncio
async def test_no_result_or_queries_raises(self) -> None:
mock_oai = MagicMock()
mock_workflow = MagicMock()
with pytest.raises(ValueError, match="Provide either"):
await evaluate_workflow(
workflow=mock_workflow,
evaluators=FoundryEvals(openai_client=mock_oai, model_deployment="gpt-4o"),
)
@pytest.mark.asyncio
async def test_per_agent_only(self) -> None:
"""include_overall=False skips the overall eval."""
mock_oai = self._mock_oai_client()
aer = _make_agent_exec_response("agent-a", "Done", ["Do stuff"])
events = [
WorkflowEvent.executor_invoked("agent-a", "Do stuff"),
WorkflowEvent.executor_completed("agent-a", [aer]),
]
wf_result = WorkflowRunResult(events, [])
mock_workflow = MagicMock()
mock_workflow.executors = {}
results = await evaluate_workflow(
workflow=mock_workflow,
workflow_result=wf_result,
evaluators=FoundryEvals(openai_client=mock_oai, model_deployment="gpt-4o"),
include_overall=False,
)
assert "agent-a" in results[0].sub_results
# Only one eval call (per-agent), no overall
assert mock_oai.evals.create.call_count == 1
@pytest.mark.asyncio
async def test_overall_eval_excludes_tool_evaluators(self) -> None:
"""Tool evaluators should not be passed to the overall workflow eval."""
mock_oai = self._mock_oai_client()
aer = _make_agent_exec_response("researcher", "Weather is sunny", ["What's the weather?"])
events = [
WorkflowEvent.executor_invoked("input-conversation", "What's the weather?"),
WorkflowEvent.executor_completed("input-conversation", None),
WorkflowEvent.executor_invoked("researcher", "What's the weather?"),
WorkflowEvent.executor_completed("researcher", [aer]),
WorkflowEvent.output("end", [Message("assistant", ["Weather is sunny"])]),
]
wf_result = WorkflowRunResult(events, [])
mock_workflow = MagicMock()
mock_workflow.executors = {}
fe = FoundryEvals(
openai_client=mock_oai,
model_deployment="gpt-4o",
evaluators=[FoundryEvals.RELEVANCE, FoundryEvals.TOOL_CALL_ACCURACY],
)
await evaluate_workflow(
workflow=mock_workflow,
workflow_result=wf_result,
evaluators=fe,
)
# Should have 2 evals: one per-agent, one overall
assert mock_oai.evals.create.call_count == 2
# Check the overall eval's testing_criteria doesn't include tool_call_accuracy
overall_call = mock_oai.evals.create.call_args_list[-1]
overall_criteria = overall_call.kwargs["testing_criteria"]
evaluator_names = [c["evaluator_name"] for c in overall_criteria]
assert "builtin.tool_call_accuracy" not in evaluator_names
assert "builtin.relevance" in evaluator_names
@pytest.mark.asyncio
async def test_per_agent_excludes_tool_evaluators_when_no_tools(self) -> None:
"""Sub-agents without tools should not get tool evaluators."""
mock_oai = self._mock_oai_client()
# researcher has tools, planner does not
aer1 = _make_agent_exec_response("researcher", "Weather is sunny", ["Check weather"])
aer2 = _make_agent_exec_response("planner", "Trip planned", ["Plan based on: sunny"])
events = [
WorkflowEvent.executor_invoked("researcher", "Check weather"),
WorkflowEvent.executor_completed("researcher", [aer1]),
WorkflowEvent.executor_invoked("planner", "Plan based on: sunny"),
WorkflowEvent.executor_completed("planner", [aer2]),
]
wf_result = WorkflowRunResult(events, [])
from agent_framework import AgentExecutor
# researcher has tools
mock_researcher = MagicMock()
mock_researcher.default_options = {
"tools": [
FunctionTool(name="get_weather", description="Get weather", func=lambda: None),
]
}
mock_researcher_executor = MagicMock(spec=AgentExecutor)
mock_researcher_executor.agent = mock_researcher
# planner has NO tools
mock_planner = MagicMock()
mock_planner.default_options = {"tools": []}
mock_planner_executor = MagicMock(spec=AgentExecutor)
mock_planner_executor.agent = mock_planner
mock_workflow = MagicMock()
mock_workflow.executors = {
"researcher": mock_researcher_executor,
"planner": mock_planner_executor,
}
fe = FoundryEvals(
openai_client=mock_oai,
model_deployment="gpt-4o",
evaluators=[FoundryEvals.RELEVANCE, FoundryEvals.TOOL_CALL_ACCURACY],
)
await evaluate_workflow(
workflow=mock_workflow,
workflow_result=wf_result,
evaluators=fe,
include_overall=False,
)
# Two sub-agent evals
assert mock_oai.evals.create.call_count == 2
# Find which call is for researcher vs planner by eval name
for call in mock_oai.evals.create.call_args_list:
criteria = call.kwargs["testing_criteria"]
eval_names = [c["evaluator_name"] for c in criteria]
name = call.kwargs["name"]
if "planner" in name:
assert "builtin.tool_call_accuracy" not in eval_names, (
"planner has no tools — should not get tool_call_accuracy"
)
elif "researcher" in name:
assert "builtin.tool_call_accuracy" in eval_names, (
"researcher has tools — should get tool_call_accuracy"
)
# ---------------------------------------------------------------------------
# EvalItemResult and EvalScoreResult
# ---------------------------------------------------------------------------
class TestEvalItemResult:
def test_status_properties(self) -> None:
from agent_framework._evaluation import EvalItemResult
passed = EvalItemResult(item_id="1", status="pass")
assert passed.is_passed
assert not passed.is_failed
assert not passed.is_error
failed = EvalItemResult(item_id="2", status="fail")
assert not failed.is_passed
assert failed.is_failed
assert not failed.is_error
errored = EvalItemResult(item_id="3", status="error")
assert not errored.is_passed
assert not errored.is_failed
assert errored.is_error
errored2 = EvalItemResult(item_id="4", status="errored")
assert errored2.is_error
def test_with_scores(self) -> None:
from agent_framework._evaluation import EvalItemResult, EvalScoreResult
scores = [
EvalScoreResult(name="relevance", score=0.9, passed=True),
EvalScoreResult(name="coherence", score=0.3, passed=False),
]
item = EvalItemResult(item_id="1", status="fail", scores=scores)
assert len(item.scores) == 2
assert item.scores[0].passed is True
assert item.scores[1].passed is False
def test_with_error(self) -> None:
from agent_framework._evaluation import EvalItemResult
item = EvalItemResult(
item_id="1",
status="error",
error_code="QueryExtractionError",
error_message="Query list cannot be empty",
)
assert item.is_error
assert item.error_code == "QueryExtractionError"
def test_with_token_usage(self) -> None:
from agent_framework._evaluation import EvalItemResult
item = EvalItemResult(
item_id="1",
status="pass",
token_usage={"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
)
assert item.token_usage is not None
assert item.token_usage["total_tokens"] == 150
class TestEvalResultsWithItems:
def test_item_status_properties(self) -> None:
from agent_framework._evaluation import EvalItemResult
results = EvalResults(
provider="test",
eval_id="e1",
run_id="r1",
status="completed",
result_counts={"passed": 2, "failed": 1, "errored": 1},
items=[
EvalItemResult(item_id="1", status="pass"),
EvalItemResult(item_id="2", status="pass"),
EvalItemResult(item_id="3", status="fail"),
EvalItemResult(item_id="4", status="error", error_code="QueryExtractionError"),
],
)
assert sum(1 for i in results.items if i.is_passed) == 2
assert sum(1 for i in results.items if i.is_failed) == 1
assert sum(1 for i in results.items if i.is_error) == 1
def test_assert_passed_includes_errored_items(self) -> None:
from agent_framework._evaluation import EvalItemResult
results = EvalResults(
provider="test",
eval_id="e1",
run_id="r1",
status="completed",
result_counts={"passed": 0, "failed": 0, "errored": 2},
items=[
EvalItemResult(item_id="i1", status="error", error_code="QueryExtractionError"),
EvalItemResult(item_id="i2", status="error", error_code="TimeoutError"),
],
)
with pytest.raises(AssertionError, match="Errored items: i1: QueryExtractionError"):
results.assert_passed()
# ---------------------------------------------------------------------------
# _fetch_output_items
# ---------------------------------------------------------------------------
class TestFetchOutputItems:
@pytest.mark.asyncio
async def test_fetches_and_converts_output_items(self) -> None:
from agent_framework_azure_ai._foundry_evals import _fetch_output_items
# Build mock output items matching the OpenAI SDK schema
mock_result = MagicMock()
mock_result.name = "relevance"
mock_result.score = 0.85
mock_result.passed = True
mock_result.sample = None
mock_usage = MagicMock()
mock_usage.prompt_tokens = 100
mock_usage.completion_tokens = 50
mock_usage.total_tokens = 150
mock_usage.cached_tokens = 0
mock_input = MagicMock()
mock_input.role = "user"
mock_input.content = "What is the weather?"
mock_output = MagicMock()
mock_output.role = "assistant"
mock_output.content = "It is sunny."
mock_error = MagicMock()
mock_error.code = ""
mock_error.message = ""
mock_sample = MagicMock()
mock_sample.error = mock_error
mock_sample.usage = mock_usage
mock_sample.input = [mock_input]
mock_sample.output = [mock_output]
mock_oi = MagicMock()
mock_oi.id = "oi_abc123"
mock_oi.status = "pass"
mock_oi.results = [mock_result]
mock_oi.sample = mock_sample
mock_oi.datasource_item = {"resp_id": "resp_xyz"}
mock_client = MagicMock()
mock_page = MagicMock()
mock_page.__iter__ = MagicMock(return_value=iter([mock_oi]))
mock_client.evals.runs.output_items.list = MagicMock(return_value=mock_page)
items = await _fetch_output_items(mock_client, "eval_1", "run_1")
assert len(items) == 1
item = items[0]
assert item.item_id == "oi_abc123"
assert item.status == "pass"
assert item.is_passed
assert len(item.scores) == 1
assert item.scores[0].name == "relevance"
assert item.scores[0].score == 0.85
assert item.scores[0].passed is True
assert item.response_id == "resp_xyz"
assert item.input_text == "What is the weather?"
assert item.output_text == "It is sunny."
assert item.token_usage is not None
assert item.token_usage["total_tokens"] == 150
assert item.error_code is None
@pytest.mark.asyncio
async def test_handles_errored_item(self) -> None:
from agent_framework_azure_ai._foundry_evals import _fetch_output_items
mock_error = MagicMock()
mock_error.code = "QueryExtractionError"
mock_error.message = "Query list cannot be empty"
mock_sample = MagicMock()
mock_sample.error = mock_error
mock_sample.usage = None
mock_sample.input = []
mock_sample.output = []
mock_oi = MagicMock()
mock_oi.id = "oi_err1"
mock_oi.status = "error"
mock_oi.results = []
mock_oi.sample = mock_sample
mock_oi.datasource_item = {}
mock_client = MagicMock()
mock_page = MagicMock()
mock_page.__iter__ = MagicMock(return_value=iter([mock_oi]))
mock_client.evals.runs.output_items.list = MagicMock(return_value=mock_page)
items = await _fetch_output_items(mock_client, "eval_1", "run_1")
assert len(items) == 1
item = items[0]
assert item.is_error
assert item.error_code == "QueryExtractionError"
assert item.error_message == "Query list cannot be empty"
assert len(item.scores) == 0
@pytest.mark.asyncio
async def test_handles_api_failure_gracefully(self) -> None:
from agent_framework_azure_ai._foundry_evals import _fetch_output_items
mock_client = MagicMock()
mock_client.evals.runs.output_items.list = MagicMock(side_effect=Exception("API error"))
items = await _fetch_output_items(mock_client, "eval_1", "run_1")
assert items == []