Files
agent-framework/docs/decisions/0023-foundry-evals-integration.md
Ben Thomas 35adfdb318 Python: Foundry Evals integration for Python (#4750)
* 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>

* fix: resolve mypy redundant-cast errors while keeping pyright happy

Use cast(list[Any], x) with type: ignore[redundant-cast] comments to
satisfy both mypy (which considers casting Any redundant) and pyright
strict mode (which needs explicit casts to narrow Unknown types).

Also fix evaluator decorator check_name type annotation to be
explicitly str, resolving mypy str|Any|None mismatch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: CI failures — pyupgrade, evaluator overloads, sample API, reset attr

- Apply pyupgrade: Sequence from collections.abc, remove forward-ref quotes
- Add @overload signatures to evaluator() for proper @evaluator usage
- Fix evaluate_workflow sample to use WorkflowBuilder(start_executor=) API
- Fix _workflow.py executor.reset() to use getattr pattern for pyright
- Remove unused EvalResults forward-ref string in default_factory lambda

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: skip gRPC-dependent observability test

The test_configure_otel_providers_with_env_file_and_vs_code_port test
triggers gRPC OTLP exporter creation, but the grpc dependency is
optional and not installed by default. Add skipif decorator matching
the pattern used by all other gRPC exporter tests in the same file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: add nosec B101 for bandit assert check

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* style: align eval samples with repo conventions

- Move module docstrings before imports (after copyright header)
- Add -> None return type to all main() and helper functions
- Fix line-too-long in multiturn sample conversation data
- Add Workflow import for typed return in all_patterns_sample

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review feedback: async fixes, sample bugs, deprecation warnings

- Simplify _ensure_async_result to direct await (async-only clients)
- Replace get_event_loop() with get_running_loop()
- Narrow _fetch_output_items exception handling to specific types
- Add warning log when _filter_tool_evaluators falls back to defaults
- Add DeprecationWarning to options alias in Agent.__init__
- Add DeprecationWarning to evaluate_response()
- Rename raw key to _raw_arguments in convert_message fallback
- Fix evaluate_agent_sample.py: replace evals.select() with FoundryEvals()
- Fix evaluate_multiturn_sample.py: use Message/Content/FunctionTool types
- Fix evaluate_workflow_sample.py: replace evals.select() with FoundryEvals()
- Update test mocks to use AsyncMock for awaited API calls

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add test coverage for review feedback items

- Add num_repetitions=2 positive test verifying 2×items and 4 agent calls
- Add _poll_eval_run tests: timeout, failed, and canceled paths
- Add evaluate_traces tests: validation error, response_ids path, trace_ids path
- Add evaluate_foundry_target happy-path test with target/query verification

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix ruff ISC004 lint error and apply formatter

- Wrap implicit string concatenation in parens in evaluate_multiturn_sample.py
- Apply ruff formatter to 6 other files with minor formatting drift

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove core type changes (extracted to fix/workflow-stale-session branch)

Reverts changes to _agents.py, _agent_executor.py, and _workflow.py
back to upstream/main. These fixes are now in a separate PR.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review round 2: bugs, tests, and architecture

Code fixes:
- Fix _normalize_queries inverted condition (single query now replicates
  to match expected_count)
- Fix substring match bug: 'end' in 'backend' matched; use exact set
  lookup for executor ID filtering
- Fix used_available_tools sample: tool_definitions→tools param, use
  FunctionTool attribute access instead of dict .get()
- Add None-check in _resolve_openai_client for misconfigured project
- Add Returns section to evaluate_workflow docstring
- Cache inspect.signature in @evaluator wrapper (avoid per-item reflection)

Architecture:
- Extract _evaluate_via_responses as module-level helper; evaluate_traces
  now calls it directly instead of creating a FoundryEvals instance
- Move Foundry-specific typed-content conversion out of core to_eval_data;
  core now returns plain role/content dicts, FoundryEvals applies
  AgentEvalConverter in _evaluate_via_dataset

Tests:
- evaluate_response() deprecation warning emission and delegation
- num_repetitions > 1 with expected_output and expected_tool_calls
- Mock output_items.list in test_evaluate_calls_evals_api
- Update to_eval_data assertions for plain-dict format
- Unknown param error now raised at @evaluator decoration time

Skipped (separate PR): executor reset loop, xfail removal, options alias

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix CI: revert test_full_conversation, fix pyright errors

- Revert test_full_conversation.py to upstream/main (the session
  preservation test was incorrectly changed to assert clearing)
- Fix pyright reportUnnecessaryComparison on get_openai_client() None
  check by adding ignore comment
- Fix pyright reportPrivateUsage: add public EvalItem.split_messages()
  method and use it in FoundryEvals._evaluate_via_dataset instead of
  accessing private _split_conversation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review round 3: reliability, test gaps, cleanup

- Add try/except guard for non-numeric score in _coerce_result
- Add poll_interval minimum bound (0.1s) to prevent tight loops
- Add runtime async client check in _resolve_openai_client
- Remove _ensure_async_result wrapper (10 call sites → direct await)
- Better error message when queries provided without agent
- Import-time asserts for evaluator set consistency
- Remove 28 redundant @pytest.mark.asyncio decorators
- Add doc note about _raw_arguments sensitive data
- Tests: tool_called_check mode=any, _normalize_queries branches,
  _extract_result_counts paths, _extract_per_evaluator, bare check
  via evaluate_agent, output_items assertion, modulo wrapping,
  async client check, queries-without-agent error

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix CI: ruff S101 assert, pyright and mypy arg-type errors

- Replace module-level assert with if/raise for evaluator set
  consistency checks (ruff S101 disallows bare assert)
- Add type: ignore[arg-type] and pyright: ignore[reportArgumentType]
  on OpenAI SDK evals API calls that pass dicts where typed params
  are expected (SDK accepts dicts at runtime)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review round 4: bugs, reliability, test fixes

- Fix all_passed ignoring parent result_counts when sub_results present
- Fix _extract_tool_calls: parse string arguments via json.loads before
  falling back to None (real LLM responses use string arguments)
- Sanitize _raw_arguments to '[unparseable]' to avoid leaking sensitive
  tool-call data to external evaluation services
- Add NOTE comment on to_eval_data message serialization dropping
  non-text content (tool calls, results)
- Eliminate double conversation split in _evaluate_via_dataset: build
  JSONL dicts directly from split_messages + AgentEvalConverter
- Raise poll_interval floor from 0.1s to 1.0s to prevent rate-limit
  exhaustion
- Fix MagicMock(name=...) bug in test: sets display name not .name attr
- Fix mock_output_item.sample: use MagicMock object instead of dict so
  _fetch_output_items exercises error/usage/input/output extraction

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review round 5: reliability, docs, test coverage

Code fixes:
- Move import-time RuntimeError checks to unit tests (avoids breaking
  imports for all users on developer set-drift mistake)
- _filter_tool_evaluators now raises ValueError when all evaluators
  require tools but no items have tools (was silently substituting)
- Add poll_interval upper bound (60s) to prevent single-iteration sleep
- Log exc_info=True in _fetch_output_items for debugging API changes
- Fix evaluate() docstring: remove claim about Responses API optimization
- Validate target dict has 'type' key in evaluate_foundry_target
- Document to_eval_data() limitation: non-text content is omitted

Tests:
- TestEvaluatorSetConsistency: verify _AGENT/_TOOL subsets of _BUILTIN
- TestEvaluateTracesAgentId: agent_id-only path with lookback_hours
- TestFilterToolEvaluatorsRaises: ValueError on all-tool no-items
- TestEvaluateFoundryTargetValidation: target without 'type' key
- Assert items==[] on failed/canceled poll results
- Mock output_items.list in response_ids test for full flow
- TestAllPassedSubResults: result_counts=None + sub_results delegation
  and parent failures override sub_results
- TestBuildOverallItemEmpty: empty workflow outputs returns None

Skipped r5-07 (_raw_arguments length hint): marginal debugging value,
could leak content size information.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix error message: evaluate_responses() → evaluate_traces(response_ids=...)

The referenced function doesn't exist; the correct API is
evaluate_traces(response_ids=...) from the azure-ai package.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove dead to_eval_data() method, fix docstring claims

- Remove to_eval_data() from EvalItem (dead code after r4-05 JSONL refactor)
- Migrate 15 tests from to_eval_data() to split_messages()
- Update sample to use split_messages() + Message properties
- Remove unimplemented Responses API optimization docstring claim
- Update split_messages() docstring to not reference removed method

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Reduce default eval timeout from 600s to 180s (3 minutes)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove dead _evaluate_via_responses method from FoundryEvals

The method was never called — evaluate() uses _evaluate_via_dataset,
and evaluate_traces() calls _evaluate_via_responses_impl directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Revert unrelated formatting changes to get-started samples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix pyright: remove phantom FoundryMemoryProvider import, apply ruff format

- Remove import of non-existent _foundry_memory_provider module
  (incorrectly kept during rebase conflict resolution)
- Apply ruff formatter to test_local_eval.py and get-started samples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix eval samples: use FoundryChatClient for Agent()

The upstream provider-leading client refactor (#4818) made client=
a required parameter on Agent(). Update the three getting-started
eval samples to use FoundryChatClient with FOUNDRY_PROJECT_ENDPOINT,
matching the standard pattern from 01-get-started samples.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Simplify self-reflection sample using FoundryEvals

Replace ~80 lines of manual OpenAI evals API code (create_eval,
run_eval, manual polling, raw JSONL params) with FoundryEvals:

- evaluate_groundedness() uses FoundryEvals.evaluate() with EvalItem
- Remove create_openai_client(), create_eval(), run_eval() functions
- Remove openai SDK type imports (DataSourceConfigCustom, etc.)
- run_self_reflection_batch creates FoundryEvals instance once,
  reuses it for all iterations across all prompts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update eval samples to FoundryChatClient and FOUNDRY_PROJECT_ENDPOINT

- Migrate all foundry_evals samples from AzureOpenAIResponsesClient to FoundryChatClient
- Update env var from AZURE_AI_PROJECT_ENDPOINT to FOUNDRY_PROJECT_ENDPOINT
- Use AzureCliCredential consistently across all samples
- Fix README.md: correct function names (evaluate_dataset -> FoundryEvals.evaluate, evaluate_responses -> evaluate_traces)
- Update self_reflection .env.example and README.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix lint errors in eval samples (E501, ASYNC240, formatting)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove evaluate_all_patterns_sample.py (redundant with focused samples)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix async credential mismatch: use azure.identity.aio for async AIProjectClient

AIProjectClient from azure.ai.projects.aio requires an async credential.
Switch all foundry_evals samples from azure.identity.AzureCliCredential
to azure.identity.aio.AzureCliCredential. Also pass project_client to
FoundryChatClient instead of duplicating endpoint+credential.

Close credential in self_reflection sample to avoid resource leak.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Revert test_observability.py to upstream/main (not our test)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address moonbox3 review: sphinx docstrings, pagination, isinstance check

- Convert all Example:: / Typical usage:: code blocks to .. code-block:: python
  format matching codebase convention (both _evaluation.py and _foundry_evals.py)
- Add async pagination in _fetch_output_items via async for (handles large result sets)
- Replace hasattr(__aenter__) with isinstance(client, AsyncOpenAI) in _resolve_openai_client
- Move AsyncOpenAI import from TYPE_CHECKING to runtime (needed for isinstance)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix test failures and address remaining moonbox3 review comments

- Fix tests: use MagicMock(spec=AsyncOpenAI) for project_client mocks
  (isinstance check now requires proper type, not duck-typing)
- Fix tests: replace mock_page.__iter__ with _AsyncPage helper for async for
- Fix evaluate_response: auto-extract queries from response messages when
  query is not provided (previously always raised ValueError)
- Add debug logging when skipping internal _-prefixed executor IDs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address Tao's PR review comments on Foundry Evals

- T1: Add comment explaining builtin.* pass-through in _resolve_evaluator
- T2: Add comment referencing OpenAI evals API for testing_criteria dict
- T3: Document Mustache-style {{item.*}} template placeholders
- T4: Document poll loop 60s sleep upper bound rationale
- T5: Narrow run type to RunRetrieveResponse, use typed field access
  instead of vars()/getattr dance in _extract_result_counts and
  _extract_per_evaluator; use run.error and run.report_url directly
- T6: Clarify openai_client docstring re: Azure Foundry endpoint
- T8: Remove misleading empty expected_tool_calls from sample
- Update tests to match real SDK PerTestingCriteriaResult shape

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove unnecessary Any union from run type annotations

RunRetrieveResponse is the correct type — no backward compat needed
for a brand new feature.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Accept FoundryChatClient instead of raw AsyncOpenAI

FoundryEvals now takes client: FoundryChatClient as its primary
parameter instead of openai_client: AsyncOpenAI.  The builtin.*
evaluators require a Foundry endpoint, so the type should reflect that.

- FoundryEvals.__init__: client: FoundryChatClient replaces openai_client
- evaluate_traces / evaluate_foundry_target: same change
- _resolve_openai_client: extracts .client from FoundryChatClient
- project_client fallback retained for standalone functions
- All samples updated to construct FoundryChatClient and pass as client=
- Tests updated (openai_client= → client=)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove implicit 60s upper bound on poll interval

If a developer sets a higher poll_interval, respect it. Only clamp
to remaining time and enforce a 1s minimum for rate-limit protection.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove 1s floor on poll interval — let the developer control it

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update python/samples/05-end-to-end/evaluation/foundry_evals/.env.example

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>

* Update python/samples/02-agents/evaluation/evaluate_agent.py

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>

* Address eavanvalkenburg review (round 2) on Python eval PR

- Rename model_deployment -> model across FoundryEvals and all samples
- Make model param optional, resolves from client.model
- Convert EvalResults from dataclass to regular class
- Remove deprecated evaluate_response() function
- Refactor splitters: BUILT_IN_SPLITTERS dict + standalone functions
- Change per_turn_items from classmethod to staticmethod
- Simplify EvalCheck type alias to use Awaitable[CheckResult]
- Remove errored property from EvalResults
- Remove default value from Evaluator protocol eval_name
- Rename assert_passed -> raise_for_status, add EvalNotPassedError
- Type agent param as SupportsAgentRun | None
- Fix Arguments docstring
- Update __init__.py exports
- Update all tests and samples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Move FoundryEvals to foundry package, split tool eval sample

- Move _foundry_evals.py from azure-ai to foundry package
- Move test_foundry_evals.py to foundry/tests/
- Update lazy re-exports in agent_framework.foundry namespace
- Update .pyi type stubs
- All samples now import from agent_framework.foundry
- Split tool-call evaluation into evaluate_tool_calls_sample.py
- Fix all_passed to check errored count from result_counts
- Fix raise_for_status to include errored item details

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Auto-create FoundryChatClient from env vars when no client provided

FoundryEvals() now works zero-config when FOUNDRY_PROJECT_ENDPOINT and
FOUNDRY_MODEL environment variables are set. Auto-creates a FoundryChatClient
under the hood, matching the established env var pattern.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix pyright errors: remove dead _normalize_queries, suppress EvalAPIError check

- Remove unused _normalize_queries function and its tests
- Add pyright ignore for EvalAPIError None check (defensive guard)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Support multimodal image content in eval pipeline

Add image (data/uri) content handling to AgentEvalConverter.convert_message()
so that Content.from_data() and Content.from_uri() image payloads are
preserved as input_image parts in the Foundry evaluator format.

- Handle Content type='data' and type='uri' → emit input_image parts
- Add 6 unit tests for image content through convert_message/convert_messages
- Add integration test verifying images flow through EvalItem → JSONL path
- Add evaluate_multimodal.py sample demonstrating local image eval

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address remaining review comments

- Fix project_client docstring to say async-only (not sync/async)
- Add builtin evaluator name validation warning in _resolve_evaluator
- Replace getattr with typed attribute access in _poll_eval_run,
  _extract_result_counts, _extract_per_evaluator, _fetch_output_items
- Remove cast import from _foundry_evals (no longer needed)
- Tighten _coerce_result: honour explicit 'passed' when both 'score'
  and 'passed' are present; remove performative cast
- Fix self_reflection sample: add env file existence check
- Fix traces sample: correct Pattern 2 section label
- Update all Foundry eval samples to FoundryChatClient + FOUNDRY_MODEL
  (remove AIProjectClient + AZURE_AI_MODEL_DEPLOYMENT_NAME pattern)
- Add eval_name and OpenAI client docs to FoundryEvals docstring
- Update test mocks to match typed SDK objects (_MockResultCounts)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix ruff lint errors (E501, SIM108, SIM102)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix pyright errors: type-narrow dict to dict[str, Any], add ignore comments

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Replace ConversationSplitter type alias with Protocol

ConversationSplitter is now a runtime-checkable Protocol with a named
'conversation' parameter, making the expected signature self-documenting.

ConversationSplit enum members gain a __call__ method so they satisfy
the protocol directly -- ConversationSplit.LAST_TURN(conversation) works.

This simplifies _split_conversation from an isinstance dispatch to a
single split(conversation) call.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Standardize on AZURE_AI_MODEL_DEPLOYMENT_NAME and fix Unicode in samples

- Replace FOUNDRY_MODEL with AZURE_AI_MODEL_DEPLOYMENT_NAME in all
  eval samples to match repo convention
- Replace Unicode symbols with ASCII equivalents in all eval sample
  print statements to avoid cp1252 encoding errors on Windows

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update python/samples/03-workflows/evaluation/evaluate_workflow.py

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>

* Rename ADR 0020 to 0023 (foundry evals integration)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-03-31 15:53:06 +00:00

34 KiB
Raw Permalink Blame History

status, contact, date, deciders, consulted, informed
status contact date deciders consulted informed
accepted bentho 2026-02-27 bentho, markwallace-microsoft, westey-m Pratyush Mishra, Shivam Shrivastava, Manni Arora (Centrica eval scenario) Agent Framework team, Foundry Evals team

Agent Evaluation Architecture with Azure AI Foundry Integration

Context and Problem Statement

Azure AI Foundry provides a rich evaluation service for AI agents — built-in evaluators for agent behavior (task adherence, intent resolution), tool usage (tool call accuracy, tool selection), quality (coherence, fluency, relevance), and safety (violence, self-harm, prohibited actions). Results are viewable in the Foundry portal with dashboards and comparison views.

However, using Foundry Evals with an agent-framework agent today requires significant manual effort. Developers must:

  1. Transform agent-framework's Message/Content types into the OpenAI-style agent message schema that Foundry evaluators expect
  2. Map tool definitions from agent-framework's FunctionTool format to evaluator-compatible schemas
  3. Manually wire up the correct Foundry data source type (azure_ai_traces, jsonl, azure_ai_target_completions, etc.) depending on their scenario
  4. Handle App Insights trace ID queries, response ID collection, and eval polling

Additionally, evaluation is a concern that extends beyond any single provider. Developers may want to use local evaluators (LLM-as-judge, regex, keyword matching), third-party evaluation libraries, or multiple providers in combination. The architecture must support this without creating a Foundry-specific lock-in at the API level.

Functional Requirements for Agent Evaluation

  • Single agents and workflows. Evaluate both individual agent responses and multi-agent workflow results, with per-agent breakdown to pinpoint underperformance.
  • One-shot and multi-turn conversations. Capture full conversation trajectories — including tool calls and results — not just final query/response pairs.
  • Conversation factoring. Support splitting conversations into query/response in multiple ways (last turn, full trajectory, per-turn) because different factorings measure different things.
  • Multiple providers, mix and match. Run Foundry LLM-as-judge evaluators alongside fast local checks and custom evaluators on the same data, without restructuring code.
  • Third-party extensibility. Any evaluation library can participate by implementing the Evaluator protocol (Python) or IAgentEvaluator interface (.NET). No predetermined list of supported libraries — the protocol is intentionally simple (evaluate(items) → results) so that wrappers for libraries like DeepEval, RAGAS, or Promptfoo are straightforward to write.
  • Bring your own evaluator. Creating a custom evaluator should be as simple as writing a function.
  • Evaluate without re-running. Evaluate existing responses from logs or previous runs without invoking the agent again.

Decision Drivers

  • Zero-friction evaluation: Developers should go from "I have an agent" to "I have eval results" with minimal code.
  • Provider-agnostic API: Core evaluation capabilities must not be tied to any specific provider. Provider configuration should be separate from the evaluation call.
  • Lowest concept count: Introduce the fewest possible new types, abstractions, and APIs for developers to learn.
  • Leverage existing knowledge: The framework already knows which agents exist, what tools they have, and what conversations occurred. Evals should use this automatically rather than requiring the developer to re-specify it.
  • Foundry-native results: When using Foundry, results should be viewable in the Foundry portal with dashboards and comparison views.
  • Progressive disclosure: Simple scenarios should be near-zero code. Advanced scenarios should build on the same primitives.
  • Cross-language parity: Design must be implementable in both Python and .NET.

Considered Options

  1. Provider-specific functions — Build Foundry-specific helper functions (evaluate_agent(), etc.) directly in the Azure package. All eval functions take Foundry connection parameters.
  2. Evaluator protocol with shared orchestration — Define a provider-agnostic Evaluator protocol in the base agent library (agent_framework in Python, Microsoft.Agents.AI in .NET). Orchestration functions live alongside it. Providers implement the protocol.
  3. Full eval framework — Build comprehensive eval infrastructure including custom evaluator definitions, scoring profiles, and reporting inside agent-framework.

Decision Outcome

Proposed option: "Evaluator protocol with shared orchestration", because it delivers the low-friction developer experience, supports multiple providers without API changes, and keeps the concept count low.

Usage Examples

Evaluate an agent

The agent is invoked once per query by default. For statistically meaningful evaluation, provide multiple diverse queries. For measuring consistency (does the same query produce reliable results?), use num_repetitions to run each query N times independently:

Python:

evals = FoundryEvals(
    project_client=client,
    model_deployment="gpt-4o",
    evaluators=[FoundryEvals.RELEVANCE, FoundryEvals.COHERENCE],
)

results = await evaluate_agent(
    agent=my_agent,
    queries=[
        "What's the weather in Seattle?",
        "Plan a weekend trip to Portland",
        "What restaurants are near Pike Place?",
    ],
    evaluators=evals,
)
for r in results:
    r.assert_passed()

C#:

var evals = new FoundryEvals(chatConfiguration, FoundryEvals.Relevance, FoundryEvals.Coherence);

AgentEvaluationResults results = await agent.EvaluateAsync(
    new[] {
        "What's the weather in Seattle?",
        "Plan a weekend trip to Portland",
        "What restaurants are near Pike Place?",
    },
    evals);

results.AssertAllPassed();

evaluate_agent returns one EvalResults per evaluator. Each result contains per-item scores with the evaluated response for auditing:

# results[0] (FoundryEvals)
EvalResults(status="completed", passed=3, failed=0, total=3)
  items[0]: EvalItemResult(
    query="What's the weather in Seattle?",
    response="It's currently 72°F and sunny in Seattle.",
    scores={"relevance": 5, "coherence": 5})
  items[1]: EvalItemResult(
    query="Plan a weekend trip to Portland",
    response="Here's a 2-day Portland itinerary...",
    scores={"relevance": 4, "coherence": 5})
  items[2]: EvalItemResult(
    query="What restaurants are near Pike Place?",
    response="Top restaurants near Pike Place Market: ...",
    scores={"relevance": 5, "coherence": 4})

Measure consistency with repetitions

Run each query multiple times to detect non-deterministic behavior:

Python:

results = await evaluate_agent(
    agent=my_agent,
    queries=["What's the weather in Seattle?"],
    evaluators=evals,
    num_repetitions=3,  # each query runs 3 times independently
)
# results contain 3 items (1 query × 3 repetitions)

C#:

AgentEvaluationResults results = await agent.EvaluateAsync(
    new[] { "What's the weather in Seattle?" },
    evals,
    numRepetitions: 3);  // each query runs 3 times independently
// results contain 3 items (1 query × 3 repetitions)

Evaluate a response you already have

When you already have agent responses, pass them directly to skip re-running the agent. Each query is paired with its corresponding response:

Python:

queries = ["What's the weather?", "What's the capital of France?"]
responses = [await agent.run([Message("user", [q])]) for q in queries]

results = await evaluate_agent(
    responses=responses,
    evaluators=evals,
)

C#:

var queries = new[] { "What's the weather?" };
var responses = new List<AgentResponse>();
foreach (var q in queries)
    responses.Add(await agent.RunAsync(new[] { new ChatMessage(ChatRole.User, q) }));

AgentEvaluationResults results = await agent.EvaluateAsync(
    responses: responses,
    evals);

Each AgentResponse already contains the conversation (query + response), so the evaluator extracts query/response from the conversation. When you pass responses without queries, the conversation is the source of truth.

Evaluate with conversation split strategies

By default, evaluators see only the last turn (final user message → final assistant response). For multi-turn conversations, you can control how the conversation is factored for evaluation:

Python:

results = await evaluate_agent(
    agent=agent,
    queries=["Plan a 3-day trip to Paris"],
    evaluators=evals,
    conversation_split=ConversationSplit.FULL,      # evaluate entire trajectory
)

# Or per-turn: each user→assistant exchange scored independently
results = await evaluate_agent(
    agent=agent,
    queries=["Plan a 3-day trip to Paris"],
    evaluators=evals,
    conversation_split=ConversationSplit.PER_TURN,
)

C#:

// Full conversation as context
AgentEvaluationResults results = await agent.EvaluateAsync(
    new[] { "Plan a 3-day trip to Paris" },
    evals,
    splitter: ConversationSplitters.Full);

// Per-turn splitting
var items = EvalItem.PerTurnItems(conversation);  // one EvalItem per user turn
var results = await evals.EvaluateAsync(items);

With PER_TURN, a 3-turn conversation produces 3 scored items:

EvalResults(status="completed", passed=3, failed=0, total=3)
  items[0]: query="Plan a 3-day trip to Paris"    scores={"relevance": 5}
  items[1]: query="What about restaurants?"        scores={"relevance": 4}
  items[2]: query="Make it budget-friendly"        scores={"relevance": 5}

Evaluate a multi-agent workflow

Python:

result = await workflow.run("Plan a trip to Paris")
eval_results = await evaluate_workflow(
    workflow=workflow,
    workflow_result=result,
    evaluators=evals,
)

for r in eval_results:
    print(f"  overall: {r.passed}/{r.total}")
    for name, sub in r.sub_results.items():
        print(f"    {name}: {sub.passed}/{sub.total}")

C#:

WorkflowRunResult result = await workflow.RunAsync("Plan a trip to Paris");

IReadOnlyList<AgentEvaluationResults> evalResults = await result.EvaluateAsync(evals);

foreach (var r in evalResults)
{
    Console.WriteLine($"  overall: {r.Passed}/{r.Total}");
    foreach (var (name, sub) in r.SubResults)
        Console.WriteLine($"    {name}: {sub.Passed}/{sub.Total}");
}

Workflows return one result per evaluator, with sub-results per agent in the workflow:

EvalResults(status="completed", passed=2, failed=0, total=2)
  sub_results:
    "planner":  EvalResults(passed=1, total=1)
    "researcher": EvalResults(passed=1, total=1)

Mix multiple providers

Python:

@evaluator
def is_helpful(response: str) -> bool:
    return len(response.split()) > 10

foundry = FoundryEvals(
    project_client=client,
    model_deployment="gpt-4o",
    evaluators=[FoundryEvals.RELEVANCE, FoundryEvals.COHERENCE],
)

results = await evaluate_agent(
    agent=agent,
    queries=queries,
    evaluators=[is_helpful, keyword_check("weather"), foundry],
)

C#:

IReadOnlyList<AgentEvaluationResults> results = await agent.EvaluateAsync(
    queries,
    evaluators: new IAgentEvaluator[]
    {
        new LocalEvaluator(
            EvalChecks.KeywordCheck("weather"),
            FunctionEvaluator.Create("is_helpful", (string r) => r.Split(' ').Length > 10)),
        new FoundryEvals(chatConfiguration, FoundryEvals.Relevance, FoundryEvals.Coherence),
    });

Multiple evaluators return one result each — results[0] is the local evaluator, results[1] is Foundry.

Custom function evaluators

Python:

@evaluator
def mentions_city(response: str, expected_output: str) -> bool:
    return expected_output.lower() in response.lower()

@evaluator
def used_tools(conversation: list, tools: list) -> float:
    # ... scoring logic
    return score

local = LocalEvaluator(mentions_city, used_tools)

@evaluator uses parameter name injection — the function's parameter names determine what data it receives from the EvalItem. Supported names: query, response, expected, expected_tool_calls, conversation, tools, context. Any combination is valid.

C#:

var local = new LocalEvaluator(
    FunctionEvaluator.Create("mentions_city",
        (EvalItem item) => item.ExpectedOutput != null
            && item.Response.Contains(item.ExpectedOutput, StringComparison.OrdinalIgnoreCase)),
    FunctionEvaluator.Create("is_concise",
        (string response) => response.Split(' ').Length < 500));

What To Build

Core: Evaluator Protocol

A runtime-checkable protocol that any evaluation provider implements:

@runtime_checkable
class Evaluator(Protocol):
    name: str

    async def evaluate(
        self, items: Sequence[EvalItem], *, eval_name: str = "Agent Framework Eval"
    ) -> EvalResults: ...

The protocol is minimal — just name and evaluate().

Core: EvalItem

Provider-agnostic data format for items to evaluate:

@dataclass
class ExpectedToolCall:
    name: str                                    # Tool/function name
    arguments: dict[str, Any] | None = None      # None = don't check args

@dataclass
class EvalItem:
    conversation: list[Message]               # Single source of truth
    tools: list[FunctionTool] | None = None   # Agent's available tools
    context: str | None = None
    expected_output: str | None = None          # Ground-truth for comparison
    expected_tool_calls: list[ExpectedToolCall] | None = None
    split_strategy: ConversationSplitter | None = None

    query: str       # property — derived from conversation split
    response: str    # property — derived from conversation split

conversation is the single source of truth. query and response are derived properties — splitting the conversation at the last user message (default) and extracting text from each side. Changing the split_strategy consistently changes all derived values.

tools provides typed FunctionTool objects — including MCP tools, which are automatically extracted after agent runs.

Internal: AgentEvalConverter

Internal class that converts agent-framework types to EvalItem. Used by evaluate_agent() and evaluate_workflow() — not part of the public API:

Agent Framework Eval Format
Content.function_call tool_call in OpenAI chat format
Content.function_result tool_result in OpenAI chat format
FunctionTool {name, description, parameters} schema
Message history conversation list + query/response extraction

Core: EvalResults

Rich result type with convenience properties for CI integration:

results.all_passed          # bool: no failures or errors (recursive for workflow)
results.passed              # int: passing count
results.failed              # int: failure count
results.total               # int: total = passed + failed + errored
results.items               # list[EvalItemResult]: per-item detail with query, response, and scores
results.error               # str | None: error details on failure
results.sub_results         # dict: per-agent breakdown (workflow evals)
results.report_url          # str | None: portal link (Foundry)
results.assert_passed()     # raises AssertionError with details

Core: Orchestration Functions

Provider-agnostic functions that extract data and delegate to evaluators:

Function What it does
evaluate_agent() Runs agent against test queries (or evaluates pre-existing responses=), converts to EvalItems, passes to evaluator. Accepts optional expected_output= for ground-truth comparison, expected_tool_calls= for tool-correctness evaluation, and num_repetitions= for consistency measurement
evaluate_workflow() Extracts per-agent data from WorkflowRunResult, evaluates each agent and overall output. Per-agent breakdown in sub_results. Also accepts num_repetitions=

Core: Conversation Split Strategies

Multi-turn conversations must be split into query (input) and response (output) halves for evaluation. How you split determines what you're evaluating:

Last-turn split — split at the last user message. Everything up to and including it is the query context; the agent's subsequent actions are the response:

conversation: user1 → assistant1 → user2 → assistant2(tool) → tool_result → assistant3
query_messages:    [user1, assistant1, user2]
response_messages: [assistant2(tool), tool_result, assistant3]

This evaluates: "Given all the context so far, did the agent answer the latest question well?" Best for response quality at a specific point in the conversation.

Full-conversation split — the first user message is the query; everything after is the response:

query_messages:    [user1]
response_messages: [assistant1, user2, assistant2(tool), tool_result, assistant3]

This evaluates: "Given the original request, did the entire conversation trajectory serve the user?" Best for task completion and overall conversation quality.

Per-turn split — produces N eval items from an N-turn conversation. Each turn is evaluated with its cumulative context:

item 1: query = [user1],                        response = [assistant1]
item 2: query = [user1, assistant1, user2],      response = [assistant2(tool), tool_result, assistant3]

This evaluates each response independently. Best for fine-grained analysis and pinpointing where a conversation goes wrong.

These factorings produce different scores for the same conversation. The framework ships all three as built-in strategies, defaulting to last-turn. Developers can also provide a custom splitter — a function (Python) or IConversationSplitter implementation (.NET) — and override the strategy at the call site or per evaluator.

Azure AI: FoundryEvals

Evaluator implementation backed by Azure AI Foundry:

class FoundryEvals:
    def __init__(self, *, project_client=None, openai_client=None,
                 model_deployment: str, evaluators=None, ...)
    async def evaluate(self, items, *, eval_name) -> EvalResults

Smart auto-detection in evaluate():

  • Default evaluators: relevance, coherence, task_adherence
  • Auto-adds tool_call_accuracy when items have tools/tool_definitions
  • Filters out tool evaluators for items without tools

Azure AI: FoundryEvals Constants

from agent_framework.foundry import FoundryEvals

evaluators = [FoundryEvals.RELEVANCE, FoundryEvals.TOOL_CALL_ACCURACY]

Categories: Agent behavior, Tool usage, Quality, Safety.

Azure AI: Foundry-Specific Functions

Function What it does
evaluate_traces() Evaluate from stored response IDs or OTel traces
evaluate_foundry_target() Evaluate a Foundry-registered agent or deployment

Core: LocalEvaluator and Function Evaluators

LocalEvaluator implements the Evaluator protocol for fast, API-free evaluation. It runs check functions locally — useful for inner-loop development, CI smoke tests, and combining with cloud-based evaluators.

Built-in checks:

  • keyword_check(*keywords) — response must contain specified keywords
  • tool_called_check(*tool_names) — agent must have called specified tools
  • tool_calls_present — all expected_tool_calls names appear in conversation (unordered, extras OK)
  • tool_call_args_match — expected tool calls match on name + arguments (subset match on args)

Custom function evaluators use @evaluator to wrap plain Python functions. The function's parameter names determine what data it receives from the EvalItem:

from agent_framework import evaluator, LocalEvaluator

# Tier 1: Simple check — just query + response
@evaluator
def is_concise(response: str) -> bool:
    return len(response.split()) < 500

# Tier 2: Ground truth — compare against expected output
@evaluator
def mentions_city(response: str, expected_output: str) -> bool:
    return expected_output.lower() in response.lower()

# Tier 3: Full context — inspect conversation and tools
@evaluator
def used_tools(conversation: list, tools: list) -> float:
    # ... scoring logic
    return score

local = LocalEvaluator(is_concise, mentions_city, used_tools)

Supported parameters: query, response, expected, expected_tool_calls, conversation, tools, context. Return types: bool, float (≥0.5 = pass), dict with score or passed key, or CheckResult.

Async functions are handled automatically — @evaluator detects async def and produces the right wrapper.

Example: GAIA Benchmark

GAIA tests real-world multi-step tasks with known expected answers. Each task has a question and a ground-truth answer, with optional file attachments. The framework accommodates GAIA's knobs (difficulty levels, file inputs, multi-step tool use) through the existing EvalItem fields:

from datasets import load_dataset
from agent_framework import evaluate_agent, evaluator, LocalEvaluator

gaia = load_dataset("gaia-benchmark/GAIA", "2023_level1", split="test")

@evaluator
def exact_match(response: str, expected_output: str) -> bool:
    return expected_output.strip().lower() in response.strip().lower()

# Simple path — evaluate_agent handles running + expected_output stamping
results = await evaluate_agent(
    agent=agent,
    queries=[task["Question"] for task in gaia],
    expected_output=[task["Final answer"] for task in gaia],
    evaluators=LocalEvaluator(exact_match),
)

Package Location

  • Core types and orchestration: agent_framework._eval, agent_framework._local_eval (Python), Microsoft.Agents.AI (.NET)
  • Foundry provider: agent_framework_azure_ai._foundry_evals (Python), Microsoft.Agents.AI.AzureAI (.NET)
  • Azure-AI re-exports core types for convenience (Python)

Known Limitations

  1. Tool evaluators require query + agent: Tool evaluators need tool definition schemas. When using these evaluators with evaluate_agent(responses=...), provide queries= and pass an agent with tool definitions.
  2. model_deployment always required: Could potentially be inferred from the Foundry project configuration.

Open Questions

  1. Red teaming non-registered agents: Requires Foundry API support for callback-based flows.
  2. Datasets with expected outputs: A dataset abstraction for pre-populating expected_output values across eval runs is a natural next step but not yet designed.
  3. Multi-modal evaluation: The conversation field on EvalItem already stores full Message/Content (Python) and ChatMessage (.NET) objects, which can represent multi-modal content (images, audio, structured data). Evaluators that accept the full EvalItem or conversation parameter can access this content today. However, the convenience shortcuts — query/response string projections and the FunctionEvaluator string overloads — are text-only. Multi-modal-aware evaluators should use the full-item path (Func<EvalItem, CheckResult> in .NET, conversation: list parameter in Python).

.NET Implementation Design

Key Difference: MEAI Ecosystem

Unlike Python, the .NET ecosystem already has Microsoft.Extensions.AI.Evaluation (v10.3.0) providing:

  • IEvaluator — per-item evaluation of (messages, chatResponse) → EvaluationResult
  • CompositeEvaluator — combines multiple evaluators
  • Quality evaluators — RelevanceEvaluator, CoherenceEvaluator, GroundednessEvaluator
  • Safety evaluators — ContentHarmEvaluator, ProtectedMaterialEvaluator
  • Metric types — NumericMetric, BooleanMetric, StringMetric

The .NET integration uses MEAI's IEvaluator directly — no new evaluator interface. Our contribution is the orchestration layer: extension methods that run agents, extract data, call IEvaluator per item, and aggregate results.

Architecture

┌──────────────────────────────────────────────────────────────┐
│  Developer Code                                              │
│  agent.EvaluateAsync(queries, evaluator)                     │
│  run.EvaluateAsync(evaluator)                                │
└────────────────┬─────────────────────────────────────────────┘
                 │
┌────────────────▼─────────────────────────────────────────────┐
│  Orchestration Layer (Microsoft.Agents.AI)                   │
│  AgentEvaluationExtensions — runs agents, extracts data,     │
│  calls IEvaluator per item, aggregates into                  │
│  AgentEvaluationResults                                      │
└────────────────┬─────────────────────────────────────────────┘
                 │ IEvaluator (MEAI)
                 │
     ┌───────────┼────────────┐
     │           │            │
 ┌───▼───-┐  ┌───▼────┐  ┌────▼──────────┐
 │ MEAI   │  │ Local  │  │ Foundry       │
 │ Quality│  │ Checks │  │ (cloud batch) │
 │ Safety │  │ Lambdas│  │               │
 └────────┘  └────────┘  └───────────────┘

All evaluators implement MEAI's IEvaluator. The orchestration layer doesn't need to know which kind — it calls EvaluateAsync(messages, chatResponse) per item on all of them. FoundryEvals handles batching internally (buffers items, submits once, returns per-item results).

.NET Core Types

No new evaluator interface. Use MEAI's IEvaluator directly.

AgentEvaluationResults — The only new type. Aggregates per-item MEAI EvaluationResults across a batch of queries:

public class AgentEvaluationResults
{
    public string Provider { get; init; }
    public string? ReportUrl { get; init; }

    // Per-item — standard MEAI EvaluationResult, unchanged
    public IReadOnlyList<EvaluationResult> Items { get; init; }

    // Aggregate pass/fail derived from metric interpretations
    public int Passed { get; }
    public int Failed { get; }
    public int Total { get; }
    public bool AllPassed { get; }

    // Workflow: per-agent breakdown
    public IReadOnlyDictionary<string, AgentEvaluationResults>? SubResults { get; init; }

    public void AssertAllPassed(string? message = null);
}

.NET Evaluator Implementations

All implement MEAI's IEvaluator:

LocalEvaluator — Runs lambda checks locally, returns BooleanMetric per check:

var local = new LocalEvaluator(
    FunctionEvaluator.Create("is_concise",
        (string response) => response.Split().Length < 500),
    EvalChecks.KeywordCheck("weather"),
    EvalChecks.ToolCalledCheck("get_weather"));

MEAI evaluators — Used directly, no adapter needed:

var quality = new CompositeEvaluator(
    new RelevanceEvaluator(),
    new CoherenceEvaluator());

FoundryEvals — Implements IEvaluator but batches internally. On first call, buffers the item. On the last item (or when explicitly flushed), submits the batch to Foundry and distributes per-item results:

var foundry = new FoundryEvals(projectClient, "gpt-4o");

.NET Orchestration: Extension Methods

public static class AgentEvaluationExtensions
{
    // Evaluate an agent against test queries
    public static Task<AgentEvaluationResults> EvaluateAsync(
        this AIAgent agent,
        IEnumerable<string> queries,
        IEvaluator evaluator,
        ChatConfiguration? chatConfiguration = null,
        IEnumerable<string>? expectedOutput = null,
        CancellationToken cancellationToken = default);

    // Evaluate pre-existing responses (without re-running the agent)
    public static Task<AgentEvaluationResults> EvaluateAsync(
        this AIAgent agent,
        AgentResponse responses,
        IEvaluator evaluator,
        IEnumerable<string>? queries = null,
        ChatConfiguration? chatConfiguration = null,
        IEnumerable<string>? expectedOutput = null,
        CancellationToken cancellationToken = default);

    // Evaluate with multiple evaluators (one result per evaluator)
    public static Task<IReadOnlyList<AgentEvaluationResults>> EvaluateAsync(
        this AIAgent agent,
        IEnumerable<string> queries,
        IEnumerable<IEvaluator> evaluators,
        ChatConfiguration? chatConfiguration = null,
        IEnumerable<string>? expectedOutput = null,
        CancellationToken cancellationToken = default);

    // Evaluate a workflow run with per-agent breakdown
    public static Task<AgentEvaluationResults> EvaluateAsync(
        this Run run,
        IEvaluator evaluator,
        ChatConfiguration? chatConfiguration = null,
        bool includeOverall = true,
        bool includePerAgent = true,
        CancellationToken cancellationToken = default);
}

Usage:

// MEAI evaluators — just works
var results = await agent.EvaluateAsync(
    queries: ["What's the weather?"],
    evaluator: new RelevanceEvaluator(),
    chatConfiguration: new ChatConfiguration(evalClient));

// Local checks
var results = await agent.EvaluateAsync(
    queries: ["What's the weather?"],
    evaluator: new LocalEvaluator(
        EvalChecks.KeywordCheck("weather")));

// Foundry cloud
var results = await agent.EvaluateAsync(
    queries: ["What's the weather?"],
    evaluator: new FoundryEvals(projectClient, "gpt-4o"));

// Evaluate existing response (without re-running the agent)
var response = await agent.RunAsync("What's the weather?");
var results = await agent.EvaluateAsync(
    responses: response,
    queries: ["What's the weather?"],
    evaluator: new FoundryEvals(projectClient, "gpt-4o"));

// Mixed — one result per evaluator
var results = await agent.EvaluateAsync(
    queries: ["What's the weather?"],
    evaluators: [
        new LocalEvaluator(EvalChecks.KeywordCheck("weather")),
        new RelevanceEvaluator(),
        new FoundryEvals(projectClient, "gpt-4o")
    ],
    chatConfiguration: new ChatConfiguration(evalClient));

// Workflow with per-agent breakdown
Run run = await workflowRunner.RunAsync(workflow, "Plan a trip");
var results = await run.EvaluateAsync(
    evaluator: new FoundryEvals(projectClient, "gpt-4o"));

.NET Function Evaluators

Typed factory overloads (C# equivalent of Python's @evaluator):

public static class FunctionEvaluator
{
    public static EvalCheck Create(string name, Func<string, bool> check);           // response only
    public static EvalCheck Create(string name, Func<string, string?, bool> check);  // expectedOutput
    public static EvalCheck Create(string name, Func<EvalItem, bool> check);         // full item
    public static EvalCheck Create(string name, Func<EvalItem, CheckResult> check);  // full control
    public static EvalCheck Create(string name, Func<string, Task<bool>> check);     // async
}

EvalItem is a lightweight record used only by FunctionEvaluator and LocalEvaluator to pass context to check functions. It is not part of the IEvaluator interface:

public record ExpectedToolCall(string Name, IReadOnlyDictionary<string, object>? Arguments = null);

public sealed class EvalItem
{
    public EvalItem(string query, string response, IReadOnlyList<ChatMessage> conversation);

    public string Query { get; }
    public string Response { get; }
    public IReadOnlyList<ChatMessage> Conversation { get; }
    public IReadOnlyList<AITool>? Tools { get; set; }
    public string? ExpectedOutput { get; set; }
    public IReadOnlyList<ExpectedToolCall>? ExpectedToolCalls { get; set; }
    public string? Context { get; set; }
    public IConversationSplitter? Splitter { get; set; }
}

Workflow Data Extraction (.NET)

run.EvaluateAsync() walks Run.OutgoingEvents via LINQ:

  1. Pair ExecutorInvokedEvent / ExecutorCompletedEvent by ExecutorId
  2. Extract AgentResponseEvent for per-agent ChatResponse
  3. Call evaluator.EvaluateAsync() per invocation
  4. Group by ExecutorId for per-agent SubResults
  5. Use final workflow output for overall eval

.NET Package Structure

Package Contents
Microsoft.Agents.AI IAgentEvaluator, AgentEvaluationResults, LocalEvaluator, FunctionEvaluator, EvalChecks, EvalItem, ExpectedToolCall, AgentEvaluationExtensions
Microsoft.Agents.AI.AzureAI FoundryEvals (provider + constants)

Python ↔ .NET Mapping

Python .NET
Evaluator protocol IAgentEvaluator (our interface; MEAI provides IEvaluator for per-item scoring)
EvalItem dataclass EvalItem class
EvalResults AgentEvaluationResults
EvalItemResult / EvalScoreResult MEAI EvaluationResult / EvaluationMetric (reused)
LocalEvaluator LocalEvaluator (implements IAgentEvaluator)
@evaluator FunctionEvaluator.Create() overloads
keyword_check() / tool_called_check() EvalChecks.KeywordCheck() / EvalChecks.ToolCalledCheck()
tool_calls_present / tool_call_args_match (custom FunctionEvaluator — same pattern)
ExpectedToolCall dataclass ExpectedToolCall record
FoundryEvals FoundryEvals (implements IAgentEvaluator, includes evaluator name constants)
evaluate_agent() agent.EvaluateAsync(queries, evaluator) extension method
evaluate_agent(responses=) agent.EvaluateAsync(responses, evaluator) extension method
evaluate_workflow() run.EvaluateAsync() extension method

More Information