Compare commits

..
Author SHA1 Message Date
Shawn HenryandGitHub b59a22d4d7 Revise important notes on third-party system usage
Updated important notes regarding third-party systems and responsibilities when using the Microsoft Agent Framework.
2026-03-31 14:46:08 -07:00
3a49b1d6dd Python: [BREAKING] Remove deprecated Python OpenAI/Azure AI surfaces (#4990)
* [BREAKING] Remove deprecated Python OpenAI/Azure AI surfaces

Also clean up follow-on docs, environment guidance, package metadata, and lab test stability.

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

* Fix deleted semantic-kernel sample links

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

* Address PR review feedback

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

* improve foundry language

* Fix A2A Foundry sample regression

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 20:36:21 +00:00
a5eacbbe65 Python: Add Python A2A agent-as-function-tools sample (#4889)
* Add Python A2A agent-as-function-tools sample

Port of the .NET A2AAgent_AsFunctionTools sample to Python.
Resolves a remote A2A agent card, converts each skill to a
FunctionTool via as_tool(), and registers them with a host agent
using AzureOpenAIResponsesClient.

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

* Sanitize A2A skill names before passing to as_tool()

as_tool() only auto-sanitizes when name is omitted. Since we pass
skill.name explicitly, we need to strip special characters ourselves.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 20:00:40 +00:00
55b6e7a9f4 Python: Add Python feature lifecycle decorators for released APIs (#4975)
* Add Python feature lifecycle decorators

Introduce reusable experimental and release-candidate decorators for released packages, migrate the Skills APIs to the new staged metadata and warning system, and add lifecycle guidance plus samples.

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

* Fix Python CI follow-ups

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

* Address PR review feedback

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

* Preserve protocol runtime checks

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 19:40:08 +00:00
9c9d81d8b6 .NET: Improve README: architecture overview, troubleshooting, and sample links (#5002)
* Fix README issues: simplify Azure quickstart, add missing sample links, fix typo

- Replace BearerTokenPolicy Azure snippet with simpler AzureOpenAIClient + DefaultAzureCredential pattern
- Add missing sample links for Python (04-hosting, 05-end-to-end) and .NET (01-get-started, 04-hosting, 05-end-to-end)
- Fix 'infererence' typo in dotnet/samples/README.md

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

* Add architecture overview and troubleshooting sections to README

- Add ASCII architecture diagram showing AIAgent and Workflow pipelines
- Add agent-vs-workflow decision table with 8 common scenarios
- Add troubleshooting section for authentication issues and environment variables
- Fix 'infererence' typo in dotnet/samples/README.md

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

* Fix architecture diagram alignment

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

* fix diagram

* Update architecture diagram and rename Azure AI Foundry to Microsoft Foundry

- Add A2AAgent and Skills to the architecture diagram
- Rename Azure AI Foundry references to Microsoft Foundry
- Add A2AAgent to agent type descriptions

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

* adderss comments

* address PR review comments

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 18:44:27 +00:00
westeyandGitHub 47a8a305d2 Fix environment variable set statement in py DEV_SETUP (#5006) 2026-03-31 18:34:04 +00:00
westeyandGitHub 6e7254bba7 .NET: [BREAKING] Rename from ServiceStoredSimulatingChatClient to PerServiceCallChatHistoryPersistingChatClient (#4993)
* Rename from ServiceStoredSimulatingChatClient to PerServiceCallChatHistoryPersistingChatClient

* Address PR comment
2026-03-31 17:32:05 +00:00
9c57680f00 Python: Add header_provider to Streamable HTTP MCP servers (#4849)
* Python: Add header_provider to MCPStreamableHTTPTool (#4808)

Add a header_provider callback parameter to MCPStreamableHTTPTool that
enables injecting dynamic per-request HTTP headers from runtime kwargs
(originating from FunctionInvocationContext.kwargs set in agent middleware).

The implementation uses contextvars and httpx event hooks to ensure headers
are task-local and safe for concurrent tool calls:

- header_provider receives the runtime kwargs dict and returns headers
- call_tool sets a ContextVar before delegating to MCPTool.call_tool
- An httpx request event hook reads from the ContextVar and injects headers

Example usage:
    mcp_tool = MCPStreamableHTTPTool(
        name="web-api",
        url="https://api.example.com/mcp",
        header_provider=lambda kwargs: {
            "X-Auth-Token": kwargs.get("auth_token", ""),
        },
    )

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

* Address review feedback for #4808: Python: [Bug]: Unable to pass AgentContext to MCPStreamableHTTPTool

* Add test for header_provider via FunctionTool.invoke with FunctionInvocationContext

Addresses PR review comment: exercises the full pipeline from
FunctionInvocationContext.kwargs through FunctionTool.invoke to
MCPStreamableHTTPTool.call_tool and header_provider, rather than
testing call_tool in isolation.

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

* Address review feedback for #4808: review comment fixes

* Fix streamable MCP transport defaults

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

* Fix Azure AI test client mocks

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

* Fix MCP runtime kwarg regressions

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

* Stabilize MCP tool runtime kwargs

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

* Use context kwargs in MCP wrappers

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

* updated mcp samples

* fix link

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 17:23:49 +00:00
7c2dae8855 Python: Fix sample bugs: incorrect API params, wrong client types, and invalid options (#4983)
* Fix sample bugs: incorrect API params, wrong client types, and invalid options

- typed_options.py: Fix AnthropicClient model->model_id, wrap raw strings in Message objects for get_response(), fix reasoning_effort->reasoning dict, fix budget_tokens minimum (1024), use OpenAIChatClient not FoundryChatClient, remove unused import

- client_reasoning.py: Fix deprecated model_id to model param

- client_with_hosted_mcp.py: Remove invalid store=True kwarg from Agent.run()

- code_defined_skill.py: Fix precision kwarg to use function_invocation_kwargs

- Various other samples: Fix deprecated API usage and incorrect params

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

* Address PR review comments

- client_with_hosted_mcp.py: Fix remaining store=True kwarg on line 68 to use options dict

- client_with_session.py: Change store=True to store=False to match in-memory persistence demo intent

- typed_options.py: Remove non-existent import and model key from docstring example

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

* new sample fixes

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:58:51 +00:00
Peter IbekweandGitHub 3d09337446 Update project name from 'Semantic Kernel' to 'Agent Framework' (#5001) 2026-03-31 16:52:44 +00:00
3c727b5b71 Improve CONTRIBUTING.md with dev setup links and docs guidance (#5000)
* Improve CONTRIBUTING.md with dev setup links and docs guidance

- Consolidate Development Scripts into a Development Setup section with
  quick links to language-specific dev guides and coding standards
- Add Python build/test/lint commands alongside existing .NET commands
- Add Documentation Contributions section with link checker, writing
  guidelines, and style guidance

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

* Use directory note for .NET commands, matching Python style

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

* Split test commands into unit vs. integration for both Python and .NET

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

* Remove Documentation Contributions section

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:42:52 +00:00
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
3f964c4cdb Removing old code-gen docs from dotnet root (#4997)
Co-authored-by: alliscode <bentho@microsoft.com>
2026-03-31 15:37:59 +00:00
Tao ChenandGitHub 016daf3b98 Python: Fix samples (#4980)
* First samples 1st batch

* Fix sample paths

* Fix workflow samples

* Fix workflow dependency

* Correct env vars

* Increase idle timeout

* Fix workflows HIL sample

* Fix more workflow samples
2026-03-31 15:20:35 +00:00
0f81c277d9 Updated package versions (#4982)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 21:26:05 +00:00
Giles OdigweandGitHub 0e00e5f8dd Python: Update Python Packages for rc6 (#4979)
* python package update

* small fix
2026-03-30 21:12:37 +00:00
westeyandGitHub 31c866172a Fix broken url in samples (#4981) 2026-03-30 19:19:16 +00:00
401e5dc7e8 .NET: Allow Simulating service stored ChatHistory to improve consistency (#4974)
* Allow Simulating service stored ChatHistory to improve consistency

* Fixing bug in ServiceStoredSimulatingChatClient

* Addressing PR comments.

* Address PR comments

* Apply suggestion from @SergeyMenshykh

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Fix bug

---------

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
2026-03-30 18:52:01 +00:00
18f7ba8632 .NET: Add API breaking change validation for RC packages (#4977)
* Add API breaking change validation for RC packages

Enable .NET Package Validation for release candidate packages to detect
API breaking changes in CI. This follows the same pattern used by
Semantic Kernel, centralized through nuget-package.props.

Changes:
- Enable EnablePackageValidation for IsReleaseCandidate packages
- Update PackageValidationBaselineVersion to 1.0.0-rc4 (latest published)
- Generate CompatibilitySuppressions.xml for existing known API changes
  in 5 packages (AI, AzureAI, OpenAI, Workflows, Workflows.Declarative.AzureAI)
- Opt out Workflows.Declarative.Mcp (not yet published to NuGet)
- Add breaking changes guidance to CONTRIBUTING.md

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

* Address PR review feedback

- Remove unnecessary empty PackageValidationBaselineVersion override
  in Workflows.Declarative.Mcp.csproj (EnablePackageValidation=false
  is sufficient)
- Tighten CONTRIBUTING.md wording to clarify opt-out possibility

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

* Enable package validation for GA packages (no VersionSuffix)

Expand the EnablePackageValidation condition to also cover future GA
packages that have no VersionSuffix, not just RC packages.

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

* Fix EnablePackageValidation GA condition to check PackageVersion

The previous condition VersionSuffix=='' matched all packages (preview
included) since VersionSuffix defaults to empty. Now uses two separate
conditions: one for RC, one for true GA (PackageVersion == VersionPrefix).

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

* Add IsGeneralAvailable flag for package validation

Replace fragile PackageVersion condition with explicit IsGeneralAvailable
property, following the same per-project self-declaration pattern as
IsReleaseCandidate.

- Directory.Build.props: Add IsGeneralAvailable=false default
- nuget-package.props: EnablePackageValidation on RC OR GA
- CONTRIBUTING.md: Update docs to mention both flags

When packages go GA, they set IsGeneralAvailable=true in their .csproj.

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

* Rename IsGeneralAvailable to IsGenerallyAvailable

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 17:36:43 +00:00
05c53dce2d Suppress CodeQL false positive (#4948)
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2026-03-30 16:34:45 +00:00
ade295b122 .NET: Add inline skills API (#4951)
* add inline skills

* Fix IDE1006 and IDE0004 formatting errors in test files

- Add 'Async' suffix to async test methods in FilteringAgentSkillsSourceTests,
  DeduplicatingAgentSkillsSourceTests, and AgentInMemorySkillsSourceTests
- Use pragma to suppress false-positive IDE0004 on casts needed for overload
  disambiguation in AgentInlineSkillTests and AgentInlineSkillResourceTests

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

* address issues

* address comments

* make inline skills script and resource model classes internal

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 16:23:04 +00:00
277 changed files with 13997 additions and 20883 deletions
@@ -126,8 +126,6 @@ jobs:
packages/openai/tests/openai/test_openai_chat_completion_client_azure.py
packages/openai/tests/openai/test_openai_chat_client_azure.py
packages/openai/tests/openai/test_openai_embedding_client_azure.py
packages/azure-ai/tests/azure_openai
--ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
@@ -288,7 +286,6 @@ jobs:
timeout-minutes: 15
run: >
uv run pytest --import-mode=importlib
packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py
packages/foundry/tests
-m integration
-n logical --dist worksteal
+1 -6
View File
@@ -62,9 +62,7 @@ jobs:
azure:
- 'python/packages/openai/**'
- 'python/packages/core/agent_framework/azure/**'
- 'python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py'
- 'python/packages/azure-ai/tests/azure_openai/**'
- 'python/samples/**/providers/azure/openai_chat_completion_client_azure*.py'
- 'python/samples/**/providers/azure/**'
misc:
- 'python/packages/anthropic/**'
- 'python/packages/ollama/**'
@@ -223,8 +221,6 @@ jobs:
packages/openai/tests/openai/test_openai_chat_completion_client_azure.py
packages/openai/tests/openai/test_openai_chat_client_azure.py
packages/openai/tests/openai/test_openai_embedding_client_azure.py
packages/azure-ai/tests/azure_openai
--ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
@@ -430,7 +426,6 @@ jobs:
timeout-minutes: 15
run: >
uv run pytest --import-mode=importlib
packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py
packages/foundry/tests
-m integration
-n logical --dist worksteal
+42 -70
View File
@@ -23,10 +23,8 @@ jobs:
environment: integration
env:
# Required configuration for get-started samples
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
defaults:
run:
working-directory: python
@@ -43,10 +41,8 @@ jobs:
- name: Create .env for samples
run: |
echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env
echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env
echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env
- name: Run sample validation
run: |
@@ -64,16 +60,13 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
# Foundry configuration
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }}
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME || vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
# OpenAI configuration
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
@@ -101,11 +94,8 @@ jobs:
run: |
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env
echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env
echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env
echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env
echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env
echo "AZURE_OPENAI_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env
echo "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME=$AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME" >> .env
echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env
echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env
@@ -169,10 +159,9 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_API_VERSION: ${{ vars.AZURE_OPENAI_API_VERSION || '' }}
defaults:
run:
working-directory: python
@@ -189,10 +178,9 @@ jobs:
- name: Create .env for samples
run: |
echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env
echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env
echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env
echo "AZURE_OPENAI_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env
echo "AZURE_OPENAI_API_VERSION=$AZURE_OPENAI_API_VERSION" >> .env
- name: Run sample validation
run: |
@@ -337,11 +325,14 @@ jobs:
validate-02-agents-foundry:
name: Validate 02-agents/providers/foundry
if: false # Temporarily disabled - provider folder also contains the local Foundry sample
runs-on: ubuntu-latest
environment: integration
env:
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME || '' }}
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION || '' }}
defaults:
run:
working-directory: python
@@ -360,6 +351,8 @@ jobs:
run: |
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env
echo "FOUNDRY_AGENT_NAME=$FOUNDRY_AGENT_NAME" >> .env
echo "FOUNDRY_AGENT_VERSION=$FOUNDRY_AGENT_VERSION" >> .env
- name: Run sample validation
run: |
@@ -448,15 +441,8 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
# Azure AI configuration
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
defaults:
run:
working-directory: python
@@ -475,11 +461,6 @@ jobs:
run: |
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env
echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env
echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env
echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env
echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env
- name: Run sample validation
run: |
@@ -498,12 +479,8 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# A2A configuration
A2A_AGENT_HOST: http://localhost:5001/
defaults:
@@ -537,19 +514,18 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure AI Search (for evaluation samples)
AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }}
AZURE_SEARCH_API_KEY: ${{ secrets.AZURE_SEARCH_API_KEY }}
AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }}
# Evaluation sample
AZURE_AI_MODEL_DEPLOYMENT_NAME_WORKFLOW: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
FOUNDRY_MODEL_WORKFLOW: ${{ vars.FOUNDRY_MODEL_WORKFLOW || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
FOUNDRY_MODEL_EVAL: ${{ vars.FOUNDRY_MODEL_EVAL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
defaults:
run:
working-directory: python
@@ -580,12 +556,11 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# OpenAI configuration
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
@@ -607,10 +582,10 @@ jobs:
- name: Create .env for samples
run: |
echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env
echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env
echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env
echo "AZURE_OPENAI_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env
echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env
echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env
echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env
@@ -631,13 +606,11 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
# OpenAI configuration
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
@@ -664,11 +637,10 @@ jobs:
- name: Create .env for samples
run: |
echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env
echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env
echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env
echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env
echo "AZURE_OPENAI_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env
echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env
echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env
echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env
+47 -8
View File
@@ -74,6 +74,37 @@ Contributions must maintain API signature and behavioral compatibility. Contribu
that include breaking changes will be rejected. Please file an issue to discuss
your idea or change if you believe that a breaking change is warranted.
#### Automated API Compatibility Validation
The .NET projects use [Package Validation](https://learn.microsoft.com/dotnet/fundamentals/package-validation/overview)
to automatically detect API breaking changes. This validation runs during `dotnet build`
(Release configuration) and `dotnet pack`, comparing the current API surface against the
latest published NuGet baseline version.
**What gets validated:** By default, packable RC packages (`IsReleaseCandidate=true`) and
GA packages (`IsGenerallyAvailable=true`) that have a published NuGet baseline and do not
override validation settings are automatically validated. The shared baseline version and
default validation settings are defined in `dotnet/nuget/nuget-package.props`, but
individual projects may opt out (for example by setting `EnablePackageValidation=false`).
**If the build fails with CP errors (e.g., CP0001, CP0002):**
1. **Unintentional breaking change** — Refactor your code to maintain backward compatibility.
2. **Intentional breaking change** (approved by maintainers) — Generate a suppression file:
```bash
dotnet build <project>.csproj -c Release /p:ApiCompatGenerateSuppressionFile=true
```
This creates or updates a `CompatibilitySuppressions.xml` in the project directory.
Include this file in your PR with justification for the breaking change.
**After each release:**
1. Delete all `CompatibilitySuppressions.xml` files from validated projects.
2. Update `PackageValidationBaselineVersion` in `dotnet/nuget/nuget-package.props` to the
newly published version.
For more details, see the [Package Validation diagnostic IDs](https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids).
### Suggested Workflow
We use and recommend the following workflow:
@@ -92,22 +123,30 @@ We use and recommend the following workflow:
"issue-123" or "githubhandle-issue".
4. Make and commit your changes to your branch.
5. Add new tests corresponding to your change, if applicable.
6. Run the relevant scripts in [the section below](#development-scripts) to ensure that your build is clean and all tests are passing.
6. Run the relevant scripts in [the section below](#development-setup) to ensure that your build is clean and all tests are passing.
7. Create a PR against the repository's **main** branch.
- State in the description what issue or improvement your change is addressing.
- Verify that all the Continuous Integration checks are passing.
8. Wait for feedback or approval of your changes from the code maintainers.
9. When area owners have signed off, and all checks are green, your PR will be merged.
### Development scripts
### Development Setup
The scripts below are used to build, test, and lint within the project.
Each language has its own dev setup guide, coding standards, and build scripts:
- Python: see [python/DEV_SETUP.md](./python/DEV_SETUP.md).
- .NET:
- Build: `dotnet build`
- Test: `dotnet test`
- Linting (auto-fix): `dotnet format`
- **Python**: [Dev Setup](./python/DEV_SETUP.md) · [Coding Standard](./python/CODING_STANDARD.md) · [README](./python/README.md)
- From the `./python` directory:
- Build: `uv run poe build`
- Unit tests: `uv run poe test -A -m "not integration"`
- Integration tests: `uv run poe test -A -m integration` (requires API keys/endpoints)
- Format + lint: `uv run poe syntax`
- All checks: `uv run poe check`
- **.NET**: [README](./dotnet/README.md) · [Agent Instructions](./dotnet/AGENTS.md)
- From the `./dotnet` directory:
- Build: `dotnet build`
- Unit tests: `dotnet test --filter-query "/*UnitTests*/*/*/*"`
- Integration tests: `dotnet test --filter-query "/*IntegrationTests*/*/*/*"` (requires API keys/endpoints)
- Linting (auto-fix): `dotnet format`
### PR - CI Process
+48 -19
View File
@@ -2,7 +2,7 @@
# Welcome to Microsoft Agent Framework!
[![Microsoft Azure AI Foundry Discord](https://dcbadge.limes.pink/api/server/b5zjErwbQM?style=flat)](https://discord.gg/b5zjErwbQM)
[![Microsoft Foundry Discord](https://dcbadge.limes.pink/api/server/b5zjErwbQM?style=flat)](https://discord.gg/b5zjErwbQM)
[![MS Learn Documentation](https://img.shields.io/badge/MS%20Learn-Documentation-blue)](https://learn.microsoft.com/en-us/agent-framework/)
[![PyPI](https://img.shields.io/pypi/v/agent-framework)](https://pypi.org/project/agent-framework/)
[![NuGet](https://img.shields.io/nuget/v/Microsoft.Agents.AI)](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
@@ -137,24 +137,21 @@ var agent = new OpenAIClient("<apikey>")
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
```
Create a simple Agent, using Azure OpenAI Responses with token based auth, that writes a haiku about the Microsoft Agent Framework
Create a simple Agent, using Microsoft Foundry with token-based auth, that writes a haiku about the Microsoft Agent Framework
```c#
// dotnet add package Microsoft.Agents.AI.OpenAI --prerelease
// dotnet add package Microsoft.Agents.AI.AzureAI --prerelease
// dotnet add package Azure.Identity
// Use `az login` to authenticate with Azure CLI
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI;
using OpenAI.Responses;
// Replace <resource> and gpt-4o-mini with your Azure OpenAI resource name and deployment name.
var agent = new OpenAIClient(
new BearerTokenPolicy(new AzureCliCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions() { Endpoint = new Uri("https://<resource>.openai.azure.com/openai/v1") })
.GetResponsesClient("gpt-4o-mini")
.AsAIAgent(name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
.AsAIAgent(model: deploymentName, name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
```
@@ -163,15 +160,43 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
### Python
- [Getting Started with Agents](./python/samples/01-get-started): progressive tutorial from hello-world to hosting
- [Getting Started](./python/samples/01-get-started): progressive tutorial from hello-world to hosting
- [Agent Concepts](./python/samples/02-agents): deep-dive samples by topic (tools, middleware, providers, etc.)
- [Getting Started with Workflows](./python/samples/03-workflows): workflow creation and integration with agents
- [Workflows](./python/samples/03-workflows): workflow creation and integration with agents
- [Hosting](./python/samples/04-hosting): A2A, Azure Functions, Durable Task hosting
- [End-to-End](./python/samples/05-end-to-end): full applications, evaluation, and demos
### .NET
- [Getting Started with Agents](./dotnet/samples/02-agents/Agents): basic agent creation and tool usage
- [Agent Provider Samples](./dotnet/samples/02-agents/AgentProviders): samples showing different agent providers
- [Workflow Samples](./dotnet/samples/03-workflows): advanced multi-agent patterns and workflow orchestration
- [Getting Started](./dotnet/samples/01-get-started): progressive tutorial from hello agent to hosting
- [Agent Concepts](./dotnet/samples/02-agents/Agents): basic agent creation and tool usage
- [Agent Providers](./dotnet/samples/02-agents/AgentProviders): samples showing different agent providers
- [Workflows](./dotnet/samples/03-workflows): advanced multi-agent patterns and workflow orchestration
- [Hosting](./dotnet/samples/04-hosting): A2A, Durable Agents, Durable Workflows
- [End-to-End](./dotnet/samples/05-end-to-end): full applications and demos
## Troubleshooting
### Authentication
| Problem | Cause | Fix |
|---------|-------|-----|
| Authentication errors when using Azure credentials | Not signed in to Azure CLI | Run `az login` before starting your app |
| API key errors | Wrong or missing API key | Verify the key and ensure it's for the correct resource/provider |
> **Tip:** `DefaultAzureCredential` is convenient for development but in production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
### Environment Variables
The samples typically read configuration from environment variables. Common required variables:
| Variable | Used by | Purpose |
|----------|---------|---------|
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI samples | Your Azure OpenAI resource URL |
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI samples | Model deployment name (e.g. `gpt-4o-mini`) |
| `AZURE_AI_PROJECT_ENDPOINT` | Microsoft Foundry samples | Your Microsoft Foundry project endpoint |
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Microsoft Foundry samples | Model deployment name |
| `OPENAI_API_KEY` | OpenAI (non-Azure) samples | Your OpenAI platform API key |
## Contributor Resources
@@ -181,5 +206,9 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
- [Architectural Decision Records](./docs/decisions)
## Important Notes
If you use the Microsoft Agent Framework to build applications that operate with third-party servers or agents, you do so at your own risk. We recommend reviewing all data being shared with third-party servers or agents and being cognizant of third-party practices for retention and location of data. It is your responsibility to manage whether your data will flow outside of your organization's Azure compliance and geographic boundaries and any related implications.
> [!IMPORTANT]
> If you use Microsoft Agent Framework to build applications that operate with any third-party servers, agents, code, or non-Azure Direct models (“Third-Party Systems”), you do so at your own risk. Third-Party Systems are Non-Microsoft Products under the Microsoft Product Terms and are governed by their own third-party license terms. You are responsible for any usage and associated costs.
>
> We recommend reviewing all data being shared with and received from Third-Party Systems and being cognizant of third-party practices for handling, sharing, retention and location of data. It is your responsibility to manage whether your data will flow outside of your organization’s Azure compliance and geographic boundaries and any related implications, and that appropriate permissions, boundaries and approvals are provisioned.
>
> You are responsible for carefully reviewing and testing applications you build using Microsoft Agent Framework in the context of your specific use cases, and making all appropriate decisions and customizations. This includes implementing your own responsible AI mitigations such as metaprompt, content filters, or other safety systems, and ensuring your applications meet appropriate quality, reliability, security, and trustworthiness standards. See also: [Transparency FAQ](./TRANSPARENCY_FAQ.md.md)
@@ -31,8 +31,6 @@ The persistence timing and `FunctionResultContent` trimming behaviors are interr
- **Per-run persistence**: When messages are batched and persisted at the end of the full run, trailing `FunctionResultContent` trimming becomes necessary to match the service's behavior. Without trimming, the stored history contains `FunctionResultContent` that the service would never have stored.
This means the trimming feature (introduced in [PR #4792](https://github.com/microsoft/agent-framework/pull/4792)) is primarily needed as a complement to per-run persistence. The `PersistChatHistoryAtEndOfRun` setting (introduced in [PR #4762](https://github.com/microsoft/agent-framework/pull/4762)) inverts the default so that per-service-call persistence is the standard behavior, and per-run persistence is opt-in.
## Decision Drivers
- **A. Consistency**: The default behavior of `ChatHistoryProvider` should produce stored history that closely matches what the underlying AI service would store, minimizing surprise when switching between framework-managed and service-managed chat history.
@@ -43,33 +41,30 @@ This means the trimming feature (introduced in [PR #4792](https://github.com/mic
## Considered Options
- Option 1: Default to per-run persistence with `FunctionResultContent` trimming (opt-in to per-service-call)
- Option 2: Default to per-service-call persistence (opt-in to per-run)
- Option 1: Per-run persistence with opt-in FRC (FunctionResultContent) trimming
- Option 2: Opt-in per-service-call persistence (via `RequirePerServiceCallChatHistoryPersistence`)
## Pros and Cons of the Options
### Option 1: Default to per-run persistence with `FunctionResultContent` trimming
### Option 1: Per-run persistence with opt-in FRC trimming
Keep the current default behavior of persisting chat history only at the end of the full agent run. Add `FunctionResultContent` trimming as the default to improve consistency with service storage. Provide an opt-in setting for users who want per-service-call persistence.
Settings:
- `PersistChatHistoryAtEndOfRun` = `true`
Keep the current default behavior of persisting chat history only at the end of the full agent run. Add `FunctionResultContent` trimming as an opt-in behavior to improve consistency with service storage.
- Good, because runs are atomic — chat history is only updated when the full run succeeds, satisfying driver B.
- Good, because the mental model is simple: one run = one history update, satisfying driver D.
- Good, because trimming trailing `FunctionResultContent` improves consistency with service storage, partially satisfying driver A.
- Good, because users can opt in to per-service-call persistence for checkpointing/recovery scenarios, satisfying drivers C and E.
- Bad, because the default persistence timing still differs from the service's behavior (per-run vs. per-service-call), only partially satisfying driver A.
- Bad, because if the process crashes mid-loop, all intermediate progress from the current run is lost, not satisfying driver C by default.
- Bad, because if the process crashes mid-loop, all intermediate progress from the current run is lost, not satisfying driver C.
- Bad, because this option alone does not provide a way for users to opt into per-service-call persistence, not satisfying driver E.
### Option 2: Default to per-service-call persistence
### Option 2: Opt-in per-service-call persistence (via `RequirePerServiceCallChatHistoryPersistence`)
Change the default to persist chat history after each individual service call within the FIC loop, matching the AI service's behavior. Trailing `FunctionResultContent` trimming is unnecessary with this approach (it is naturally handled). Provide an opt-in setting for users who want per-run atomicity with trimming.
Introduce an optional RequirePerServiceCallChatHistoryPersistence setting to persist chat history after each individual service call within the FIC loop, matching the AI service's behavior. Trailing `FunctionResultContent` trimming is unnecessary with this approach (it is naturally handled).
Settings:
- `PersistChatHistoryAtEndOfRun` = `false` (default)
- `RequirePerServiceCallChatHistoryPersistence` = `true`
- Good, because the stored history matches the service's behavior by default for both timing and content, fully satisfying driver A.
- Good, because the stored history matches the service's behavior when opting in for both timing and content, fully satisfying driver A.
- Good, because intermediate progress is preserved if the process is interrupted, satisfying driver C.
- Good, because no separate `FunctionResultContent` trimming logic is needed, reducing complexity.
- Bad, because chat history may be left in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), not satisfying driver B. A subsequent run cannot proceed without manually providing the missing `FunctionResultContent`.
@@ -78,39 +73,49 @@ Settings:
## Decision Outcome
Chosen option: **Option 2 — Default to per-service-call persistence**, because it fully satisfies the consistency driver (A), naturally handles `FunctionResultContent` trimming without additional logic, and provides better recoverability for long-running tool-calling loops. Per-run persistence remains available via the `PersistChatHistoryAtEndOfRun` setting for users who prefer atomic run semantics.
Chosen option: **Option 2: Opt-in per-service-call persistence (via `RequirePerServiceCallChatHistoryPersistence`)**. The existing per-run persistence behavior is retained as-is, requiring no changes from users. Per-service-call persistence is available as an opt-in feature via the `RequirePerServiceCallChatHistoryPersistence` setting. This satisfies drivers B (atomicity) and D (simplicity) for the common case, while fully satisfying driver A (consistency) for users who opt into simulated service-stored behavior. Users who need per-service-call persistence for recoverability (driver C) can enable it explicitly.
### Configuration Matrix
The behavior depends on the combination of `UseProvidedChatClientAsIs` and `PersistChatHistoryAtEndOfRun`:
The behavior depends on the combination of `UseProvidedChatClientAsIs` and `RequirePerServiceCallChatHistoryPersistence`:
| `UseProvidedChatClientAsIs` | `PersistChatHistoryAtEndOfRun` | Behavior |
| `UseProvidedChatClientAsIs` | `RequirePerServiceCallChatHistoryPersistence` | Behavior |
|---|---|---|
| `false` (default) | `false` (default) | **Per-service-call persistence.** A `ChatHistoryPersistingChatClient` middleware is automatically injected into the chat client pipeline between `FunctionInvokingChatClient` and the leaf `IChatClient`. Messages are persisted after each service call. |
| `true` | `false` | **User responsibility.** No middleware is injected because the user has provided a custom chat client stack. The user is responsible for ensuring correct persistence behavior (e.g., by including their own persisting middleware). |
| `false` | `true` | **Per-run persistence with marking.** A `ChatHistoryPersistingChatClient` middleware is injected, but configured to *mark* messages with metadata rather than store them immediately. At the end of the run, marked messages are stored. Trailing `FunctionResultContent` is trimmed. |
| `true` | `true` | **Per-run persistence with warning.** The system checks whether the custom chat client stack includes a `ChatHistoryPersistingChatClient`. If not, a warning is emitted (particularly relevant for workflow handoff scenarios where trimming cannot be guaranteed). If no `ChatHistoryPersistingChatClient` is preset, all messages are stored at the end of the run, otherwise marked messages are stored. |
| `false` (default) | `false` (default) | **Per-run persistence.** Messages are persisted at the end of the full agent run via the `ChatHistoryProvider`. |
| `false` | `true` | **Per-service-call persistence (simulated).** A `PerServiceCallChatHistoryPersistingChatClient` middleware is automatically injected into the chat client pipeline between `FunctionInvokingChatClient` and the leaf `IChatClient`. Messages are persisted after each service call. A sentinel `ConversationId` causes FIC to treat the conversation as service-managed. |
| `true` | `false` | **Per-run persistence.** No middleware is injected because the user has provided a custom chat client stack. Messages are persisted at the end of the run. |
| `true` | `true` | **User responsibility.** The system checks whether the custom chat client stack includes a `PerServiceCallChatHistoryPersistingChatClient`. If not, a warning is emitted — the user is expected to have added their own per-service-call persistence mechanism. End-of-run persistence is skipped. |
### Consequences
- Good, because the stored history matches the service's behavior by default for both timing and content, fully satisfying consistency (driver A).
- Good, because intermediate progress is preserved if the process is interrupted, satisfying recoverability (driver C).
- Good, because no separate `FunctionResultContent` trimming logic is needed in the default path, reducing complexity.
- Good, because marking persisted messages with metadata enables deduplication and aids debugging.
- Good, because warnings for custom chat client configurations without the persisting middleware help prevent silent failures in workflow handoff scenarios.
- Bad, because chat history may be left in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), requiring manual recovery in rare cases.
- Bad, because the mental model is more complex for the default path: a single run may produce multiple history updates.
- Neutral, because users who prefer atomic run semantics can opt in to per-run persistence via `PersistChatHistoryAtEndOfRun = true`.
- Good, because per-run persistence is atomic by default — chat history is only updated when the full run succeeds, satisfying driver B.
- Good, because the default mental model is simple: one run = one history update, satisfying driver D.
- Good, because users who opt into `RequirePerServiceCallChatHistoryPersistence` get stored history that matches the service's behavior for both timing and content, fully satisfying driver A.
- Good, because per-service-call persistence preserves intermediate progress if the process is interrupted, satisfying driver C when opted in.
- Good, because no separate `FunctionResultContent` trimming logic is needed when per-service-call persistence is active — it is naturally handled.
- Good, because conflict detection (configurable via `ThrowOnChatHistoryProviderConflict`, `WarnOnChatHistoryProviderConflict`, `ClearOnChatHistoryProviderConflict`) prevents misconfiguration when a service returns a `ConversationId` alongside a configured `ChatHistoryProvider`.
- Bad, because per-service-call persistence (when opted in) may leave chat history in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), requiring manual recovery in rare cases.
- Neutral, because users who want per-service-call consistency can opt in via `RequirePerServiceCallChatHistoryPersistence = true`, satisfying driver E.
- Neutral, because increased write frequency from per-service-call persistence may impact performance for some storage backends; this can be mitigated with a caching decorator.
### Implementation Notes
#### Conversation ID Consistency
The `ChatHistoryPersistingChatClient` middleware must also update the session's `ConversationId` consistently for both response-based and conversation-based service interactions, ensuring the session always reflects the latest service-provided identifier.
When `RequirePerServiceCallChatHistoryPersistence` is enabled, the `PerServiceCallChatHistoryPersistingChatClient`
decorator also updates `session.ConversationId` after each service call. This handles two scenarios:
## More Information
1. **Framework-managed chat history** — the decorator sets a sentinel `ConversationId` on the response
so that `FunctionInvokingChatClient` treats the conversation as service-managed (clearing accumulated
history between iterations and not injecting duplicate `FunctionCallContent` during approval processing).
2. **Service-stored chat history** — when the service returns a real `ConversationId`, the decorator
updates `session.ConversationId` immediately after each service call, rather than deferring the update
to the end of the run. This ensures intermediate ConversationId changes are captured even if the
process is interrupted mid-loop.
For some service-stored scenarios (e.g., the Conversations API with the Responses API), there is only
one thread with one ID, so every service call returns the same ConversationId and this per-call update
makes no practical difference. Enabling `RequirePerServiceCallChatHistoryPersistence` ensures consistent
per-service-call behavior across all service types regardless of how they manage ConversationIds.
- [PR #4762: Persist messages during function call loop](https://github.com/microsoft/agent-framework/pull/4762) — introduces `PersistChatHistoryAfterEachServiceCall` option and `ChatHistoryPersistingChatClient` decorator
- [PR #4792: Trim final FRC to match service storage](https://github.com/microsoft/agent-framework/pull/4792) — introduces `StoreFinalFunctionResultContent` option and `FilterFinalFunctionResultContent` logic
- [Issue #2889](https://github.com/microsoft/agent-framework/issues/2889) — original issue tracking chat history persistence during function call loops
@@ -462,7 +462,7 @@ class FoundryEvals:
### Azure AI: FoundryEvals Constants
```python
from agent_framework_azure_ai import FoundryEvals
from agent_framework.foundry import FoundryEvals
evaluators = [FoundryEvals.RELEVANCE, FoundryEvals.TOOL_CALL_ACCURACY]
```
+1
View File
@@ -17,6 +17,7 @@
<PropertyGroup>
<IsReleaseCandidate>false</IsReleaseCandidate>
<IsGenerallyAvailable>false</IsGenerallyAvailable>
</PropertyGroup>
<PropertyGroup>
+1
View File
@@ -105,6 +105,7 @@
<Folder Name="/Samples/02-agents/AgentSkills/">
<File Path="samples/02-agents/AgentSkills/README.md" />
<Project Path="samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills/Agent_Step01_FileBasedSkills.csproj" />
<Project Path="samples/02-agents/AgentSkills/Agent_Step02_CodeDefinedSkills/Agent_Step02_CodeDefinedSkills.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
+7 -5
View File
@@ -2,17 +2,19 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<RCNumber>4</RCNumber>
<RCNumber>5</RCNumber>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260311.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260311.1</PackageVersion>
<GitTag>1.0.0-rc4</GitTag>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260330.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260330.1</PackageVersion>
<GitTag>1.0.0-rc5</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
<!-- Package validation. Baseline Version should be the latest version available on NuGet. -->
<PackageValidationBaselineVersion>0.0.1</PackageValidationBaselineVersion>
<PackageValidationBaselineVersion>1.0.0-rc4</PackageValidationBaselineVersion>
<!-- Enable validation for RC packages and GA packages -->
<EnablePackageValidation Condition="'$(IsReleaseCandidate)' == 'true' OR '$(IsGenerallyAvailable)' == 'true'">true</EnablePackageValidation>
<!-- Validate assembly attributes only for Publish builds -->
<NoWarn Condition="'$(Configuration)' != 'Publish'">$(NoWarn);CP0003</NoWarn>
<!-- Do not validate reference assemblies -->
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,90 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to define Agent Skills entirely in code using AgentInlineSkill.
// No SKILL.md files are needed — skills, resources, and scripts are all defined programmatically.
//
// Three approaches are shown using a unit-converter skill:
// 1. Static resources — inline content provided via AddResource
// 2. Dynamic resources — computed at runtime via a factory delegate
// 3. Code scripts — executable delegates the agent can invoke directly
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI.Responses;
// --- Configuration ---
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// --- Build the code-defined skill ---
var unitConverterSkill = new AgentInlineSkill(
name: "unit-converter",
description: "Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.",
instructions: """
Use this skill when the user asks to convert between units.
1. Review the conversion-table resource to find the factor for the requested conversion.
2. Check the conversion-policy resource for rounding and formatting rules.
3. Use the convert script, passing the value and factor from the table.
""")
// 1. Static Resource: conversion tables
.AddResource(
"conversion-table",
"""
# Conversion Tables
Formula: **result = value Ă— factor**
| From | To | Factor |
|-------------|-------------|----------|
| miles | kilometers | 1.60934 |
| kilometers | miles | 0.621371 |
| pounds | kilograms | 0.453592 |
| kilograms | pounds | 2.20462 |
""")
// 2. Dynamic Resource: conversion policy (computed at runtime)
.AddResource("conversion-policy", () =>
{
const int Precision = 4;
return $"""
# Conversion Policy
**Decimal places:** {Precision}
**Format:** Always show both the original and converted values with units
**Generated at:** {DateTime.UtcNow:O}
""";
})
// 3. Code Script: convert
.AddScript("convert", (double value, double factor) =>
{
double result = Math.Round(value * factor, 4);
return JsonSerializer.Serialize(new { value, factor, result });
});
// --- Skills Provider ---
var skillsProvider = new AgentSkillsProvider(unitConverterSkill);
// --- Agent Setup ---
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetResponsesClient()
.AsAIAgent(new ChatClientAgentOptions
{
Name = "UnitConverterAgent",
ChatOptions = new()
{
Instructions = "You are a helpful assistant that can convert units.",
},
AIContextProviders = [skillsProvider],
},
model: deploymentName);
// --- Example: Unit conversion ---
Console.WriteLine("Converting units with code-defined skills");
Console.WriteLine(new string('-', 60));
AgentResponse response = await agent.RunAsync(
"How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?");
Console.WriteLine($"Agent: {response.Text}");
@@ -0,0 +1,52 @@
# Code-Defined Agent Skills Sample
This sample demonstrates how to define **Agent Skills entirely in code** using `AgentInlineSkill`.
## What it demonstrates
- Creating skills programmatically with `AgentInlineSkill` — no SKILL.md files needed
- **Static resources** via `AddResource` with inline content
- **Dynamic resources** via `AddResource` with a factory delegate (computed at runtime)
- **Code scripts** via `AddScript` with a delegate handler
- Using the `AgentSkillsProvider` constructor with inline skills
## Skills Included
### unit-converter (code-defined)
Converts between common units using multiplication factors. Defined entirely in C# code:
- `conversion-table` — Static resource with factor table
- `conversion-policy` — Dynamic resource with formatting rules (generated at runtime)
- `convert` — Script that performs `value × factor` conversion
## Running the Sample
### Prerequisites
- .NET 10.0 SDK
- Azure OpenAI endpoint with a deployed model
### Setup
```bash
export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
```
### Run
```bash
dotnet run
```
### Expected Output
```
Converting units with code-defined skills
------------------------------------------------------------
Agent: Here are your conversions:
1. **26.2 miles → 42.16 km** (a marathon distance)
2. **75 kg → 165.35 lbs**
```
+18 -1
View File
@@ -1,7 +1,24 @@
# AgentSkills Samples
Samples demonstrating Agent Skills capabilities.
Samples demonstrating Agent Skills capabilities. Each sample shows a different way to define and use skills.
| Sample | Description |
|--------|-------------|
| [Agent_Step01_FileBasedSkills](Agent_Step01_FileBasedSkills/) | Define skills as `SKILL.md` files on disk with reference documents. Uses a unit-converter skill. |
| [Agent_Step02_CodeDefinedSkills](Agent_Step02_CodeDefinedSkills/) | Define skills entirely in C# code using `AgentInlineSkill`, with static/dynamic resources and scripts. |
## Key Concepts
### File-Based vs Code-Defined Skills
| Aspect | File-Based | Code-Defined |
|--------|-----------|--------------|
| Definition | `SKILL.md` files on disk | `AgentInlineSkill` instances in C# |
| Resources | All files in skill directory (filtered by extension) | `AddResource` (static value or delegate-backed) |
| Scripts | Supported via script executor delegate | `AddScript` delegates |
| Discovery | Automatic from directory path | Explicit via constructor |
| Dynamic content | No (static files only) | Yes (factory delegates) |
| Reusability | Copy skill directory | Inline or shared instances |
For single-source scenarios, use the `AgentSkillsProvider` constructors directly. To combine multiple skill types, use the `AgentSkillsProviderBuilder`.
@@ -16,7 +16,7 @@ using Qdrant.Client;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large";
var afOverviewUrl = "https://github.com/MicrosoftDocs/semantic-kernel-docs/blob/main/agent-framework/overview/agent-framework-overview.md";
var afOverviewUrl = "https://raw.githubusercontent.com/MicrosoftDocs/semantic-kernel-docs/refs/heads/main/agent-framework/overview/index.md";
var afMigrationUrl = "https://raw.githubusercontent.com/MicrosoftDocs/semantic-kernel-docs/refs/heads/main/agent-framework/migration-guide/from-semantic-kernel/index.md";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
@@ -1,15 +1,16 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how the ChatClientAgent persists chat history after each individual
// call to the AI service.
// call to the AI service, using the RequirePerServiceCallChatHistoryPersistence option.
// When an agent uses tools, FunctionInvokingChatClient may loop multiple times
// (service call → tool execution → service call), and intermediate messages (tool calls and
// results) are persisted after each service call. This allows you to inspect or recover them
// even if the process is interrupted mid-loop, but may also result in chat history that is not
// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
//
// To opt into end-of-run persistence instead (atomic run semantics), set
// PersistChatHistoryAtEndOfRun = true on ChatClientAgentOptions.
// To use end-of-run persistence instead (atomic run semantics), remove the
// RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run
// persistence is the default behavior.
//
// The sample runs two multi-turn conversations: one using non-streaming (RunAsync) and one
// using streaming (RunStreamingAsync), to demonstrate correct behavior in both modes.
@@ -53,7 +54,7 @@ static string GetTime([Description("The city name.")] string city) =>
_ => $"{city}: time data not available."
};
// Create the agent — per-service-call persistence is the default behavior.
// Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence.
// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
IChatClient chatClient = string.Equals(store, "TRUE", StringComparison.OrdinalIgnoreCase) ?
@@ -63,6 +64,7 @@ AIAgent agent = chatClient.AsAIAgent(
new ChatClientAgentOptions
{
Name = "WeatherAssistant",
RequirePerServiceCallChatHistoryPersistence = true,
ChatOptions = new()
{
Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
@@ -1,16 +1,19 @@
# In-Function-Loop Checkpointing
This sample demonstrates how `ChatClientAgent` persists chat history after each individual call to the AI service by default. This per-service-call persistence ensures intermediate progress is saved during the function invocation loop.
This sample demonstrates how `ChatClientAgent` can persist chat history after each individual call to the AI service using the `RequirePerServiceCallChatHistoryPersistence` option. This per-service-call persistence ensures intermediate progress is saved during the function invocation loop.
## What This Sample Shows
When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By default, chat history is persisted after each service call via the `ChatHistoryPersistingChatClient` decorator:
When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By enabling `RequirePerServiceCallChatHistoryPersistence = true`, chat history is persisted after each service call via the `PerServiceCallChatHistoryPersistingChatClient` decorator:
- A `ChatHistoryPersistingChatClient` decorator is automatically inserted into the chat client pipeline
- A `PerServiceCallChatHistoryPersistingChatClient` decorator is inserted into the chat client pipeline
- Before each service call, the decorator loads history from the `ChatHistoryProvider` and prepends it to the request
- After each service call, the decorator notifies the `ChatHistoryProvider` (and any `AIContextProvider` instances) with the new messages
- Only **new** messages are sent to providers on each notification — messages that were already persisted in an earlier call within the same run are deduplicated automatically
To opt into end-of-run persistence instead (atomic run semantics), set `PersistChatHistoryAtEndOfRun = true` on `ChatClientAgentOptions`. In that mode, the decorator marks messages with metadata rather than persisting them immediately, and `ChatClientAgent` persists only the marked messages at the end of the run.
By default (without `RequirePerServiceCallChatHistoryPersistence`), chat history is persisted at the end of the full agent run instead. To use per-service-call persistence, set `RequirePerServiceCallChatHistoryPersistence = true` on `ChatClientAgentOptions`.
With `RequirePerServiceCallChatHistoryPersistence` = true, the behavior matches that of chat history stored in the underlying AI service exactly.
Per-service-call persistence is useful for:
- **Crash recovery** — if the process is interrupted mid-loop, the intermediate tool calls and results are already persisted
@@ -26,7 +29,7 @@ The sample asks the agent about the weather and time in three cities. The model
```
ChatClientAgent
└─ FunctionInvokingChatClient (handles tool call loop)
└─ ChatHistoryPersistingChatClient (persists after each service call)
└─ PerServiceCallChatHistoryPersistingChatClient (persists after each service call)
└─ Leaf IChatClient (Azure OpenAI)
```
+1 -1
View File
@@ -3,7 +3,7 @@
The agent framework samples are designed to help you get started with building AI-powered agents
from various providers.
The Agent Framework supports building agents using various infererence and inference-style services.
The Agent Framework supports building agents using various inference and inference-style services.
All these are supported using the single `ChatClientAgent` class.
The Agent Framework also supports creating proxy agents, that allow accessing remote agents as if they
@@ -0,0 +1,284 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsIChatClientWithStoredOutputDisabled(OpenAI.Responses.ResponsesClient)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider)</Target>
<Left>lib/net472/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider)</Target>
<Left>lib/net472/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsIChatClientWithStoredOutputDisabled(OpenAI.Responses.ResponsesClient)</Target>
<Left>lib/net472/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsIChatClientWithStoredOutputDisabled(OpenAI.Responses.ResponsesClient)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsIChatClientWithStoredOutputDisabled(OpenAI.Responses.ResponsesClient)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsAIAgent(OpenAI.Responses.ResponsesClient,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},Microsoft.Extensions.Logging.ILoggerFactory,System.IServiceProvider)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:OpenAI.Responses.OpenAIResponseClientExtensions.AsIChatClientWithStoredOutputDisabled(OpenAI.Responses.ResponsesClient)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/net472/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -13,6 +13,11 @@
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<!-- Package not yet published to NuGet — disable baseline validation until first release -->
<PropertyGroup>
<EnablePackageValidation>false</EnablePackageValidation>
</PropertyGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Declarative Workflows MCP</Title>
@@ -0,0 +1,319 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Config</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Config`1</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.ConfigurationExtensions</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured`1</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured`2</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Config</Target>
<Left>lib/net472/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Config`1</Target>
<Left>lib/net472/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.ConfigurationExtensions</Target>
<Left>lib/net472/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured</Target>
<Left>lib/net472/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured`1</Target>
<Left>lib/net472/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured`2</Target>
<Left>lib/net472/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Config</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Config`1</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.ConfigurationExtensions</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured`1</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured`2</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Config</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Config`1</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.ConfigurationExtensions</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured`1</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured`2</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Config</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Config`1</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.ConfigurationExtensions</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured`1</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.Workflows.Configured`2</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.AgentWorkflowBuilder.CreateHandoffBuilderWith(Microsoft.Agents.AI.AIAgent)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.BindExecutor``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.ConfigureFactory``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.AgentWorkflowBuilder.CreateHandoffBuilderWith(Microsoft.Agents.AI.AIAgent)</Target>
<Left>lib/net472/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.BindExecutor``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1)</Target>
<Left>lib/net472/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.ConfigureFactory``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1)</Target>
<Left>lib/net472/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.AgentWorkflowBuilder.CreateHandoffBuilderWith(Microsoft.Agents.AI.AIAgent)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.BindExecutor``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.ConfigureFactory``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.AgentWorkflowBuilder.CreateHandoffBuilderWith(Microsoft.Agents.AI.AIAgent)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.BindExecutor``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.ConfigureFactory``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.AgentWorkflowBuilder.CreateHandoffBuilderWith(Microsoft.Agents.AI.AIAgent)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.BindExecutor``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.ExecutorBindingExtensions.ConfigureFactory``2(System.Func{Microsoft.Agents.AI.Workflows.Config{``1},System.String,System.Threading.Tasks.ValueTask{``0}},System.String,``1)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -139,8 +139,8 @@ public sealed partial class ChatClientAgent : AIAgent
this._logger = (loggerFactory ?? chatClient.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
// Warn if using a custom chat client stack with end-of-run persistence but no ChatHistoryPersistingChatClient.
this.WarnOnMissingPersistingClient();
// Warn if using a custom chat client stack with simulated service stored persistence but no PerServiceCallChatHistoryPersistingChatClient.
this.WarnOnMissingPerServiceCallChatHistoryPersistingChatClient();
}
/// <summary>
@@ -454,7 +454,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// Notifies the <see cref="ChatHistoryProvider"/> and all <see cref="AIContextProviders"/> of successfully completed messages.
/// </summary>
/// <remarks>
/// This method is also called by <see cref="ChatHistoryPersistingChatClient"/> to persist messages per-service-call.
/// This method is also called by <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to persist messages per-service-call.
/// </remarks>
internal async Task NotifyProvidersOfNewMessagesAsync(
ChatClientAgentSession session,
@@ -463,7 +463,7 @@ public sealed partial class ChatClientAgent : AIAgent
ChatOptions? chatOptions,
CancellationToken cancellationToken)
{
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions, session);
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions);
if (chatHistoryProvider is not null)
{
@@ -486,7 +486,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// Notifies the <see cref="ChatHistoryProvider"/> and all <see cref="AIContextProviders"/> of a failure during a service call.
/// </summary>
/// <remarks>
/// This method is also called by <see cref="ChatHistoryPersistingChatClient"/> to report failures per-service-call.
/// This method is also called by <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to report failures per-service-call.
/// </remarks>
internal async Task NotifyProvidersOfFailureAsync(
ChatClientAgentSession session,
@@ -495,7 +495,7 @@ public sealed partial class ChatClientAgent : AIAgent
ChatOptions? chatOptions,
CancellationToken cancellationToken)
{
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions, session);
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions);
if (chatHistoryProvider is not null)
{
@@ -701,7 +701,7 @@ public sealed partial class ChatClientAgent : AIAgent
throw new InvalidOperationException("A session must be provided when continuing a background response with a continuation token.");
}
if ((continuationToken is not null || chatOptions?.AllowBackgroundResponses is true) && this.PersistsChatHistoryPerServiceCall && this._logger.IsEnabled(LogLevel.Warning))
if ((continuationToken is not null || chatOptions?.AllowBackgroundResponses is true) && this.RequiresPerServiceCallChatHistoryPersistence && this._logger.IsEnabled(LogLevel.Warning))
{
var warningAgentName = this.GetLoggingAgentName();
this._logger.LogAgentChatClientBackgroundResponseFallback(this.Id, warningAgentName);
@@ -719,57 +719,6 @@ public sealed partial class ChatClientAgent : AIAgent
throw new InvalidOperationException("Input messages are not allowed when continuing a background response using a continuation token.");
}
IEnumerable<ChatMessage> inputMessagesForChatClient = inputMessages;
// Populate the session messages only if we are not continuing an existing response as it's not allowed
if (chatOptions?.ContinuationToken is null)
{
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions, typedSession);
// Add any existing messages from the session to the messages to be sent to the chat client.
// The ChatHistoryProvider returns the merged result (history + input messages).
if (chatHistoryProvider is not null)
{
var invokingContext = new ChatHistoryProvider.InvokingContext(this, typedSession, inputMessagesForChatClient);
inputMessagesForChatClient = await chatHistoryProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
}
// If we have an AIContextProvider, we should get context from it, and update our
// messages and options with the additional context.
// The AIContextProvider returns the accumulated AIContext (original + new contributions).
if (this.AIContextProviders is { Count: > 0 } aiContextProviders)
{
var aiContext = new AIContext
{
Instructions = chatOptions?.Instructions,
Messages = inputMessagesForChatClient,
Tools = chatOptions?.Tools
};
foreach (var aiContextProvider in aiContextProviders)
{
var invokingContext = new AIContextProvider.InvokingContext(this, typedSession, aiContext);
aiContext = await aiContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
}
// Materialize the accumulated messages and tools once at the end of the provider pipeline.
inputMessagesForChatClient = aiContext.Messages ?? [];
var tools = aiContext.Tools as IList<AITool> ?? aiContext.Tools?.ToList();
if (chatOptions?.Tools is { Count: > 0 } || tools is { Count: > 0 })
{
chatOptions ??= new();
chatOptions.Tools = tools;
}
if (chatOptions?.Instructions is not null || aiContext.Instructions is not null)
{
chatOptions ??= new();
chatOptions.Instructions = aiContext.Instructions;
}
}
}
// If a user provided two different session ids, via the session object and options, we should throw
// since we don't know which one to use.
if (!string.IsNullOrWhiteSpace(typedSession.ConversationId) && !string.IsNullOrWhiteSpace(chatOptions?.ConversationId) && typedSession.ConversationId != chatOptions!.ConversationId)
@@ -788,12 +737,53 @@ public sealed partial class ChatClientAgent : AIAgent
chatOptions.ConversationId = typedSession.ConversationId;
}
// When per-service-call persistence is active, set a sentinel conversation ID so that
// FunctionInvokingChatClient treats locally-persisted history the same as service-managed
// history. This prevents it from adding duplicate FunctionCallContent messages into the
// request when processing approval responses — the loaded history already contains them.
// ChatHistoryPersistingChatClient strips the sentinel before forwarding to the inner client.
chatOptions = this.SetLocalHistoryConversationIdIfNeeded(chatOptions);
IEnumerable<ChatMessage> inputMessagesForChatClient = inputMessages;
// Populate the session messages only if we are not continuing an existing response as it's not allowed.
// When RequirePerServiceCallChatHistoryPersistence is active, the PerServiceCallChatHistoryPersistingChatClient
// owns the chat history lifecycle — it loads history before each service call. The agent
// must not load history itself, as that would result in duplicate messages.
if (chatOptions?.ContinuationToken is null && !this.RequiresPerServiceCallChatHistoryPersistence)
{
// Add any existing messages from the session to the messages to be sent to the chat client.
// The ChatHistoryProvider returns the merged result (history + input messages).
inputMessagesForChatClient = await this.LoadChatHistoryAsync(typedSession, inputMessagesForChatClient, chatOptions, cancellationToken).ConfigureAwait(false);
}
// AIContextProviders should always be invoked (unless continuing an existing response)
// to contribute additional messages, tools, and instructions — even when the decorator
// handles history loading.
if (chatOptions?.ContinuationToken is null && this.AIContextProviders is { Count: > 0 } aiContextProviders)
{
var aiContext = new AIContext
{
Instructions = chatOptions?.Instructions,
Messages = inputMessagesForChatClient,
Tools = chatOptions?.Tools
};
foreach (var aiContextProvider in aiContextProviders)
{
var invokingContext = new AIContextProvider.InvokingContext(this, typedSession, aiContext);
aiContext = await aiContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
}
// Materialize the accumulated messages and tools once at the end of the provider pipeline.
inputMessagesForChatClient = aiContext.Messages ?? [];
var tools = aiContext.Tools as IList<AITool> ?? aiContext.Tools?.ToList();
if (chatOptions?.Tools is { Count: > 0 } || tools is { Count: > 0 })
{
chatOptions ??= new();
chatOptions.Tools = tools;
}
if (chatOptions?.Instructions is not null || aiContext.Instructions is not null)
{
chatOptions ??= new();
chatOptions.Instructions = aiContext.Instructions;
}
}
// Materialize the accumulated messages once at the end of the provider pipeline, reusing the existing list if possible.
List<ChatMessage> messagesList = inputMessagesForChatClient as List<ChatMessage> ?? inputMessagesForChatClient.ToList();
@@ -839,8 +829,6 @@ public sealed partial class ChatClientAgent : AIAgent
}
}
// If we got a conversation id back from the chat client, it means that the service supports server side session storage
// so we should update the session with the new id.
session.ConversationId = responseConversationId;
}
}
@@ -849,14 +837,14 @@ public sealed partial class ChatClientAgent : AIAgent
/// Updates the session conversation ID at the end of an agent run.
/// </summary>
/// <remarks>
/// When a <see cref="ChatHistoryPersistingChatClient"/> in persist mode handles per-service-call
/// conversation ID updates, this end-of-run update is skipped. When the decorator is in mark-only
/// mode or absent, the update is performed here. When <paramref name="forceUpdate"/> is <see langword="true"/>
/// When a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> handles per-service-call
/// conversation ID updates, this end-of-run update is skipped. When the decorator is
/// absent, the update is performed here. When <paramref name="forceUpdate"/> is <see langword="true"/>
/// (continuation token scenarios), the update is always performed.
/// </remarks>
private void UpdateSessionConversationIdAtEndOfRun(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken, bool forceUpdate = false)
{
if (!forceUpdate && this.PersistsChatHistoryPerServiceCall)
if (!forceUpdate && this.RequiresPerServiceCallChatHistoryPersistence)
{
return;
}
@@ -868,10 +856,9 @@ public sealed partial class ChatClientAgent : AIAgent
/// Notifies providers of successfully completed messages at the end of an agent run.
/// </summary>
/// <remarks>
/// When a <see cref="ChatHistoryPersistingChatClient"/> in persist mode handles per-service-call
/// notification, this end-of-run notification is skipped. When the decorator is in mark-only mode,
/// only the marked messages are persisted. When no decorator is present (custom stack with
/// <see cref="ChatClientAgentOptions.PersistChatHistoryAtEndOfRun"/>), all messages are persisted.
/// When a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> handles per-service-call
/// notification, this end-of-run notification is skipped. When no decorator is present,
/// all messages are persisted.
/// When <paramref name="forceNotify"/> is <see langword="true"/> (continuation token or
/// background response scenarios), notification is always performed with all messages because
/// per-service-call persistence is unreliable in these scenarios.
@@ -884,19 +871,11 @@ public sealed partial class ChatClientAgent : AIAgent
CancellationToken cancellationToken,
bool forceNotify = false)
{
if (!forceNotify && this.PersistsChatHistoryPerServiceCall)
if (!forceNotify && this.RequiresPerServiceCallChatHistoryPersistence)
{
return Task.CompletedTask;
}
if (!forceNotify && this.HasMarkOnlyChatHistoryPersistingClient)
{
// In mark-only mode, persist only messages that were marked by the decorator.
var markedRequestMessages = GetMarkedMessages(requestMessages);
var markedResponseMessages = GetMarkedMessages(responseMessages);
return this.NotifyProvidersOfNewMessagesAsync(session, markedRequestMessages, markedResponseMessages, chatOptions, cancellationToken);
}
return this.NotifyProvidersOfNewMessagesAsync(session, requestMessages, responseMessages, chatOptions, cancellationToken);
}
@@ -904,7 +883,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// Notifies providers of a failure at the end of an agent run.
/// </summary>
/// <remarks>
/// When a <see cref="ChatHistoryPersistingChatClient"/> in persist mode handles per-service-call
/// When a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> handles per-service-call
/// notification (including failure), this end-of-run notification is skipped to avoid
/// duplicate notification. In all other cases, failure is reported at the end of the run.
/// </remarks>
@@ -915,7 +894,7 @@ public sealed partial class ChatClientAgent : AIAgent
ChatOptions? chatOptions,
CancellationToken cancellationToken)
{
if (this.PersistsChatHistoryPerServiceCall)
if (this.RequiresPerServiceCallChatHistoryPersistence)
{
return Task.CompletedTask;
}
@@ -924,60 +903,19 @@ public sealed partial class ChatClientAgent : AIAgent
}
/// <summary>
/// Gets a value indicating whether the agent has a <see cref="ChatHistoryPersistingChatClient"/>
/// decorator in persist mode (not mark-only), which handles per-service-call persistence.
/// Gets a value indicating whether the agent is configured to simulate service-stored chat history.
/// When <see langword="true"/>, end-of-run persistence and history loading are skipped because a
/// per-service-call decorator (such as <see cref="PerServiceCallChatHistoryPersistingChatClient"/> or a
/// user-supplied equivalent) is expected to handle the history lifecycle.
/// </summary>
private bool PersistsChatHistoryPerServiceCall
private bool RequiresPerServiceCallChatHistoryPersistence
{
get
{
var persistingClient = this.ChatClient.GetService<ChatHistoryPersistingChatClient>();
return persistingClient?.MarkOnly == false;
return this._agentOptions?.RequirePerServiceCallChatHistoryPersistence is true;
}
}
/// <summary>
/// Sets the <see cref="ChatHistoryPersistingChatClient.LocalHistoryConversationId"/> sentinel on
/// <paramref name="chatOptions"/> when per-service-call persistence is active and no real
/// conversation ID is present.
/// </summary>
/// <returns>
/// The (possibly new) <see cref="ChatOptions"/> with the sentinel set, or the original
/// <paramref name="chatOptions"/> if no sentinel is needed.
/// </returns>
private ChatOptions? SetLocalHistoryConversationIdIfNeeded(ChatOptions? chatOptions)
{
if (this.PersistsChatHistoryPerServiceCall && string.IsNullOrWhiteSpace(chatOptions?.ConversationId))
{
chatOptions ??= new ChatOptions();
chatOptions.ConversationId = ChatHistoryPersistingChatClient.LocalHistoryConversationId;
}
return chatOptions;
}
/// <summary>
/// Gets a value indicating whether the agent has a <see cref="ChatHistoryPersistingChatClient"/>
/// decorator in mark-only mode, which marks messages for later persistence at the end of the run.
/// </summary>
private bool HasMarkOnlyChatHistoryPersistingClient
{
get
{
var persistingClient = this.ChatClient.GetService<ChatHistoryPersistingChatClient>();
return persistingClient?.MarkOnly == true;
}
}
/// <summary>
/// Returns only the messages that have been marked as persisted by a <see cref="ChatHistoryPersistingChatClient"/> in mark-only mode.
/// </summary>
private static List<ChatMessage> GetMarkedMessages(IEnumerable<ChatMessage> messages)
{
return messages.Where(m =>
m.AdditionalProperties?.TryGetValue(ChatHistoryPersistingChatClient.PersistedMarkerKey, out var value) == true && value is true).ToList();
}
/// <summary>
/// Ensures that <see cref="AIAgent.CurrentRunContext"/> contains the resolved session.
/// </summary>
@@ -985,7 +923,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// The base class sets <see cref="AIAgent.CurrentRunContext"/> with the raw session parameter
/// (which may be null) and restores it after each yield in streaming scenarios. After
/// <see cref="PrepareSessionAndMessagesAsync"/> resolves or creates a session, we update the
/// context so the <see cref="ChatHistoryPersistingChatClient"/> decorator always has a valid session.
/// context so the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> decorator always has a valid session.
/// The original agent from the context is preserved to maintain the top-of-stack agent in
/// decorated agent scenarios.
/// </remarks>
@@ -1001,36 +939,36 @@ public sealed partial class ChatClientAgent : AIAgent
/// <summary>
/// Checks for potential misconfiguration when using a custom chat client stack and logs warnings.
/// </summary>
private void WarnOnMissingPersistingClient()
private void WarnOnMissingPerServiceCallChatHistoryPersistingChatClient()
{
if (this._agentOptions?.UseProvidedChatClientAsIs is not true)
{
return;
}
if (this._agentOptions?.PersistChatHistoryAtEndOfRun is not true)
if (this._agentOptions?.RequirePerServiceCallChatHistoryPersistence is not true)
{
return;
}
var persistingClient = this.ChatClient.GetService<ChatHistoryPersistingChatClient>();
var persistingClient = this.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
if (persistingClient is null && this._logger.IsEnabled(LogLevel.Warning))
{
var loggingAgentName = this.GetLoggingAgentName();
this._logger.LogAgentChatClientMissingPersistingClient(
this.Id,
loggingAgentName);
loggingAgentName); // CodeQL [CWE-359] False positive: Agent name is not personal information, but rather just the name of a code component (agent in this case).
}
}
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions, ChatClientAgentSession session)
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions)
{
ChatHistoryProvider? provider = session.ConversationId is null ? this.ChatHistoryProvider : null;
ChatHistoryProvider? provider = chatOptions?.ConversationId is null ? this.ChatHistoryProvider : null;
// If someone provided an override ChatHistoryProvider via AdditionalProperties, we should use that instead.
if (chatOptions?.AdditionalProperties?.TryGetValue(out ChatHistoryProvider? overrideProvider) is true)
{
if (session.ConversationId is not null && overrideProvider is not null)
if (this._agentOptions?.ThrowOnChatHistoryProviderConflict is true && string.IsNullOrWhiteSpace(chatOptions?.ConversationId) is false)
{
throw new InvalidOperationException(
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The current {nameof(ChatClientAgentSession)} has a {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management, but an override {nameof(this.ChatHistoryProvider)} was provided via {nameof(AgentRunOptions.AdditionalProperties)}.");
@@ -1055,6 +993,29 @@ public sealed partial class ChatClientAgent : AIAgent
return provider;
}
/// <summary>
/// Loads chat history from the resolved <see cref="ChatHistoryProvider"/> and prepends it to the given messages.
/// </summary>
/// <remarks>
/// This method is used by both the agent (during <see cref="PrepareSessionAndMessagesAsync"/>) and by
/// <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to load history before each service call.
/// </remarks>
internal async Task<IEnumerable<ChatMessage>> LoadChatHistoryAsync(
ChatClientAgentSession session,
IEnumerable<ChatMessage> messages,
ChatOptions? chatOptions,
CancellationToken cancellationToken)
{
var chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions);
if (chatHistoryProvider is null)
{
return messages;
}
var invokingContext = new ChatHistoryProvider.InvokingContext(this, session, messages);
return await chatHistoryProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
}
private static ChatClientAgentContinuationToken? WrapContinuationToken(ResponseContinuationToken? continuationToken, IEnumerable<ChatMessage>? inputMessages = null, List<ChatResponseUpdate>? responseUpdates = null)
{
if (continuationToken is null)
@@ -72,12 +72,12 @@ internal static partial class ChatClientAgentLogMessages
/// <summary>
/// Logs a warning when <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>
/// and <see cref="ChatClientAgentOptions.PersistChatHistoryAtEndOfRun"/> is <see langword="true"/>,
/// but no <see cref="ChatHistoryPersistingChatClient"/> is found in the custom chat client stack.
/// and <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>,
/// but no <see cref="PerServiceCallChatHistoryPersistingChatClient"/> is found in the custom chat client stack.
/// </summary>
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Agent {AgentId}/{AgentName}: PersistChatHistoryAtEndOfRun is enabled with a custom chat client stack (UseProvidedChatClientAsIs), but no ChatHistoryPersistingChatClient was found in the pipeline. All messages will be persisted at the end of the run without marking. This setup is not supported with some other features, e.g. handoffs. Consider adding a ChatHistoryPersistingChatClient to the pipeline using the UseChatHistoryPersisting extension method.")]
Message = "Agent {AgentId}/{AgentName}: RequirePerServiceCallChatHistoryPersistence is enabled with a custom chat client stack (UseProvidedChatClientAsIs), but no PerServiceCallChatHistoryPersistingChatClient was found in the pipeline. Chat history will not be persisted by ChatClientAgent. Consider adding a PerServiceCallChatHistoryPersistingChatClient to the pipeline using the UsePerServiceCallChatHistoryPersistence extension method if you have not added your own persistence mechanism.")]
public static partial void LogAgentChatClientMissingPersistingClient(
this ILogger logger,
string agentId,
@@ -92,7 +92,7 @@ internal static partial class ChatClientAgentLogMessages
/// </summary>
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Agent {AgentId}/{AgentName}: Per-service-call persistence is falling back to end-of-run persistence because the run involves background responses. Messages will be marked during the run and persisted at the end.")]
Message = "Agent {AgentId}/{AgentName}: RequirePerServiceCallChatHistoryPersistence is enabled but we have to fall back to end-of-run persistence because the run involves background responses.")]
public static partial void LogAgentChatClientBackgroundResponseFallback(
this ILogger logger,
string agentId,
@@ -92,54 +92,64 @@ public sealed class ChatClientAgentOptions
public bool ThrowOnChatHistoryProviderConflict { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether to persist chat history only at the end of the full agent run
/// rather than after each individual service call.
/// Gets or sets a value indicating whether the <see cref="ChatClientAgent"/> should persist
/// chat history after each individual service call within the <see cref="FunctionInvokingChatClient"/>
/// loop, rather than at the end of the full agent run.
/// </summary>
/// <remarks>
/// <para>
/// By default, <see cref="ChatClientAgent"/> persists request and response messages either via
/// a <see cref="ChatHistoryProvider"/>, or the underlying AI service's chat history storage.
/// Persistence is done immediately after each call to the AI service within the function invocation loop.
/// When storing in the underlying AI service, the session's <see cref="ChatClientAgentSession.ConversationId"/>
/// is also updated after each service call, keeping it in sync with the service-side conversation state.
/// When set to <see langword="true"/>, a <see cref="PerServiceCallChatHistoryPersistingChatClient"/>
/// decorator becomes active in the chat client pipeline. It handles two complementary scenarios:
/// </para>
/// <list type="bullet">
/// <item>
/// <term>Framework-managed chat history</term>
/// <description>
/// The decorator loads history from the <see cref="ChatHistoryProvider"/> before each service call
/// and persists new request and response messages after each call. It returns a sentinel
/// <see cref="ChatOptions.ConversationId"/> on the response, causing the
/// <see cref="FunctionInvokingChatClient"/> to treat the conversation as service-managed — clearing
/// accumulated history between iterations and not injecting duplicate <see cref="FunctionCallContent"/>
/// during approval-response processing.
/// </description>
/// </item>
/// <item>
/// <term>AI Service-stored chat history</term>
/// <description>
/// When the service manages its own chat history (returning a real <see cref="ChatOptions.ConversationId"/>),
/// the decorator updates <see cref="ChatClientAgentSession.ConversationId"/> after each service call so
/// that intermediate ConversationId changes are captured immediately. For some services (e.g., the
/// Conversations API with the Responses API), there is only one thread with one ID, so every service
/// call updates it anyway and updating the <see cref="ChatClientAgentSession.ConversationId"/> has little effect
/// since it's the same ID. For other services (e.g., Responses API with Response IDs), a new ID is generated
/// with each service call, so updating the <see cref="ChatClientAgentSession.ConversationId"/> ensures that the
/// latest ID is always captured, even mid-run.
/// Enabling this option ensures consistent per-service-call behavior across all service types.
/// </description>
/// </item>
/// </list>
/// <para>
/// When set to <see langword="false"/> (the default), the <see cref="ChatClientAgent"/> handles
/// chat history persistence at the end of the full agent run via the <see cref="ChatHistoryProvider"/> if using
/// framework-managed chat history. For AI service-stored chat history, the <see cref="ChatClientAgentSession.ConversationId"/>
/// updates happen only at the end of the run.
/// </para>
/// <para>
/// Setting this property to <see langword="true"/> causes messages to be marked during the function
/// invocation loop but persisted only at the end of the full agent run, providing atomic run semantics.
/// Updating the <see cref="ChatClientAgentSession.ConversationId"/> is likewise deferred and
/// updated only at the end of the run, consistent with atomic run semantics.
/// A <see cref="ChatHistoryPersistingChatClient"/> decorator is inserted into the chat client pipeline
/// in mark-only mode, and the <see cref="ChatClientAgent"/> persists only the marked messages at the
/// end of the run.
/// </para>
/// <para>
/// When this option is <see langword="false"/> (the default), the <see cref="ChatHistoryPersistingChatClient"/>
/// decorator persists messages and updates the <see cref="ChatClientAgentSession.ConversationId"/>
/// immediately after each service call. This may leave chat history in a state where
/// <see cref="FunctionResultContent"/> is required to start a new run if the last successful service
/// call returned <see cref="FunctionCallContent"/>.
/// </para>
/// <para>
/// This option has no effect when <see cref="UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When using a custom chat client stack, you can add a <see cref="ChatHistoryPersistingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UseChatHistoryPersisting"/>
/// When setting the <see cref="UseProvidedChatClientAsIs"/> setting to <see langword="true"/> and
/// <see cref="RequirePerServiceCallChatHistoryPersistence"/> to <see langword="true"/>, ensure that your custom chat client stack includes a
/// <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to enable per-service-call persistence.
/// If no <see cref="PerServiceCallChatHistoryPersistingChatClient"/> is provided, and you are not storing chat history via other means,
/// no chat history may be stored.
/// When using a custom chat client stack, you can add a <see cref="PerServiceCallChatHistoryPersistingChatClient"/>
/// manually via the <see cref="ChatClientBuilderExtensions.UsePerServiceCallChatHistoryPersistence"/>
/// extension method.
/// </para>
/// <para>
/// Note that when using single threaded service stored chat history, like OpenAI Conversations,
/// there is only one id, so even if the conversation id is not updated after each service call,
/// the chat history will still contain intermediate messages. Setting this property to <see langword="true"/>
/// in this case will therefore have no real effect. Setting this property to <see langword="true"/> when using
/// OpenAI Responses with response ids on the other hand, allows atomic run semantics, since
/// each service request produces a new response id, and if the run fails mid-loop, the session will
/// still contain the pre-run respnose id, allowing the next run to start with a clean slate.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool PersistChatHistoryAtEndOfRun { get; set; }
public bool RequirePerServiceCallChatHistoryPersistence { get; set; }
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
@@ -157,6 +167,6 @@ public sealed class ChatClientAgentOptions
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
PersistChatHistoryAtEndOfRun = this.PersistChatHistoryAtEndOfRun,
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
};
}
@@ -86,25 +86,21 @@ public static class ChatClientBuilderExtensions
services: services);
/// <summary>
/// Adds a <see cref="ChatHistoryPersistingChatClient"/> to the chat client pipeline.
/// Adds a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// This decorator should be positioned between the <see cref="FunctionInvokingChatClient"/> and the leaf
/// <see cref="IChatClient"/> in the pipeline. It intercepts service calls to either persist messages
/// immediately or mark them for later persistence, depending on the <paramref name="markOnly"/> parameter.
/// </para>
/// <para>
/// If <paramref name="markOnly"/> is set to <see langword="true"/>, the <see cref="ChatClientAgent"/>
/// should be configured with <see cref="ChatClientAgentOptions.PersistChatHistoryAtEndOfRun"/> set to <see langword="true"/>
/// as without this combination, messages will never be persisted when using a <see cref="ChatHistoryProvider"/> for
/// chat history persistence.
/// <see cref="IChatClient"/> in the pipeline. It persists chat history after each individual service call
/// and updates the session <see cref="ChatOptions.ConversationId"/> per call for both framework-managed
/// and service-stored chat history scenarios.
/// </para>
/// <para>
/// This extension method is intended for use with custom chat client stacks when
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
/// the <see cref="ChatClientAgent"/> automatically injects this decorator.
/// the <see cref="ChatClientAgent"/> automatically includes this decorator in the pipeline and activates it when
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>.
/// </para>
/// <para>
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> and will throw an
@@ -112,18 +108,10 @@ public static class ChatClientBuilderExtensions
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
/// <param name="markOnly">
/// When <see langword="true"/>, messages are marked with metadata but not persisted immediately,
/// and the session's <see cref="ChatClientAgentSession.ConversationId"/> is not updated.
/// The <see cref="ChatClientAgent"/> will persist only the marked messages and update the
/// conversation ID at the end of the run.
/// When <see langword="false"/> (the default), messages are persisted and the conversation ID
/// is updated immediately after each service call.
/// </param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static ChatClientBuilder UseChatHistoryPersisting(this ChatClientBuilder builder, bool markOnly = false)
public static ChatClientBuilder UsePerServiceCallChatHistoryPersistence(this ChatClientBuilder builder)
{
return builder.Use(innerClient => new ChatHistoryPersistingChatClient(innerClient, markOnly));
return builder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
}
}
@@ -63,14 +63,17 @@ public static class ChatClientExtensions
});
}
// ChatHistoryPersistingChatClient is registered after FunctionInvokingChatClient so that it sits
// between FIC and the leaf client. ChatClientBuilder.Build applies factories in reverse order,
// making the first Use() call outermost. By adding our decorator second, the resulting pipeline is:
// FunctionInvokingChatClient → ChatHistoryPersistingChatClient → leaf IChatClient
// This allows the decorator to persist messages after each individual service call within
// FIC's function invocation loop, or to mark them for later persistence at the end of the run.
bool markOnly = options?.PersistChatHistoryAtEndOfRun is true;
chatBuilder.Use(innerClient => new ChatHistoryPersistingChatClient(innerClient, markOnly));
// PerServiceCallChatHistoryPersistingChatClient is only injected when RequirePerServiceCallChatHistoryPersistence is enabled.
// It is registered after FunctionInvokingChatClient so that it sits between FIC and the leaf client.
// ChatClientBuilder.Build applies factories in reverse order, making the first Use() call outermost.
// By adding our decorator second, the resulting pipeline is:
// FunctionInvokingChatClient → PerServiceCallChatHistoryPersistingChatClient → leaf IChatClient
// This allows the decorator to simulate service-stored chat history by loading history before
// each service call, persisting after each call, and returning a sentinel ConversationId.
if (options?.RequirePerServiceCallChatHistoryPersistence is true)
{
chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
}
var agentChatClient = chatBuilder.Build(services);
@@ -1,351 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating chat client that notifies <see cref="ChatHistoryProvider"/> and <see cref="AIContextProvider"/>
/// instances of request and response messages after each individual call to the inner chat client,
/// or marks messages for later persistence depending on the configured mode.
/// </summary>
/// <remarks>
/// <para>
/// This decorator is intended to operate between the <see cref="FunctionInvokingChatClient"/> and the leaf
/// <see cref="IChatClient"/> in a <see cref="ChatClientAgent"/> pipeline.
/// </para>
/// <para>
/// In persist mode (the default), it ensures that providers are notified and the session's
/// <see cref="ChatClientAgentSession.ConversationId"/> is updated after each service call, so that
/// intermediate messages (e.g., tool calls and results) are saved even if the process is interrupted
/// mid-loop.
/// </para>
/// <para>
/// In mark-only mode (<see cref="MarkOnly"/> is <see langword="true"/>), it marks messages with metadata
/// but does not notify providers or update the <see cref="ChatClientAgentSession.ConversationId"/>.
/// Both are deferred to the <see cref="ChatClientAgent"/> at the end of the run, providing atomic
/// run semantics.
/// </para>
/// <para>
/// This chat client must be used within the context of a running <see cref="ChatClientAgent"/>. It retrieves the
/// current agent and session from <see cref="AIAgent.CurrentRunContext"/>, which is set automatically when an agent's
/// <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/> or
/// <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/>
/// method is called. The <see cref="ChatClientAgent"/> ensures the run context always contains a resolved session,
/// even when the caller passes null. An <see cref="InvalidOperationException"/> is thrown if no run context is
/// available or if the agent is not a <see cref="ChatClientAgent"/>.
/// </para>
/// </remarks>
internal sealed class ChatHistoryPersistingChatClient : DelegatingChatClient
{
/// <summary>
/// The key used in <see cref="ChatMessage.AdditionalProperties"/> and <see cref="AIContent.AdditionalProperties"/>
/// to mark messages and their content as already persisted to chat history.
/// </summary>
internal const string PersistedMarkerKey = "_chatHistoryPersisted";
/// <summary>
/// A sentinel value set on <see cref="ChatOptions.ConversationId"/> by <see cref="ChatClientAgent"/>
/// when per-service-call persistence is active and no real conversation ID exists.
/// </summary>
/// <remarks>
/// <para>
/// This signals to <see cref="FunctionInvokingChatClient"/> that the chat history is being managed
/// externally (by this decorator), which prevents it from adding duplicate <see cref="FunctionCallContent"/>
/// messages into the request during approval-response processing. Without this sentinel,
/// <see cref="FunctionInvokingChatClient"/> would reconstruct function-call messages from approval
/// responses and append them to the original messages — but the loaded history already contains
/// those same function calls, causing duplicate tool-call entries that the model rejects.
/// </para>
/// <para>
/// This decorator strips the sentinel before forwarding requests to the inner client, so the
/// underlying model never sees it.
/// </para>
/// </remarks>
internal const string LocalHistoryConversationId = "_agent_local_history";
/// <summary>
/// Initializes a new instance of the <see cref="ChatHistoryPersistingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying chat client that will handle the core operations.</param>
/// <param name="markOnly">
/// When <see langword="true"/>, messages are marked with metadata but not persisted immediately,
/// and the session's <see cref="ChatClientAgentSession.ConversationId"/> is not updated.
/// The <see cref="ChatClientAgent"/> will persist only the marked messages and update the
/// conversation ID at the end of the run.
/// When <see langword="false"/> (the default), messages are persisted and the conversation ID
/// is updated immediately after each service call.
/// </param>
public ChatHistoryPersistingChatClient(IChatClient innerClient, bool markOnly = false)
: base(innerClient)
{
this.MarkOnly = markOnly;
}
/// <summary>
/// Gets a value indicating whether this decorator is in mark-only mode.
/// </summary>
/// <remarks>
/// When <see langword="true"/>, messages are marked with metadata but not persisted immediately,
/// and the session's <see cref="ChatClientAgentSession.ConversationId"/> is not updated.
/// Both are deferred to the <see cref="ChatClientAgent"/> at the end of the run.
/// When <see langword="false"/>, messages are persisted and the conversation ID is updated
/// after each service call.
/// </remarks>
public bool MarkOnly { get; }
/// <inheritdoc/>
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var (agent, session) = GetRequiredAgentAndSession();
options = StripLocalHistoryConversationId(options);
ChatResponse response;
try
{
response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
var newRequestMessagesOnFailure = GetNewRequestMessages(messages);
await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
throw;
}
var newRequestMessages = GetNewRequestMessages(messages);
if (this.ShouldDeferPersistence(options))
{
// In mark-only mode or when resuming from a continuation token, just mark messages
// for later persistence by ChatClientAgent. Conversation ID and provider notification
// are deferred to end-of-run. For continuation tokens, the end-of-run handler needs
// to send the combined data from both the previous and current runs.
MarkAsPersisted(newRequestMessages);
MarkAsPersisted(response.Messages);
}
else
{
// In persist mode, persist immediately and update conversation ID.
agent.UpdateSessionConversationId(session, response.ConversationId, cancellationToken);
await agent.NotifyProvidersOfNewMessagesAsync(session, newRequestMessages, response.Messages, options, cancellationToken).ConfigureAwait(false);
MarkAsPersisted(newRequestMessages);
MarkAsPersisted(response.Messages);
}
return response;
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var (agent, session) = GetRequiredAgentAndSession();
options = StripLocalHistoryConversationId(options);
List<ChatResponseUpdate> responseUpdates = [];
IAsyncEnumerator<ChatResponseUpdate> enumerator;
try
{
enumerator = base.GetStreamingResponseAsync(messages, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
}
catch (Exception ex)
{
var newRequestMessagesOnFailure = GetNewRequestMessages(messages);
await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
throw;
}
bool hasUpdates;
try
{
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
var newRequestMessagesOnFailure = GetNewRequestMessages(messages);
await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = enumerator.Current;
responseUpdates.Add(update);
yield return update;
try
{
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
var newRequestMessagesOnFailure = GetNewRequestMessages(messages);
await agent.NotifyProvidersOfFailureAsync(session, ex, newRequestMessagesOnFailure, options, cancellationToken).ConfigureAwait(false);
throw;
}
}
var chatResponse = responseUpdates.ToChatResponse();
var newRequestMessages = GetNewRequestMessages(messages);
if (this.ShouldDeferPersistence(options))
{
// In mark-only mode or when resuming from a continuation token, just mark messages
// for later persistence by ChatClientAgent. Conversation ID and provider notification
// are deferred to end-of-run. For continuation tokens, the end-of-run handler needs
// to send the combined data from both the previous and current runs.
MarkAsPersisted(newRequestMessages);
MarkAsPersisted(chatResponse.Messages);
}
else
{
// In persist mode, persist immediately and update conversation ID.
agent.UpdateSessionConversationId(session, chatResponse.ConversationId, cancellationToken);
await agent.NotifyProvidersOfNewMessagesAsync(session, newRequestMessages, chatResponse.Messages, options, cancellationToken).ConfigureAwait(false);
MarkAsPersisted(newRequestMessages);
MarkAsPersisted(chatResponse.Messages);
}
}
/// <summary>
/// Gets the current <see cref="ChatClientAgent"/> and <see cref="ChatClientAgentSession"/> from the run context.
/// </summary>
private static (ChatClientAgent Agent, ChatClientAgentSession Session) GetRequiredAgentAndSession()
{
var runContext = AIAgent.CurrentRunContext
?? throw new InvalidOperationException(
$"{nameof(ChatHistoryPersistingChatClient)} can only be used within the context of a running AIAgent. " +
"Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
var chatClientAgent = runContext.Agent.GetService<ChatClientAgent>()
?? throw new InvalidOperationException(
$"{nameof(ChatHistoryPersistingChatClient)} can only be used with a {nameof(ChatClientAgent)}. " +
$"The current agent is of type '{runContext.Agent.GetType().Name}'.");
if (runContext.Session is not ChatClientAgentSession chatClientAgentSession)
{
throw new InvalidOperationException(
$"{nameof(ChatHistoryPersistingChatClient)} requires a {nameof(ChatClientAgentSession)}. " +
$"The current session is of type '{runContext.Session?.GetType().Name ?? "null"}'.");
}
return (chatClientAgent, chatClientAgentSession);
}
/// <summary>
/// Determines whether persistence should be deferred to end-of-run instead of happening immediately.
/// </summary>
/// <returns>
/// <see langword="true"/> when in <see cref="MarkOnly"/> mode, when the call is resuming from
/// a continuation token (since the end-of-run handler needs to combine data from the previous
/// and current runs), or when background responses are allowed (since the caller may stop
/// consuming the stream mid-run, preventing the post-stream persistence code from executing).
/// </returns>
private bool ShouldDeferPersistence(ChatOptions? options)
{
return this.MarkOnly || options?.ContinuationToken is not null || options?.AllowBackgroundResponses is true;
}
/// <summary>
/// Returns only the request messages that have not yet been persisted to chat history.
/// </summary>
/// <remarks>
/// A message is considered already persisted if any of the following is true:
/// <list type="bullet">
/// <item>It has the <see cref="PersistedMarkerKey"/> in its <see cref="ChatMessage.AdditionalProperties"/>.</item>
/// <item>It has an <see cref="AgentRequestMessageSourceType"/> of <see cref="AgentRequestMessageSourceType.ChatHistory"/>
/// (indicating it was loaded from chat history and does not need to be re-persisted).</item>
/// <item>It has <see cref="ChatMessage.Contents"/> and all of its <see cref="AIContent"/> items have the
/// <see cref="PersistedMarkerKey"/> in their <see cref="AIContent.AdditionalProperties"/>. This handles the
/// streaming case where <see cref="FunctionInvokingChatClient"/> reconstructs <see cref="ChatMessage"/> objects
/// independently via <c>ToChatResponse()</c>, producing different object references that share the same
/// underlying <see cref="AIContent"/> instances.</item>
/// </list>
/// </remarks>
/// <returns>A list of request messages that have not yet been persisted.</returns>
/// <param name="messages">The full set of request messages to filter.</param>
private static List<ChatMessage> GetNewRequestMessages(IEnumerable<ChatMessage> messages)
{
return messages.Where(m => !IsAlreadyPersisted(m)).ToList();
}
/// <summary>
/// Determines whether a message has already been persisted to chat history by this decorator.
/// </summary>
private static bool IsAlreadyPersisted(ChatMessage message)
{
if (message.AdditionalProperties?.TryGetValue(PersistedMarkerKey, out var value) == true && value is true)
{
return true;
}
if (message.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.ChatHistory)
{
return true;
}
// In streaming mode, FunctionInvokingChatClient reconstructs ChatMessage objects via ToChatResponse()
// independently, producing different ChatMessage instances. However, the underlying AIContent objects
// (e.g., FunctionCallContent, FunctionResultContent) are shared references. Checking for markers on
// AIContent handles dedup in this case.
if (message.Contents.Count > 0 && message.Contents.All(c => c.AdditionalProperties?.TryGetValue(PersistedMarkerKey, out var value) == true && value is true))
{
return true;
}
return false;
}
/// <summary>
/// Marks the given messages as persisted by setting a marker on both the <see cref="ChatMessage"/>
/// and each of its <see cref="AIContent"/> items.
/// </summary>
/// <remarks>
/// Both levels are marked because <see cref="FunctionInvokingChatClient"/> may reconstruct
/// <see cref="ChatMessage"/> objects in streaming mode (losing the message-level marker),
/// but the <see cref="AIContent"/> references are shared and retain their markers.
/// </remarks>
/// <param name="messages">The messages to mark as persisted.</param>
private static void MarkAsPersisted(IEnumerable<ChatMessage> messages)
{
foreach (var message in messages)
{
message.AdditionalProperties ??= new();
message.AdditionalProperties[PersistedMarkerKey] = true;
foreach (var content in message.Contents)
{
content.AdditionalProperties ??= new();
content.AdditionalProperties[PersistedMarkerKey] = true;
}
}
}
/// <summary>
/// If the <paramref name="options"/> carry the <see cref="LocalHistoryConversationId"/> sentinel,
/// returns a clone with the conversation ID cleared so the inner client never sees it.
/// Otherwise returns the original <paramref name="options"/> unchanged.
/// </summary>
private static ChatOptions? StripLocalHistoryConversationId(ChatOptions? options)
{
if (options?.ConversationId == LocalHistoryConversationId)
{
options = options.Clone();
options.ConversationId = null;
}
return options;
}
}
@@ -0,0 +1,289 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating chat client that persists chat history and updates session state after each
/// individual service call within the <see cref="FunctionInvokingChatClient"/> loop.
/// </summary>
/// <remarks>
/// <para>
/// This decorator is intended to operate between the <see cref="FunctionInvokingChatClient"/> and the leaf
/// <see cref="IChatClient"/> in a <see cref="ChatClientAgent"/> pipeline. It is activated when
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>.
/// </para>
/// <para>
/// When active, it handles two complementary scenarios:
/// </para>
/// <list type="bullet">
/// <item>
/// <term>Framework-managed chat history</term>
/// <description>
/// Before each service call, the decorator loads history from the agent's <see cref="ChatHistoryProvider"/>
/// and prepends it to the request messages. After each successful call, it persists new messages to
/// the provider and returns a sentinel <see cref="ChatOptions.ConversationId"/> so that
/// <see cref="FunctionInvokingChatClient"/> treats the conversation as service-managed — clearing
/// accumulated history between iterations and not injecting duplicate <see cref="FunctionCallContent"/>
/// during approval-response processing.
/// </description>
/// </item>
/// <item>
/// <term>Service-stored chat history</term>
/// <description>
/// When the underlying service manages its own chat history (real <see cref="ChatOptions.ConversationId"/>),
/// the decorator updates <see cref="ChatClientAgentSession.ConversationId"/> after each service call so
/// that intermediate ConversationId changes are captured immediately rather than only at the end of the run.
/// </description>
/// </item>
/// </list>
/// <para>
/// This chat client must be used within the context of a running <see cref="ChatClientAgent"/>. It retrieves the
/// current agent and session from <see cref="AIAgent.CurrentRunContext"/>, which is set automatically when an agent's
/// <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/> or
/// <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/>
/// method is called. The <see cref="ChatClientAgent"/> ensures the run context always contains a resolved session,
/// even when the caller passes null. An <see cref="InvalidOperationException"/> is thrown if no run context is
/// available or if the agent is not a <see cref="ChatClientAgent"/>.
/// </para>
/// </remarks>
internal sealed class PerServiceCallChatHistoryPersistingChatClient : DelegatingChatClient
{
/// <summary>
/// A sentinel value returned on <see cref="ChatResponse.ConversationId"/> to signal
/// <see cref="FunctionInvokingChatClient"/> that chat history is being managed downstream.
/// </summary>
/// <remarks>
/// <para>
/// When <see cref="FunctionInvokingChatClient"/> sees a non-null <see cref="ChatResponse.ConversationId"/>,
/// it treats the conversation as service-managed: it clears accumulated history between
/// iterations (via <c>FixupHistories</c>) and does not inject <see cref="FunctionCallContent"/>
/// into the request during approval-response processing (via <c>ProcessFunctionApprovalResponses</c>).
/// </para>
/// <para>
/// This decorator strips the sentinel from <see cref="ChatOptions.ConversationId"/> on incoming
/// requests before forwarding to the inner client, so the underlying model never sees it.
/// </para>
/// </remarks>
internal const string LocalHistoryConversationId = "_agent_local_chat_history";
/// <summary>
/// Initializes a new instance of the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying chat client that will handle the core operations.</param>
public PerServiceCallChatHistoryPersistingChatClient(IChatClient innerClient)
: base(innerClient)
{
}
/// <inheritdoc/>
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var (agent, session) = GetRequiredAgentAndSession();
options = StripLocalHistoryConversationId(options);
bool isServiceManaged = !string.IsNullOrEmpty(options?.ConversationId);
bool isContinuationOrBackground = options?.ContinuationToken is not null
|| options?.AllowBackgroundResponses is true;
bool skipSimulation = isServiceManaged || isContinuationOrBackground;
var newMessages = messages as IList<ChatMessage> ?? messages.ToList();
// When simulating, load history and prepend it. When the service manages
// history (real ConversationId) or this is a continuation/background run,
// just forward the input messages as-is.
var messagesForService = skipSimulation
? newMessages
: await agent.LoadChatHistoryAsync(session, newMessages, options, cancellationToken).ConfigureAwait(false);
ChatResponse response;
try
{
response = await base.GetResponseAsync(messagesForService, options, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
throw;
}
await agent.NotifyProvidersOfNewMessagesAsync(session, newMessages, response.Messages, options, cancellationToken).ConfigureAwait(false);
if (isContinuationOrBackground)
{
// Continuation/background run — the agent's forced end-of-run handles
// session ConversationId and persistence; the decorator is a no-op.
}
else if (isServiceManaged || !string.IsNullOrEmpty(response.ConversationId))
{
// Service manages history — update session with the real ConversationId.
agent.UpdateSessionConversationId(session, response.ConversationId, cancellationToken);
}
else
{
// Normal simulated path — set sentinel so FICC treats this as service-managed.
SetSentinelConversationId(response, session);
}
return response;
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var (agent, session) = GetRequiredAgentAndSession();
options = StripLocalHistoryConversationId(options);
bool isServiceManaged = !string.IsNullOrEmpty(options?.ConversationId);
bool isContinuationOrBackground = options?.ContinuationToken is not null
|| options?.AllowBackgroundResponses is true;
bool skipSimulation = isServiceManaged || isContinuationOrBackground;
var newMessages = messages as IList<ChatMessage> ?? messages.ToList();
// When simulating, load history and prepend it. When the service manages
// history (real ConversationId) or this is a continuation/background run,
// just forward the input messages as-is.
var messagesForService = skipSimulation
? newMessages
: await agent.LoadChatHistoryAsync(session, newMessages, options, cancellationToken).ConfigureAwait(false);
List<ChatResponseUpdate> responseUpdates = [];
IAsyncEnumerator<ChatResponseUpdate> enumerator;
try
{
enumerator = base.GetStreamingResponseAsync(messagesForService, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
}
catch (Exception ex)
{
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
throw;
}
bool hasUpdates;
try
{
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
throw;
}
while (hasUpdates)
{
var update = enumerator.Current;
responseUpdates.Add(update);
// If the service returned a real ConversationId on any update, remember that.
// Otherwise stamp our sentinel so FICC treats this as service-managed —
// unless this is a continuation/background run where the agent handles everything.
if (!string.IsNullOrEmpty(update.ConversationId))
{
isServiceManaged = true;
}
else if (!skipSimulation)
{
update.ConversationId = LocalHistoryConversationId;
}
yield return update;
try
{
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await agent.NotifyProvidersOfFailureAsync(session, ex, newMessages, options, cancellationToken).ConfigureAwait(false);
throw;
}
}
var chatResponse = responseUpdates.ToChatResponse();
await agent.NotifyProvidersOfNewMessagesAsync(session, newMessages, chatResponse.Messages, options, cancellationToken).ConfigureAwait(false);
if (isContinuationOrBackground)
{
// Continuation/background run — the agent's forced end-of-run handles
// session ConversationId and persistence; the decorator is a no-op.
}
else if (isServiceManaged)
{
// Service manages history — update session with the real ConversationId.
agent.UpdateSessionConversationId(session, chatResponse.ConversationId, cancellationToken);
}
else
{
// Normal simulated path — set sentinel on session.
session.ConversationId = LocalHistoryConversationId;
}
}
/// <summary>
/// Sets the sentinel <see cref="LocalHistoryConversationId"/> on the response and session
/// so that <see cref="FunctionInvokingChatClient"/> treats the conversation as service-managed.
/// </summary>
private static void SetSentinelConversationId(ChatResponse response, ChatClientAgentSession session)
{
response.ConversationId = LocalHistoryConversationId;
session.ConversationId = LocalHistoryConversationId;
}
/// <summary>
/// Gets the current <see cref="ChatClientAgent"/> and <see cref="ChatClientAgentSession"/> from the run context.
/// </summary>
private static (ChatClientAgent Agent, ChatClientAgentSession Session) GetRequiredAgentAndSession()
{
var runContext = AIAgent.CurrentRunContext
?? throw new InvalidOperationException(
$"{nameof(PerServiceCallChatHistoryPersistingChatClient)} can only be used within the context of a running AIAgent. " +
"Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
var chatClientAgent = runContext.Agent.GetService<ChatClientAgent>()
?? throw new InvalidOperationException(
$"{nameof(PerServiceCallChatHistoryPersistingChatClient)} can only be used with a {nameof(ChatClientAgent)}. " +
$"The current agent is of type '{runContext.Agent.GetType().Name}'.");
if (runContext.Session is not ChatClientAgentSession chatClientAgentSession)
{
throw new InvalidOperationException(
$"{nameof(PerServiceCallChatHistoryPersistingChatClient)} requires a {nameof(ChatClientAgentSession)}. " +
$"The current session is of type '{runContext.Session?.GetType().Name ?? "null"}'.");
}
return (chatClientAgent, chatClientAgentSession);
}
/// <summary>
/// If the <paramref name="options"/> carry the <see cref="LocalHistoryConversationId"/> sentinel,
/// returns a clone with the conversation ID cleared so the inner client never sees it.
/// Otherwise returns the original <paramref name="options"/> unchanged.
/// </summary>
private static ChatOptions? StripLocalHistoryConversationId(ChatOptions? options)
{
if (options?.ConversationId == LocalHistoryConversationId)
{
options = options.Clone();
options.ConversationId = null;
}
return options;
}
}
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.FileAgentSkillsProvider</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.FileAgentSkillsProviderOptions</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.FileAgentSkillsProvider</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.FileAgentSkillsProviderOptions</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.FileAgentSkillsProvider</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.FileAgentSkillsProviderOptions</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.FileAgentSkillsProvider</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.FileAgentSkillsProviderOptions</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.FileAgentSkillsProvider</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:Microsoft.Agents.AI.FileAgentSkillsProviderOptions</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A skill source that holds <see cref="AgentSkill"/> instances in memory.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class AgentInMemorySkillsSource : AgentSkillsSource
{
private readonly List<AgentSkill> _skills;
/// <summary>
/// Initializes a new instance of the <see cref="AgentInMemorySkillsSource"/> class.
/// </summary>
/// <param name="skills">The skills to include in this source.</param>
public AgentInMemorySkillsSource(IEnumerable<AgentSkill> skills)
{
this._skills = Throw.IfNull(skills).ToList();
}
/// <inheritdoc/>
public override Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult<IList<AgentSkill>>(this._skills);
}
}
@@ -12,7 +12,8 @@ namespace Microsoft.Agents.AI;
/// <remarks>
/// <para>
/// A skill represents a domain-specific capability with instructions, resources, and scripts.
/// Concrete implementations include <see cref="AgentFileSkill"/> (filesystem-backed).
/// Concrete implementations include <see cref="AgentFileSkill"/> (filesystem-backed)
/// and <see cref="AgentInlineSkill"/> (code-defined).
/// </para>
/// <para>
/// Skill metadata follows the <see href="https://agentskills.io/specification">Agent Skills specification</see>.
@@ -35,6 +36,8 @@ public abstract class AgentSkill
/// </summary>
/// <remarks>
/// For file-based skills this is the raw SKILL.md file content.
/// For code-defined skills this is a synthesized XML document
/// containing name, description, and body (instructions, resources, scripts).
/// </remarks>
public abstract string Content { get; }
@@ -116,6 +116,38 @@ public sealed partial class AgentSkillsProvider : AIContextProvider
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
/// with one or more inline (code-defined) skills.
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
/// </summary>
/// <param name="skills">The inline skills to include.</param>
public AgentSkillsProvider(params AgentInlineSkill[] skills)
: this(skills as IEnumerable<AgentInlineSkill>)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
/// with inline (code-defined) skills.
/// Duplicate skill names are automatically deduplicated (first occurrence wins).
/// </summary>
/// <param name="skills">The inline skills to include.</param>
/// <param name="options">Optional provider configuration.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public AgentSkillsProvider(
IEnumerable<AgentInlineSkill> skills,
AgentSkillsProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
: this(
new DeduplicatingAgentSkillsSource(
new AgentInMemorySkillsSource(Throw.IfNull(skills)),
loggerFactory),
options,
loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillsProvider"/> class
/// from a custom <see cref="AgentSkillsSource"/>. Unlike other constructors, this one does not
@@ -13,9 +13,13 @@ namespace Microsoft.Agents.AI;
/// Fluent builder for constructing an <see cref="AgentSkillsProvider"/> backed by a composite source.
/// </summary>
/// <remarks>
/// <para>
/// Use this builder to combine multiple skill sources into a single provider:
/// </para>
/// <code>
/// var provider = new AgentSkillsProviderBuilder()
/// .UseFileSkills("/path/to/skills")
/// .UseSkills(myInlineSkill1, myInlineSkill2)
/// .Build();
/// </code>
/// </remarks>
@@ -65,6 +69,40 @@ public sealed class AgentSkillsProviderBuilder
return this;
}
/// <summary>
/// Adds a single skill.
/// </summary>
/// <param name="skill">The skill to add.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseSkill(AgentSkill skill)
{
return this.UseSkills(skill);
}
/// <summary>
/// Adds one or more skills.
/// </summary>
/// <param name="skills">The skills to add.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseSkills(params AgentSkill[] skills)
{
var source = new AgentInMemorySkillsSource(skills);
this._sourceFactories.Add((_, _) => source);
return this;
}
/// <summary>
/// Adds skills from the specified collection.
/// </summary>
/// <param name="skills">The skills to add.</param>
/// <returns>This builder instance for chaining.</returns>
public AgentSkillsProviderBuilder UseSkills(IEnumerable<AgentSkill> skills)
{
var source = new AgentInMemorySkillsSource(skills);
this._sourceFactories.Add((_, _) => source);
return this;
}
/// <summary>
/// Adds a custom skill source.
/// </summary>
@@ -0,0 +1,215 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A skill defined entirely in code with resources (static values or delegates) and scripts (delegates).
/// </summary>
/// <remarks>
/// All calls to <see cref="AddResource(string, object, string?)"/>,
/// <see cref="AddResource(string, Delegate, string?)"/>, and <see cref="AddScript"/>
/// must be made before the skill's <see cref="Content"/> is first accessed.
/// Calls made after that point will not be reflected in the generated
/// <see cref="Content"/>. In typical usage, this means configuring all
/// resources and scripts before registering the skill with an
/// <see cref="AgentSkillsProvider"/> or <see cref="AgentSkillsProviderBuilder"/>.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentInlineSkill : AgentSkill
{
private readonly string _instructions;
private List<AgentSkillResource>? _resources;
private List<AgentSkillScript>? _scripts;
private string? _cachedContent;
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkill"/> class
/// with a pre-built <see cref="AgentSkillFrontmatter"/>.
/// </summary>
/// <param name="frontmatter">The skill frontmatter containing name, description, and other metadata.</param>
/// <param name="instructions">Skill instructions text.</param>
public AgentInlineSkill(AgentSkillFrontmatter frontmatter, string instructions)
{
this.Frontmatter = Throw.IfNull(frontmatter);
this._instructions = Throw.IfNullOrWhitespace(instructions);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkill"/> class
/// with all frontmatter properties specified individually.
/// </summary>
/// <param name="name">Skill name in kebab-case.</param>
/// <param name="description">Skill description for discovery.</param>
/// <param name="instructions">Skill instructions text.</param>
/// <param name="license">Optional license name or reference.</param>
/// <param name="compatibility">Optional compatibility information (max 500 chars).</param>
/// <param name="allowedTools">Optional space-delimited list of pre-approved tools.</param>
/// <param name="metadata">Optional arbitrary key-value metadata.</param>
public AgentInlineSkill(
string name,
string description,
string instructions,
string? license = null,
string? compatibility = null,
string? allowedTools = null,
AdditionalPropertiesDictionary? metadata = null)
: this(
new AgentSkillFrontmatter(name, description, compatibility)
{
License = license,
AllowedTools = allowedTools,
Metadata = metadata,
},
instructions)
{
}
/// <inheritdoc/>
public override AgentSkillFrontmatter Frontmatter { get; }
/// <inheritdoc/>
public override string Content => this._cachedContent ??= this.BuildContent();
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources;
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts;
/// <summary>
/// Registers a static resource with this skill.
/// </summary>
/// <param name="name">The resource name.</param>
/// <param name="value">The static resource value.</param>
/// <param name="description">An optional description of the resource.</param>
/// <returns>This instance, for chaining.</returns>
public AgentInlineSkill AddResource(string name, object value, string? description = null)
{
(this._resources ??= []).Add(new AgentInlineSkillResource(name, value, description));
return this;
}
/// <summary>
/// Registers a dynamic resource with this skill, backed by a C# delegate.
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
/// </summary>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
/// <param name="description">An optional description of the resource.</param>
/// <returns>This instance, for chaining.</returns>
public AgentInlineSkill AddResource(string name, Delegate method, string? description = null)
{
(this._resources ??= []).Add(new AgentInlineSkillResource(name, method, description));
return this;
}
/// <summary>
/// Registers a script with this skill, backed by a C# delegate.
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
/// </summary>
/// <param name="name">The script name.</param>
/// <param name="method">A method to execute when the script is invoked.</param>
/// <param name="description">An optional description of the script.</param>
/// <returns>This instance, for chaining.</returns>
public AgentInlineSkill AddScript(string name, Delegate method, string? description = null)
{
(this._scripts ??= []).Add(new AgentInlineSkillScript(name, method, description));
return this;
}
private string BuildContent()
{
var sb = new StringBuilder();
sb.Append($"<name>{EscapeXmlString(this.Frontmatter.Name)}</name>\n")
.Append($"<description>{EscapeXmlString(this.Frontmatter.Description)}</description>\n\n")
.Append("<instructions>\n")
.Append(EscapeXmlString(this._instructions))
.Append("\n</instructions>");
if (this.Resources is { Count: > 0 })
{
sb.Append("\n\n<resources>\n");
foreach (var resource in this.Resources)
{
if (resource.Description is not null)
{
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\" description=\"{EscapeXmlString(resource.Description)}\"/>\n");
}
else
{
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\"/>\n");
}
}
sb.Append("</resources>");
}
if (this.Scripts is { Count: > 0 })
{
sb.Append("\n\n<scripts>\n");
foreach (var script in this.Scripts)
{
JsonElement? parametersSchema = ((AgentInlineSkillScript)script).ParametersSchema;
if (script.Description is null && parametersSchema is null)
{
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
}
else
{
sb.Append(script.Description is not null
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");
if (parametersSchema is not null)
{
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
}
sb.Append(" </script>\n");
}
}
sb.Append("</scripts>");
}
return sb.ToString();
}
/// <summary>
/// Escapes XML special characters: always escapes <c>&amp;</c>, <c>&lt;</c>, <c>&gt;</c>,
/// <c>&quot;</c>, and <c>&apos;</c>. When <paramref name="preserveQuotes"/> is <see langword="true"/>,
/// quotes are left unescaped to preserve readability of embedded content such as JSON.
/// </summary>
/// <param name="value">The string to escape.</param>
/// <param name="preserveQuotes">
/// When <see langword="true"/>, leaves <c>"</c> and <c>'</c> unescaped for use in XML element content (e.g., JSON).
/// When <see langword="false"/> (default), escapes all XML special characters including quotes.
/// </param>
private static string EscapeXmlString(string value, bool preserveQuotes = false)
{
var result = value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;");
if (!preserveQuotes)
{
result = result
.Replace("\"", "&quot;")
.Replace("'", "&apos;");
}
return result;
}
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A skill resource defined in code, backed by either a static value or a delegate.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class AgentInlineSkillResource : AgentSkillResource
{
private readonly object? _value;
private readonly AIFunction? _function;
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkillResource"/> class with a static value.
/// The value is returned as-is when <see cref="ReadAsync"/> is called.
/// </summary>
/// <param name="name">The resource name.</param>
/// <param name="value">The static resource value.</param>
/// <param name="description">An optional description of the resource.</param>
public AgentInlineSkillResource(string name, object value, string? description = null)
: base(name, description)
{
this._value = Throw.IfNull(value);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkillResource"/> class with a delegate.
/// The delegate is invoked via an <see cref="AIFunction"/> each time <see cref="ReadAsync"/> is called,
/// producing a dynamic (computed) value.
/// </summary>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
/// <param name="description">An optional description of the resource.</param>
public AgentInlineSkillResource(string name, Delegate method, string? description = null)
: base(name, description)
{
Throw.IfNull(method);
this._function = AIFunctionFactory.Create(method, name: this.Name);
}
/// <inheritdoc/>
public override async Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
{
if (this._function is not null)
{
return await this._function.InvokeAsync(new AIFunctionArguments() { Services = serviceProvider }, cancellationToken).ConfigureAwait(false);
}
return this._value;
}
}
@@ -0,0 +1,46 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A skill script backed by a delegate.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
internal sealed class AgentInlineSkillScript : AgentSkillScript
{
private readonly AIFunction _function;
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkillScript"/> class from a delegate.
/// The delegate's parameters and return type are automatically marshaled via <see cref="AIFunctionFactory"/>.
/// </summary>
/// <param name="name">The script name.</param>
/// <param name="method">A method to execute when the script is invoked. Parameters are automatically deserialized from JSON.</param>
/// <param name="description">An optional description of the script.</param>
public AgentInlineSkillScript(string name, Delegate method, string? description = null)
: base(Throw.IfNullOrWhitespace(name), description)
{
Throw.IfNull(method);
this._function = AIFunctionFactory.Create(method, name: this.Name);
}
/// <summary>
/// Gets the JSON schema describing the parameters accepted by this script, or <see langword="null"/> if not available.
/// </summary>
public JsonElement? ParametersSchema => this._function.JsonSchema;
/// <inheritdoc/>
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
{
return await this._function.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInMemorySkillsSource"/>.
/// </summary>
public sealed class AgentInMemorySkillsSourceTests
{
[Fact]
public async Task GetSkillsAsync_ValidSkills_ReturnsAllAsync()
{
// Arrange
var skills = new AgentSkill[]
{
new AgentInlineSkill("my-skill", "A valid skill.", "Instructions."),
new AgentInlineSkill("another", "Another valid skill.", "More instructions."),
};
var source = new AgentInMemorySkillsSource(skills);
// Act
var result = await source.GetSkillsAsync(CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("my-skill", result[0].Frontmatter.Name);
Assert.Equal("another", result[1].Frontmatter.Name);
}
[Theory]
[InlineData("INVALID-NAME")]
[InlineData("-leading")]
[InlineData("trailing-")]
public void Constructor_InvalidFrontmatter_ThrowsArgumentException(string invalidName)
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
new AgentInlineSkill(invalidName, "A skill.", "Instructions."));
}
[Fact]
public void Constructor_NullSkills_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new AgentInMemorySkillsSource(null!));
}
}
@@ -0,0 +1,155 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInlineSkillResource"/>.
/// </summary>
public sealed class AgentInlineSkillResourceTests
{
[Fact]
public async Task ReadAsync_StaticValue_ReturnsValueAsync()
{
// Arrange
var resource = new AgentInlineSkillResource("config", "my-value");
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Equal("my-value", result);
}
[Fact]
public async Task ReadAsync_StaticObjectValue_ReturnsSameInstanceAsync()
{
// Arrange
var obj = new object();
var resource = new AgentInlineSkillResource("ref", obj);
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Same(obj, result);
}
[Fact]
public async Task ReadAsync_Delegate_InvokesFunctionAsync()
{
// Arrange
int callCount = 0;
var resource = new AgentInlineSkillResource("dynamic", () =>
{
callCount++;
return "computed";
});
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Equal("computed", result?.ToString());
Assert.Equal(1, callCount);
}
[Fact]
public async Task ReadAsync_Delegate_InvokesEachTimeAsync()
{
// Arrange
int callCount = 0;
var resource = new AgentInlineSkillResource("counter", () => ++callCount);
// Act
await resource.ReadAsync();
await resource.ReadAsync();
var result = await resource.ReadAsync();
// Assert
Assert.Equal(3, callCount);
}
[Fact]
public void Constructor_StaticValue_SetsNameAndDescription()
{
// Arrange & Act
var resource = new AgentInlineSkillResource("my-res", "val", "A description.");
// Assert
Assert.Equal("my-res", resource.Name);
Assert.Equal("A description.", resource.Description);
}
[Fact]
public void Constructor_StaticValue_NullDescription_DescriptionIsNull()
{
// Arrange & Act
var resource = new AgentInlineSkillResource("my-res", "val");
// Assert
Assert.Null(resource.Description);
}
[Fact]
public void Constructor_StaticValue_NullValue_Throws()
{
// Act & Assert — cast needed to target the object overload
#pragma warning disable IDE0004
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillResource("my-res", (object)null!));
#pragma warning restore IDE0004
}
[Fact]
public void Constructor_Delegate_NullMethod_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillResource("my-res", null!));
}
[Fact]
public void Constructor_NullName_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillResource(null!, "val"));
}
[Fact]
public void Constructor_WhitespaceName_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
new AgentInlineSkillResource(" ", "val"));
}
[Fact]
public void Constructor_Delegate_SetsNameAndDescription()
{
// Arrange & Act
var resource = new AgentInlineSkillResource("dyn-res", () => "hello", "Dynamic resource.");
// Assert
Assert.Equal("dyn-res", resource.Name);
Assert.Equal("Dynamic resource.", resource.Description);
}
[Fact]
public async Task ReadAsync_SupportsCancellationTokenAsync()
{
// Arrange
using var cts = new CancellationTokenSource();
var resource = new AgentInlineSkillResource("cancellable", "value");
// Act — should not throw with a non-cancelled token
var result = await resource.ReadAsync(cancellationToken: cts.Token);
// Assert
Assert.Equal("value", result);
}
}
@@ -0,0 +1,132 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInlineSkillScript"/>.
/// </summary>
public sealed class AgentInlineSkillScriptTests
{
[Fact]
public async Task RunAsync_InvokesDelegate_ReturnsResultAsync()
{
// Arrange
var script = new AgentInlineSkillScript("greet", () => "hello");
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
// Act
var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None);
// Assert
Assert.Equal("hello", result?.ToString());
}
[Fact]
public async Task RunAsync_WithParameters_PassesArgumentsAsync()
{
// Arrange
var script = new AgentInlineSkillScript("add", (int a, int b) => a + b);
var skill = new AgentInlineSkill("calc-skill", "Calc.", "Instructions.");
var args = new AIFunctionArguments { ["a"] = 3, ["b"] = 7 };
// Act
var result = await script.RunAsync(skill, args, CancellationToken.None);
// Assert
Assert.Equal(10, int.Parse(result?.ToString()!));
}
[Fact]
public void ParametersSchema_NoParameters_ReturnsSchema()
{
// Arrange
var script = new AgentInlineSkillScript("noop", () => "ok");
// Act
var schema = script.ParametersSchema;
// Assert — parameterless delegates still produce a schema
Assert.NotNull(schema);
}
[Fact]
public void ParametersSchema_WithParameters_ContainsPropertyNames()
{
// Arrange
var script = new AgentInlineSkillScript("search", (string query, int limit) => $"{query}:{limit}");
// Act
var schema = script.ParametersSchema;
// Assert
Assert.NotNull(schema);
var schemaText = schema!.Value.GetRawText();
Assert.Contains("query", schemaText);
Assert.Contains("limit", schemaText);
}
[Fact]
public void Constructor_SetsNameAndDescription()
{
// Arrange & Act
var script = new AgentInlineSkillScript("my-script", () => "ok", "Does something.");
// Assert
Assert.Equal("my-script", script.Name);
Assert.Equal("Does something.", script.Description);
}
[Fact]
public void Constructor_NullDescription_DescriptionIsNull()
{
// Arrange & Act
var script = new AgentInlineSkillScript("my-script", () => "ok");
// Assert
Assert.Null(script.Description);
}
[Fact]
public void Constructor_NullName_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillScript(null!, () => "ok"));
}
[Fact]
public void Constructor_WhitespaceName_Throws()
{
// Act & Assert
Assert.Throws<ArgumentException>(() =>
new AgentInlineSkillScript(" ", () => "ok"));
}
[Fact]
public void Constructor_NullMethod_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillScript("my-script", null!));
}
[Fact]
public async Task RunAsync_StringParameter_WorksAsync()
{
// Arrange
var script = new AgentInlineSkillScript("echo", (string message) => message);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
var args = new AIFunctionArguments { ["message"] = "hello world" };
// Act
var result = await script.RunAsync(skill, args, CancellationToken.None);
// Assert
Assert.Equal("hello world", result?.ToString());
}
}
@@ -0,0 +1,420 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInlineSkill"/>.
/// </summary>
public sealed class AgentInlineSkillTests
{
[Fact]
public void Constructor_WithNameAndDescription_SetsFrontmatter()
{
// Arrange & Act
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Assert
Assert.Equal("my-skill", skill.Frontmatter.Name);
Assert.Equal("A valid skill.", skill.Frontmatter.Description);
Assert.Null(skill.Frontmatter.License);
Assert.Null(skill.Frontmatter.Compatibility);
Assert.Null(skill.Frontmatter.AllowedTools);
Assert.Null(skill.Frontmatter.Metadata);
}
[Fact]
public void Constructor_WithAllProps_SetsFrontmatter()
{
// Arrange
var metadata = new AdditionalPropertiesDictionary { ["key"] = "value" };
// Act
var skill = new AgentInlineSkill(
"my-skill",
"A valid skill.",
"Instructions.",
license: "MIT",
compatibility: "gpt-4",
allowedTools: "tool-a tool-b",
metadata: metadata);
// Assert
Assert.Equal("my-skill", skill.Frontmatter.Name);
Assert.Equal("A valid skill.", skill.Frontmatter.Description);
Assert.Equal("MIT", skill.Frontmatter.License);
Assert.Equal("gpt-4", skill.Frontmatter.Compatibility);
Assert.Equal("tool-a tool-b", skill.Frontmatter.AllowedTools);
Assert.NotNull(skill.Frontmatter.Metadata);
Assert.Equal("value", skill.Frontmatter.Metadata["key"]);
}
[Fact]
public void Constructor_WithFrontmatter_UsesFrontmatterDirectly()
{
// Arrange
var frontmatter = new AgentSkillFrontmatter("my-skill", "A valid skill.")
{
License = "Apache-2.0",
Compatibility = "gpt-4",
AllowedTools = "tool-a",
Metadata = new AdditionalPropertiesDictionary { ["env"] = "prod" },
};
// Act
var skill = new AgentInlineSkill(frontmatter, "Instructions.");
// Assert
Assert.Same(frontmatter, skill.Frontmatter);
Assert.Equal("Apache-2.0", skill.Frontmatter.License);
Assert.Equal("gpt-4", skill.Frontmatter.Compatibility);
Assert.Equal("tool-a", skill.Frontmatter.AllowedTools);
Assert.Equal("prod", skill.Frontmatter.Metadata!["env"]);
}
[Fact]
public void Constructor_WithFrontmatter_NullFrontmatter_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkill(null!, "Instructions."));
}
[Fact]
public void Constructor_WithFrontmatter_NullInstructions_Throws()
{
// Arrange
var frontmatter = new AgentSkillFrontmatter("my-skill", "A valid skill.");
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkill(frontmatter, null!));
}
[Fact]
public void Constructor_WithAllProps_NullInstructions_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkill("my-skill", "A valid skill.", null!));
}
[Fact]
public void Content_ContainsNameDescriptionAndInstructions()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Do the thing.");
// Act
var content = skill.Content;
// Assert
Assert.Contains("<name>my-skill</name>", content);
Assert.Contains("<description>A valid skill.</description>", content);
Assert.Contains("<instructions>\nDo the thing.\n</instructions>", content);
}
[Fact]
public void Content_EscapesXmlCharacters()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "x<y>z\"w & it's more", "1 & 2 < 3");
// Act
var content = skill.Content;
// Assert
Assert.Contains("<name>my-skill</name>", content);
Assert.Contains("<description>x&lt;y&gt;z&quot;w &amp; it&apos;s more</description>", content);
Assert.Contains("1 &amp; 2 &lt; 3", content); // instructions are escaped
}
[Fact]
public void Content_IsCachedAcrossAccesses()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var first = skill.Content;
var second = skill.Content;
// Assert
Assert.Same(first, second);
}
[Fact]
public void Content_IncludesResourcesAddedBeforeFirstAccess()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("config", "value1", "A config resource.");
// Act
var content = skill.Content;
// Assert
Assert.Contains("<resources>", content);
Assert.Contains("config", content);
}
[Fact]
public void Content_IncludesDelegateResourcesAddedBeforeFirstAccess()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("dynamic", () => "hello");
// Act
var content = skill.Content;
// Assert
Assert.Contains("<resources>", content);
Assert.Contains("dynamic", content);
}
[Fact]
public void Content_IncludesScriptsAddedBeforeFirstAccess()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("run", () => "result", "Runs something.");
// Act
var content = skill.Content;
// Assert
Assert.Contains("<scripts>", content);
Assert.Contains("run", content);
}
[Fact]
public void Content_IsCachedAndNotRebuilt()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("r1", "v1");
// Act
var first = skill.Content;
var second = skill.Content;
// Assert
Assert.Same(first, second);
}
[Fact]
public void Content_IncludesResourcesAndScriptsAddedBeforeFirstAccess()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("r1", "v1");
skill.AddScript("s1", () => "ok");
// Act
var content = skill.Content;
// Assert
Assert.Contains("<resources>", content);
Assert.Contains("r1", content);
Assert.Contains("<scripts>", content);
Assert.Contains("s1", content);
}
[Fact]
public void Content_ParametersSchema_IsXmlEscaped()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("search", (string query, int limit) => $"found {limit} results for {query}");
// Act
var content = skill.Content;
// Assert — JSON schema should be present and XML content chars escaped
Assert.Contains("parameters_schema", content);
Assert.DoesNotContain("<![CDATA[", content);
}
[Fact]
public void AddResource_NullValue_Throws()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert — cast needed to target the object overload
#pragma warning disable IDE0004
Assert.Throws<ArgumentNullException>(() => skill.AddResource("config", (object)null!));
#pragma warning restore IDE0004
}
[Fact]
public void AddResource_NullDelegate_Throws()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Throws<ArgumentNullException>(() => skill.AddResource("config", null!));
}
[Fact]
public void AddScript_NullDelegate_Throws()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Throws<ArgumentNullException>(() => skill.AddScript("run", null!));
}
[Fact]
public void Resources_WhenNoneAdded_ReturnsNull()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Null(skill.Resources);
}
[Fact]
public void Scripts_WhenNoneAdded_ReturnsNull()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act & Assert
Assert.Null(skill.Scripts);
}
[Fact]
public void AddResource_ReturnsSameInstance_ForChaining()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var returned = skill.AddResource("r1", "v1");
// Assert
Assert.Same(skill, returned);
}
[Fact]
public void AddResource_Delegate_ReturnsSameInstance_ForChaining()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var returned = skill.AddResource("r1", () => "v1");
// Assert
Assert.Same(skill, returned);
}
[Fact]
public void AddScript_ReturnsSameInstance_ForChaining()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var returned = skill.AddScript("s1", () => "ok");
// Assert
Assert.Same(skill, returned);
}
[Fact]
public void Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTags()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
// Act
var content = skill.Content;
// Assert
Assert.DoesNotContain("<resources>", content);
Assert.DoesNotContain("<scripts>", content);
}
[Fact]
public void Content_ResourcesAddedAfterCaching_AreNotIncluded()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
_ = skill.Content; // trigger caching
skill.AddResource("late-resource", "late-value");
// Act
var content = skill.Content;
// Assert — the late resource should not appear because content was cached
Assert.DoesNotContain("late-resource", content);
}
[Fact]
public void Content_ScriptsAddedAfterCaching_AreNotIncluded()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
_ = skill.Content; // trigger caching
skill.AddScript("late-script", () => "late");
// Act
var content = skill.Content;
// Assert — the late script should not appear because content was cached
Assert.DoesNotContain("late-script", content);
}
[Fact]
public void Content_ScriptWithDescription_IncludesDescriptionAttribute()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("my-script", () => "ok", "Runs something.");
// Act
var content = skill.Content;
// Assert
Assert.Contains("description=\"Runs something.\"", content);
}
[Fact]
public void Content_ScriptWithoutParametersOrDescription_UsesSelfClosingTag()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddScript("simple", () => "ok");
// Act
var content = skill.Content;
// Assert — parameterless Action delegates still produce a schema, so this
// verifies the script is at least included in the output
Assert.Contains("simple", content);
}
[Fact]
public void Content_ResourceWithDescription_IncludesDescriptionAttribute()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
skill.AddResource("with-desc", "value", "A described resource.");
skill.AddResource("no-desc", "value");
// Act
var content = skill.Content;
// Assert
Assert.Contains("description=\"A described resource.\"", content);
Assert.DoesNotContain("no-desc\" description", content);
}
}
@@ -270,7 +270,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
// Arrange
var source = new CountingAgentSkillsSource(
[
new TestAgentSkill("concurrent-skill", "Concurrent test", "Body.")
new AgentInlineSkill("concurrent-skill", "Concurrent test", "Body.")
]);
var provider = new AgentSkillsProvider(source);
@@ -502,7 +502,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
// Arrange
var source = new CountingAgentSkillsSource(
[
new TestAgentSkill("no-cache-skill", "No cache test", "Body.")
new AgentInlineSkill("no-cache-skill", "No cache test", "Body.")
]);
var provider = new AgentSkillsProviderBuilder()
.UseSource(source)
@@ -525,7 +525,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
// Arrange
var source = new CountingAgentSkillsSource(
[
new TestAgentSkill("cached-skill", "Cached test", "Body.")
new AgentInlineSkill("cached-skill", "Cached test", "Body.")
]);
var provider = new AgentSkillsProviderBuilder()
.UseSource(source)
@@ -547,7 +547,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
// Arrange
var source = new CountingAgentSkillsSource(
[
new TestAgentSkill("default-skill", "Default test", "Body.")
new AgentInlineSkill("default-skill", "Default test", "Body.")
]);
var provider = new AgentSkillsProviderBuilder()
.UseSource(source)
@@ -563,6 +563,78 @@ public sealed class AgentSkillsProviderTests : IDisposable
Assert.Equal(1, source.GetSkillsCallCount);
}
[Fact]
public async Task Build_PreservesSourceRegistrationOrderAsync()
{
// Arrange — register file, inline, file in that order
string dir1 = Path.Combine(this._testRoot, "dir1");
string dir2 = Path.Combine(this._testRoot, "dir2");
CreateSkillIn(dir1, "file-skill-1", "First file skill", "Body 1.");
CreateSkillIn(dir2, "file-skill-2", "Second file skill", "Body 2.");
var inlineSkill = new AgentInlineSkill("inline-skill", "Inline skill", "Body inline.");
var provider = new AgentSkillsProviderBuilder()
.UseFileSkill(dir1)
.UseSkills(inlineSkill)
.UseFileSkill(dir2)
.UseFileScriptRunner(s_noOpExecutor)
.UseOptions(o => o.DisableCaching = true)
.Build();
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert — all three skills should be present in alphabetical order in the prompt
Assert.NotNull(result.Instructions);
var instructions = result.Instructions!;
var indexFileSkill1 = instructions.IndexOf("file-skill-1", StringComparison.Ordinal);
var indexFileSkill2 = instructions.IndexOf("file-skill-2", StringComparison.Ordinal);
var indexInlineSkill = instructions.IndexOf("inline-skill", StringComparison.Ordinal);
Assert.True(indexFileSkill1 >= 0, "file-skill-1 should be present in the instructions.");
Assert.True(indexFileSkill2 >= 0, "file-skill-2 should be present in the instructions.");
Assert.True(indexInlineSkill >= 0, "inline-skill should be present in the instructions.");
Assert.True(indexFileSkill1 < indexFileSkill2, "file-skill-1 should appear before file-skill-2.");
Assert.True(indexFileSkill2 < indexInlineSkill, "file-skill-2 should appear before inline-skill.");
}
[Fact]
public async Task Build_MixedSources_AllSkillsDiscoveredAsync()
{
// Arrange — use UseSource, UseSkill, and UseFileSkill in mixed order
string dir = Path.Combine(this._testRoot, "mixed-dir");
CreateSkillIn(dir, "file-skill", "File skill", "Body file.");
var inlineSkill = new AgentInlineSkill("inline-skill", "Inline skill", "Body inline.");
var customSource = new CountingAgentSkillsSource(
[
new AgentInlineSkill("custom-skill", "Custom source skill", "Body custom.")
]);
var provider = new AgentSkillsProviderBuilder()
.UseSource(customSource)
.UseSkills(inlineSkill)
.UseFileSkill(dir)
.UseFileScriptRunner(s_noOpExecutor)
.UseOptions(o => o.DisableCaching = true)
.Build();
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert — all skills from all sources are present
Assert.NotNull(result.Instructions);
Assert.Contains("custom-skill", result.Instructions);
Assert.Contains("inline-skill", result.Instructions);
Assert.Contains("file-skill", result.Instructions);
}
[Fact]
public async Task InvokingCoreAsync_WithScriptsAndScriptApproval_WrapsRunScriptToolAsync()
{
@@ -722,6 +794,63 @@ public sealed class AgentSkillsProviderTests : IDisposable
Assert.Contains("Body 1.", content!.ToString()!);
}
[Fact]
public async Task Constructor_InlineSkillsParams_ProvidesSkillsAsync()
{
// Arrange
var skill1 = new AgentInlineSkill("inline-a", "Inline A", "Instructions A.");
var skill2 = new AgentInlineSkill("inline-b", "Inline B", "Instructions B.");
var provider = new AgentSkillsProvider(skill1, skill2);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(result.Instructions);
Assert.Contains("inline-a", result.Instructions);
Assert.Contains("inline-b", result.Instructions);
}
[Fact]
public async Task Constructor_InlineSkillsEnumerable_ProvidesSkillsAsync()
{
// Arrange
var skills = new List<AgentInlineSkill>
{
new("enum-inline-a", "Inline A", "Instructions A."),
new("enum-inline-b", "Inline B", "Instructions B."),
};
var provider = new AgentSkillsProvider(skills);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.NotNull(result.Instructions);
Assert.Contains("enum-inline-a", result.Instructions);
Assert.Contains("enum-inline-b", result.Instructions);
}
[Fact]
public async Task Constructor_InlineSkills_DeduplicatesAsync()
{
// Arrange — two inline skills with the same name
var skill1 = new AgentInlineSkill("dup-inline", "First", "First instructions.");
var skill2 = new AgentInlineSkill("dup-inline", "Second", "Second instructions.");
var provider = new AgentSkillsProvider(skill1, skill2);
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
// Act
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var loadSkillTool = result.Tools!.First(t => t.Name == "load_skill") as AIFunction;
var content = await loadSkillTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?> { ["skillName"] = "dup-inline" }));
// Assert — only one occurrence (first)
Assert.Contains("First instructions.", content!.ToString()!);
}
/// <summary>
/// A test skill source that counts how many times <see cref="GetSkillsAsync"/> is called.
/// </summary>
@@ -743,23 +872,4 @@ public sealed class AgentSkillsProviderTests : IDisposable
return Task.FromResult(this._skills);
}
}
private sealed class TestAgentSkill : AgentSkill
{
private readonly string _content;
public TestAgentSkill(string name, string description, string content)
{
this.Frontmatter = new AgentSkillFrontmatter(name, description);
this._content = content;
}
public override AgentSkillFrontmatter Frontmatter { get; }
public override string Content => this._content;
public override IReadOnlyList<AgentSkillResource>? Resources => null;
public override IReadOnlyList<AgentSkillScript>? Scripts => null;
}
}
@@ -16,9 +16,11 @@ public sealed class DeduplicatingAgentSkillsSourceTests
public async Task GetSkillsAsync_NoDuplicates_ReturnsAllSkillsAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(
new TestAgentSkill("skill-a", "A", "Instructions A."),
new TestAgentSkill("skill-b", "B", "Instructions B."));
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("skill-a", "A", "Instructions A."),
new AgentInlineSkill("skill-b", "B", "Instructions B."),
});
var source = new DeduplicatingAgentSkillsSource(inner);
// Act
@@ -34,11 +36,11 @@ public sealed class DeduplicatingAgentSkillsSourceTests
// Arrange
var skills = new AgentSkill[]
{
new TestAgentSkill("dupe", "First", "Instructions 1."),
new TestAgentSkill("dupe", "Second", "Instructions 2."),
new TestAgentSkill("unique", "Unique", "Instructions 3."),
new AgentInlineSkill("dupe", "First", "Instructions 1."),
new AgentInlineSkill("dupe", "Second", "Instructions 2."),
new AgentInlineSkill("unique", "Unique", "Instructions 3."),
};
var inner = new TestAgentSkillsSource(skills);
var inner = new AgentInMemorySkillsSource(skills);
var source = new DeduplicatingAgentSkillsSource(inner);
// Act
@@ -53,7 +55,7 @@ public sealed class DeduplicatingAgentSkillsSourceTests
[Fact]
public async Task GetSkillsAsync_CaseInsensitiveDuplication_KeepsFirstAsync()
{
// Arrange — use a custom source that returns skills with same name but different casing
// Arrange - Use a custom source that returns skills with same name but different casing
var inner = new FakeDuplicateCaseSource();
var source = new DeduplicatingAgentSkillsSource(inner);
@@ -69,7 +71,7 @@ public sealed class DeduplicatingAgentSkillsSourceTests
public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(System.Array.Empty<AgentSkill>());
var inner = new AgentInMemorySkillsSource(System.Array.Empty<AgentSkill>());
var source = new DeduplicatingAgentSkillsSource(inner);
// Act
@@ -90,8 +92,8 @@ public sealed class DeduplicatingAgentSkillsSourceTests
// two skills with the same lowercase name to test case-insensitive dedup.
var skills = new List<AgentSkill>
{
new TestAgentSkill("my-skill", "First", "Instructions 1."),
new TestAgentSkill("my-skill", "Second", "Instructions 2."),
new AgentInlineSkill("my-skill", "First", "Instructions 1."),
new AgentInlineSkill("my-skill", "Second", "Instructions 2."),
};
return Task.FromResult<IList<AgentSkill>>(skills);
}
@@ -15,9 +15,11 @@ public sealed class FilteringAgentSkillsSourceTests
public async Task GetSkillsAsync_PredicateIncludesAll_ReturnsAllSkillsAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(
new TestAgentSkill("skill-a", "A", "Instructions A."),
new TestAgentSkill("skill-b", "B", "Instructions B."));
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("skill-a", "A", "Instructions A."),
new AgentInlineSkill("skill-b", "B", "Instructions B."),
});
var source = new FilteringAgentSkillsSource(inner, _ => true);
// Act
@@ -31,9 +33,11 @@ public sealed class FilteringAgentSkillsSourceTests
public async Task GetSkillsAsync_PredicateExcludesAll_ReturnsEmptyAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(
new TestAgentSkill("skill-a", "A", "Instructions A."),
new TestAgentSkill("skill-b", "B", "Instructions B."));
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("skill-a", "A", "Instructions A."),
new AgentInlineSkill("skill-b", "B", "Instructions B."),
});
var source = new FilteringAgentSkillsSource(inner, _ => false);
// Act
@@ -47,10 +51,12 @@ public sealed class FilteringAgentSkillsSourceTests
public async Task GetSkillsAsync_PartialFilter_ReturnsMatchingSkillsOnlyAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(
new TestAgentSkill("keep-me", "Keep", "Instructions."),
new TestAgentSkill("drop-me", "Drop", "Instructions."),
new TestAgentSkill("keep-also", "KeepAlso", "Instructions."));
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("keep-me", "Keep", "Instructions."),
new AgentInlineSkill("drop-me", "Drop", "Instructions."),
new AgentInlineSkill("keep-also", "KeepAlso", "Instructions."),
});
var source = new FilteringAgentSkillsSource(
inner,
skill => skill.Frontmatter.Name.StartsWith("keep", StringComparison.OrdinalIgnoreCase));
@@ -67,7 +73,7 @@ public sealed class FilteringAgentSkillsSourceTests
public async Task GetSkillsAsync_EmptySource_ReturnsEmptyAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(Array.Empty<AgentSkill>());
var inner = new AgentInMemorySkillsSource(Array.Empty<AgentSkill>());
var source = new FilteringAgentSkillsSource(inner, _ => true);
// Act
@@ -81,7 +87,7 @@ public sealed class FilteringAgentSkillsSourceTests
public void Constructor_NullPredicate_Throws()
{
// Arrange
var inner = new TestAgentSkillsSource(Array.Empty<AgentSkill>());
var inner = new AgentInMemorySkillsSource(Array.Empty<AgentSkill>());
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new FilteringAgentSkillsSource(inner, null!));
@@ -98,11 +104,13 @@ public sealed class FilteringAgentSkillsSourceTests
public async Task GetSkillsAsync_PreservesOrderAsync()
{
// Arrange
var inner = new TestAgentSkillsSource(
new TestAgentSkill("alpha", "Alpha", "Instructions."),
new TestAgentSkill("beta", "Beta", "Instructions."),
new TestAgentSkill("gamma", "Gamma", "Instructions."),
new TestAgentSkill("delta", "Delta", "Instructions."));
var inner = new AgentInMemorySkillsSource(new AgentSkill[]
{
new AgentInlineSkill("alpha", "Alpha", "Instructions."),
new AgentInlineSkill("beta", "Beta", "Instructions."),
new AgentInlineSkill("gamma", "Gamma", "Instructions."),
new AgentInlineSkill("delta", "Delta", "Instructions."),
});
// Keep only alpha and gamma
var source = new FilteringAgentSkillsSource(
@@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Shared test helper for <see cref="ChatClientAgent"/> integration tests that verify
/// end-to-end behavior with <see cref="ChatHistoryPersistingChatClient"/> and
/// end-to-end behavior with <see cref="PerServiceCallChatHistoryPersistingChatClient"/> and
/// <see cref="FunctionInvokingChatClient"/>.
/// </summary>
internal static class ChatClientAgentTestHelper
@@ -379,12 +379,10 @@ public partial class ChatClientAgentTests
}
/// <summary>
/// Verify that RunAsync passes ChatOptions with null ConversationId when using regular AgentRunOptions.
/// When per-service-call persistence is active (default), the sentinel conversation ID is set on ChatOptions
/// and then stripped by ChatHistoryPersistingChatClient before reaching the inner client.
/// Verify that RunAsync passes null ChatOptions when using regular AgentRunOptions.
/// </summary>
[Fact]
public async Task RunAsyncPassesChatOptionsWithNullConversationIdWhenUsingRegularAgentRunOptionsAsync()
public async Task RunAsyncPassesNullChatOptionsWhenUsingRegularAgentRunOptionsAsync()
{
// Arrange
ChatOptions? capturedOptions = null;
@@ -403,9 +401,8 @@ public partial class ChatClientAgentTests
// Act
await agent.RunAsync([new(ChatRole.User, "test")], options: runOptions);
// Assert — the inner client receives ChatOptions with null ConversationId (sentinel was stripped)
Assert.NotNull(capturedOptions);
Assert.Null(capturedOptions!.ConversationId);
// Assert
Assert.Null(capturedOptions);
}
/// <summary>
@@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests that verify the end-to-end approval flow behavior of the
/// <see cref="ChatClientAgent"/> class with <see cref="ChatHistoryPersistingChatClient"/>,
/// <see cref="ChatClientAgent"/> class with <see cref="PerServiceCallChatHistoryPersistingChatClient"/>,
/// ensuring that chat history is correctly persisted across multi-turn approval interactions.
/// </summary>
public class ChatClientAgent_ApprovalsTests
@@ -48,7 +48,7 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -123,7 +123,6 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
PersistChatHistoryAtEndOfRun = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -150,8 +149,10 @@ public class ChatClientAgent_ApprovalsTests
expectedHistory:
[
// End-of-run persistence retains the approval request from Turn 1
// and the approval response from Turn 2
new(ChatRole.User, TextContains: "What's the weather?"),
new(ChatRole.Assistant, ContentTypes: [typeof(ToolApprovalRequestContent)]),
new(ChatRole.User, ContentTypes: [typeof(ToolApprovalResponseContent)]),
new(ChatRole.Assistant, ContentTypes: [typeof(FunctionCallContent)]),
new(ChatRole.Tool, ContentTypes: [typeof(FunctionResultContent)]),
new(ChatRole.Assistant, TextContains: "sunny and 22°C"),
@@ -196,7 +197,6 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
PersistChatHistoryAtEndOfRun = false,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -260,7 +260,7 @@ public class ChatClientAgent_ApprovalsTests
agentOptions: new()
{
ChatOptions = new() { Tools = [approvalTool] },
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
},
callIndex: callIndex,
capturedInputs: capturedInputs);
@@ -520,7 +520,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
},
expectedServiceCallCount: 1,
expectedHistory:
@@ -554,7 +554,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Tools = [tool] },
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
},
expectedServiceCallCount: 2,
expectedHistory:
@@ -583,7 +583,6 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
PersistChatHistoryAtEndOfRun = true,
},
expectedServiceCallCount: 1,
expectedHistory:
@@ -615,7 +614,6 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Tools = [tool] },
PersistChatHistoryAtEndOfRun = true,
},
expectedServiceCallCount: 2,
expectedHistory:
@@ -644,7 +642,6 @@ public class ChatClientAgent_ChatHistoryManagementTests
agentOptions: new()
{
ChatOptions = new() { Instructions = "Be helpful" },
PersistChatHistoryAtEndOfRun = false,
},
expectedServiceCallCount: 1);
@@ -176,12 +176,11 @@ public class ChatClientAgent_ChatOptionsMergingTests
}
/// <summary>
/// Verify that ChatOptions merging returns a non-null ChatOptions instance with null ConversationId
/// when both agent and request have no ChatOptions. The sentinel conversation ID is set for
/// per-service-call persistence and stripped before reaching the inner client.
/// Verify that when both agent and request have no ChatOptions, the inner client
/// receives null options.
/// </summary>
[Fact]
public async Task ChatOptionsMergingReturnsChatOptionsWithNullConversationIdWhenBothAgentAndRequestHaveNoneAsync()
public async Task ChatOptionsMergingReturnsNullChatOptionsWhenBothAgentAndRequestHaveNoneAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -201,9 +200,8 @@ public class ChatClientAgent_ChatOptionsMergingTests
// Act
await agent.RunAsync(messages);
// Assert — ChatOptions is non-null because the sentinel was set, but ConversationId is null (stripped)
Assert.NotNull(capturedChatOptions);
Assert.Null(capturedChatOptions!.ConversationId);
// Assert
Assert.Null(capturedChatOptions);
}
/// <summary>
@@ -13,15 +13,15 @@ using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Contains unit tests for the <see cref="ChatHistoryPersistingChatClient"/> decorator,
/// Contains unit tests for the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> decorator,
/// verifying that it persists messages via the <see cref="ChatHistoryProvider"/> after each
/// individual service call by default, or marks messages for end-of-run persistence when the
/// <see cref="ChatClientAgentOptions.PersistChatHistoryAtEndOfRun"/> option is enabled.
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> option is enabled.
/// </summary>
public class ChatHistoryPersistingChatClientTests
public class PerServiceCallChatHistoryPersistingChatClientTests
{
/// <summary>
/// Verifies that by default (PersistChatHistoryAtEndOfRun is false),
/// Verifies that by default (RequirePerServiceCallChatHistoryPersistence is false),
/// the ChatHistoryProvider receives messages after a successful non-streaming call.
/// </summary>
[Fact]
@@ -50,7 +50,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -97,7 +97,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -145,7 +145,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -163,11 +163,10 @@ public class ChatHistoryPersistingChatClientTests
}
/// <summary>
/// Verifies that the decorator is injected in persist mode by default
/// and can be discovered via GetService.
/// Verifies that the decorator is NOT injected by default (RequirePerServiceCallChatHistoryPersistence is false).
/// </summary>
[Fact]
public void ChatClient_ContainsDecorator_InPersistMode_ByDefault()
public void ChatClient_DoesNotContainDecorator_ByDefault()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -176,16 +175,15 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new());
// Assert
var decorator = agent.ChatClient.GetService<ChatHistoryPersistingChatClient>();
Assert.NotNull(decorator);
Assert.False(decorator.MarkOnly);
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
Assert.Null(decorator);
}
/// <summary>
/// Verifies that the decorator is injected in mark-only mode when PersistChatHistoryAtEndOfRun is true.
/// Verifies that the decorator is injected when RequirePerServiceCallChatHistoryPersistence is true.
/// </summary>
[Fact]
public void ChatClient_ContainsDecorator_InMarkOnlyMode_WhenPersistAtEndOfRun()
public void ChatClient_ContainsDecorator_WhenRequirePerServiceCallChatHistoryPersistence()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -193,13 +191,12 @@ public class ChatHistoryPersistingChatClientTests
// Act
ChatClientAgent agent = new(mockService.Object, options: new()
{
PersistChatHistoryAtEndOfRun = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Assert
var decorator = agent.ChatClient.GetService<ChatHistoryPersistingChatClient>();
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
Assert.NotNull(decorator);
Assert.True(decorator.MarkOnly);
}
/// <summary>
@@ -218,27 +215,27 @@ public class ChatHistoryPersistingChatClientTests
});
// Assert
var decorator = agent.ChatClient.GetService<ChatHistoryPersistingChatClient>();
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
Assert.Null(decorator);
}
/// <summary>
/// Verifies that the PersistChatHistoryAtEndOfRun option is included in Clone().
/// Verifies that the RequirePerServiceCallChatHistoryPersistence option is included in Clone().
/// </summary>
[Fact]
public void ChatClientAgentOptions_Clone_IncludesPersistChatHistoryAtEndOfRun()
public void ChatClientAgentOptions_Clone_IncludesRequirePerServiceCallChatHistoryPersistence()
{
// Arrange
var options = new ChatClientAgentOptions
{
PersistChatHistoryAtEndOfRun = true,
RequirePerServiceCallChatHistoryPersistence = true,
};
// Act
var cloned = options.Clone();
// Assert
Assert.True(cloned.PersistChatHistoryAtEndOfRun);
Assert.True(cloned.RequirePerServiceCallChatHistoryPersistence);
}
/// <summary>
@@ -292,7 +289,7 @@ public class ChatHistoryPersistingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -361,7 +358,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -410,7 +407,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
AIContextProviders = [mockContextProvider.Object],
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -457,7 +454,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
AIContextProviders = [mockContextProvider.Object],
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -516,7 +513,7 @@ public class ChatHistoryPersistingChatClientTests
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
AIContextProviders = [mockContextProvider.Object],
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -590,7 +587,7 @@ public class ChatHistoryPersistingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -655,7 +652,7 @@ public class ChatHistoryPersistingChatClientTests
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
}, services: new ServiceCollection().BuildServiceProvider());
// Act
@@ -680,52 +677,12 @@ public class ChatHistoryPersistingChatClientTests
/// Verifies that after a successful run with per-service-call persistence, the notified
/// messages are stamped with the persisted marker so they are not re-notified.
/// </summary>
[Fact]
public async Task RunAsync_MarksNotifiedMessages_WithPersistedMarkerAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(() => new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
PersistChatHistoryAtEndOfRun = false,
});
// Act
var inputMessage = new ChatMessage(ChatRole.User, "test");
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([inputMessage], session);
// Assert — input message should be marked as persisted
Assert.True(
inputMessage.AdditionalProperties?.ContainsKey(ChatHistoryPersistingChatClient.PersistedMarkerKey) == true,
"Input message should be marked as persisted after a successful run.");
}
/// <summary>
/// Verifies that when per-service-call persistence is enabled and the inner client returns a
/// conversation ID, the session's ConversationId is updated after the service call.
/// Verifies that when the inner client returns a real conversation ID,
/// the session's ConversationId is updated after the run.
/// </summary>
[Fact]
public async Task RunAsync_UpdatesSessionConversationId_WhenPerServiceCallPersistenceEnabledAsync()
public async Task RunAsync_UpdatesSessionConversationId_WhenServiceReturnsOneAsync()
{
// Arrange
const string ExpectedConversationId = "conv-123";
@@ -741,10 +698,7 @@ public class ChatHistoryPersistingChatClientTests
ConversationId = ExpectedConversationId,
});
ChatClientAgent agent = new(mockService.Object, options: new()
{
PersistChatHistoryAtEndOfRun = false,
});
ChatClientAgent agent = new(mockService.Object);
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
@@ -766,8 +720,8 @@ public class ChatHistoryPersistingChatClientTests
/// <summary>
/// Verifies that when per-service-call persistence is active and no real conversation ID exists,
/// <see cref="ChatClientAgent"/> sets the <see cref="ChatHistoryPersistingChatClient.LocalHistoryConversationId"/>
/// sentinel on the chat options and <see cref="ChatHistoryPersistingChatClient"/> strips it before
/// <see cref="ChatClientAgent"/> sets the <see cref="PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId"/>
/// sentinel on the chat options and <see cref="PerServiceCallChatHistoryPersistingChatClient"/> strips it before
/// forwarding to the inner client.
/// </summary>
[Fact]
@@ -787,7 +741,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -819,7 +773,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
PersistChatHistoryAtEndOfRun = true,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -854,7 +808,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Create a session with a real conversation ID.
@@ -888,7 +842,7 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Instructions = "test" },
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
@@ -903,11 +857,12 @@ public class ChatHistoryPersistingChatClientTests
}
/// <summary>
/// Verifies that the session's conversation ID is NOT set to the sentinel after the run.
/// The sentinel should only exist transiently on the ChatOptions for the pipeline.
/// Verifies that the session's conversation ID IS set to the sentinel after the run
/// when simulating service-stored chat history. This allows subsequent runs to
/// skip provider resolution in the agent (the decorator handles it).
/// </summary>
[Fact]
public async Task RunAsync_SentinelDoesNotLeakToSession_WhenPerServiceCallPersistenceActiveAsync()
public async Task RunAsync_SetsSentinelOnSession_WhenRequirePerServiceCallChatHistoryPersistenceActiveAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
@@ -920,14 +875,440 @@ public class ChatHistoryPersistingChatClientTests
ChatClientAgent agent = new(mockService.Object, options: new()
{
PersistChatHistoryAtEndOfRun = false,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — session should NOT have the sentinel conversation ID
Assert.Null(session!.ConversationId);
// Assert — session should have the sentinel conversation ID
Assert.Equal(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
/// <summary>
/// Verifies that when simulating service-stored chat history and the service returns
/// a real <see cref="ChatResponse.ConversationId"/>, the conflict detection in
/// <see cref="ChatClientAgent.UpdateSessionConversationId"/> throws because both a
/// <see cref="ChatHistoryProvider"/> and a service-managed ConversationId are present.
/// </summary>
[Fact]
public async Task RunAsync_Throws_WhenServiceReturnsRealConversationIdWithChatHistoryProviderAsync()
{
// Arrange
const string RealConversationId = "service-conv-456";
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
{
ConversationId = RealConversationId,
});
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act & Assert — conflict detection should throw
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
}
/// <summary>
/// Verifies that when simulating service-stored chat history and the request carries a real
/// <see cref="ChatOptions.ConversationId"/>, the decorator skips history loading but still
/// notifies <see cref="AIContextProvider"/>s on success and updates the session ConversationId.
/// </summary>
[Fact]
public async Task RunAsync_NotifiesProvidersAndUpdatesSession_WhenRequestHasRealConversationIdAsync()
{
// Arrange
const string RealConversationId = "real-conv-request";
const string ServiceConversationId = "real-conv-response";
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
mockContextProvider
.Protected()
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
mockContextProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
{
ConversationId = ServiceConversationId,
});
ChatClientAgent agent = new(mockService.Object, options: new()
{
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
// Create a session with a real conversation ID so it's on chatOptions.
var session = await agent.CreateSessionAsync(RealConversationId);
// Act
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — AIContextProvider.InvokedAsync should have been called
mockContextProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
x.RequestMessages.Any(m => m.Text == "test") &&
x.ResponseMessages!.Any(m => m.Text == "response")),
ItExpr.IsAny<CancellationToken>());
// Assert — session should have the service-returned ConversationId
Assert.Equal(ServiceConversationId, (session as ChatClientAgentSession)!.ConversationId);
}
/// <summary>
/// Verifies that when simulating service-stored chat history and the request carries a real
/// <see cref="ChatOptions.ConversationId"/>, the decorator notifies providers of failure
/// when the inner client throws.
/// </summary>
[Fact]
public async Task RunAsync_NotifiesProvidersOfFailure_WhenRequestHasRealConversationIdAsync()
{
// Arrange
const string RealConversationId = "real-conv-failure";
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
mockContextProvider
.Protected()
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
mockContextProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Service error"));
ChatClientAgent agent = new(mockService.Object, options: new()
{
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
var session = await agent.CreateSessionAsync(RealConversationId);
// Act & Assert — should throw
await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
// Assert — AIContextProvider.InvokedAsync should have been called with the failure
mockContextProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.Is<AIContextProvider.InvokedContext>(x => x.InvokeException != null),
ItExpr.IsAny<CancellationToken>());
}
/// <summary>
/// Verifies that in the streaming path, when the request carries a real
/// <see cref="ChatOptions.ConversationId"/>, the decorator skips history loading but still
/// notifies providers and updates the session ConversationId.
/// </summary>
[Fact]
public async Task RunStreamingAsync_NotifiesProvidersAndUpdatesSession_WhenRequestHasRealConversationIdAsync()
{
// Arrange
const string RealConversationId = "real-conv-streaming";
const string ServiceConversationId = "service-conv-streaming";
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
mockContextProvider
.Protected()
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
mockContextProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(CreateAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "streamed") { ConversationId = ServiceConversationId }));
ChatClientAgent agent = new(mockService.Object, options: new()
{
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
var session = await agent.CreateSessionAsync(RealConversationId);
// Act
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
{
// Consume all updates.
}
// Assert — AIContextProvider.InvokedAsync should have been called
mockContextProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.IsAny<AIContextProvider.InvokedContext>(),
ItExpr.IsAny<CancellationToken>());
// Assert — session should have the service-returned ConversationId
Assert.Equal(ServiceConversationId, (session as ChatClientAgentSession)!.ConversationId);
}
/// <summary>
/// Verifies that when simulating and the service unexpectedly returns a real
/// <see cref="ChatResponse.ConversationId"/> (no ConversationId on the request), the decorator
/// notifies providers and updates the session ConversationId without setting the sentinel.
/// </summary>
[Fact]
public async Task RunAsync_NotifiesProvidersAndUpdatesSession_WhenServiceReturnsUnexpectedConversationIdAsync()
{
// Arrange
const string ServiceConversationId = "unexpected-conv-id";
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
mockContextProvider
.Protected()
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
mockContextProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])
{
ConversationId = ServiceConversationId,
});
// No ChatHistoryProvider — so conflict detection won't throw.
ChatClientAgent agent = new(mockService.Object, options: new()
{
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync([new(ChatRole.User, "test")], session);
// Assert — AIContextProvider.InvokedAsync should have been called
mockContextProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.Is<AIContextProvider.InvokedContext>(x =>
x.ResponseMessages!.Any(m => m.Text == "response")),
ItExpr.IsAny<CancellationToken>());
// Assert — session should have the service ConversationId, not the sentinel
Assert.Equal(ServiceConversationId, session!.ConversationId);
}
/// <summary>
/// Verifies that in the streaming path, when the service returns a real ConversationId mid-stream
/// (no ConversationId on the request), the decorator notifies providers and updates the session.
/// </summary>
[Fact]
public async Task RunStreamingAsync_NotifiesProvidersAndUpdatesSession_WhenServiceReturnsUnexpectedConversationIdAsync()
{
// Arrange
const string ServiceConversationId = "unexpected-stream-conv";
Mock<AIContextProvider> mockContextProvider = new(null, null, null);
mockContextProvider.SetupGet(p => p.StateKeys).Returns(["TestContextProvider"]);
mockContextProvider
.Protected()
.Setup<ValueTask<AIContext>>("InvokingCoreAsync", ItExpr.IsAny<AIContextProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((AIContextProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<AIContext>(new AIContext { Messages = ctx.AIContext.Messages }));
mockContextProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<AIContextProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(CreateAsyncEnumerableAsync(
new ChatResponseUpdate(ChatRole.Assistant, "part1"),
new ChatResponseUpdate(null, "part2") { ConversationId = ServiceConversationId }));
// No ChatHistoryProvider — so conflict detection won't throw.
ChatClientAgent agent = new(mockService.Object, options: new()
{
RequirePerServiceCallChatHistoryPersistence = true,
AIContextProviders = [mockContextProvider.Object],
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
{
// Consume all updates.
}
// Assert — AIContextProvider.InvokedAsync should have been called
mockContextProvider
.Protected()
.Verify<ValueTask>("InvokedCoreAsync", Times.Once(),
ItExpr.IsAny<AIContextProvider.InvokedContext>(),
ItExpr.IsAny<CancellationToken>());
// Assert — session should have the service ConversationId, not the sentinel
Assert.Equal(ServiceConversationId, session!.ConversationId);
}
/// <summary>
/// Verifies that when <see cref="ChatOptions.AllowBackgroundResponses"/> is true,
/// the decorator skips history loading and sentinel setting, letting the agent's
/// forced end-of-run path handle persistence.
/// </summary>
[Fact]
public async Task RunAsync_SkipsSimulation_WhenAllowBackgroundResponsesAsync()
{
// Arrange
IEnumerable<ChatMessage>? capturedMessages = null;
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) => capturedMessages = msgs)
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
{
// Add a history message to verify it's NOT prepended in this scenario.
var result = ctx.RequestMessages.ToList();
result.Insert(0, new ChatMessage(ChatRole.Assistant, "history"));
return new ValueTask<IEnumerable<ChatMessage>>(result);
});
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
await agent.RunAsync(
[new(ChatRole.User, "test")],
session,
new AgentRunOptions { AllowBackgroundResponses = true });
// Assert — the inner client should NOT have received history messages
Assert.NotNull(capturedMessages);
var messageList = capturedMessages!.ToList();
Assert.Single(messageList);
Assert.Equal("test", messageList[0].Text);
// Assert — session should NOT have the sentinel (agent handles ConversationId at end-of-run)
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
/// <summary>
/// Verifies that in the streaming path, when <see cref="ChatOptions.AllowBackgroundResponses"/> is true,
/// the decorator skips history loading and sentinel setting.
/// </summary>
[Fact]
public async Task RunStreamingAsync_SkipsSimulation_WhenAllowBackgroundResponsesAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(CreateAsyncEnumerableAsync(new ChatResponseUpdate(ChatRole.Assistant, "response")));
ChatClientAgent agent = new(mockService.Object, options: new()
{
RequirePerServiceCallChatHistoryPersistence = true,
});
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
List<AgentResponseUpdate> updates = [];
await foreach (var update in agent.RunStreamingAsync(
[new(ChatRole.User, "test")],
session,
new AgentRunOptions { AllowBackgroundResponses = true }))
{
updates.Add(update);
}
// Assert — updates should NOT carry the sentinel ConversationId
Assert.NotEmpty(updates);
// Assert — session should NOT have the sentinel
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
}
}
-257
View File
@@ -1,257 +0,0 @@
# Source Generator for Workflow Executors: Rationale and Impact
## Overview
The Microsoft Agents AI Workflows framework has introduced a Roslyn source generator (`Microsoft.Agents.AI.Workflows.Generators`) that replaces the previous reflection-based approach for discovering and registering message handlers. This document explains why this change was made, what benefits it provides, and how it impacts framework users.
## Why Move from Reflection to Code Generation?
### The Previous Approach: `ReflectingExecutor<T>`
Previously, executors that needed automatic handler discovery inherited from `ReflectingExecutor<T>` and implemented marker interfaces like `IMessageHandler<TMessage>`:
```csharp
// Old approach - reflection-based
public class MyExecutor : ReflectingExecutor<MyExecutor>,
IMessageHandler<QueryMessage>,
IMessageHandler<CommandMessage, CommandResult>
{
public ValueTask HandleAsync(QueryMessage msg, IWorkflowContext ctx, CancellationToken ct)
{
// Handle query
}
public ValueTask<CommandResult> HandleAsync(CommandMessage msg, IWorkflowContext ctx, CancellationToken ct)
{
// Handle command and return result
}
}
```
This approach had several limitations:
1. **Runtime overhead**: Handler discovery happened at runtime via reflection, adding latency to executor initialization
2. **No AOT compatibility**: Reflection-based discovery doesn't work with Native AOT compilation
3. **Redundant declarations**: The interface list duplicated information already present in method signatures
4. **Limited metadata**: No clean way to declare yield/send types for protocol validation
5. **Hidden errors**: Invalid handler signatures weren't caught until runtime
### The New Approach: `[MessageHandler]` Attribute
The source generator enables a cleaner, attribute-based pattern:
```csharp
// New approach - source generated
[SendsMessage(typeof(PollToken))]
public partial class MyExecutor : Executor
{
[MessageHandler]
private ValueTask HandleQueryAsync(QueryMessage msg, IWorkflowContext ctx, CancellationToken ct)
{
// Handle query
}
[MessageHandler(Yield = [typeof(StreamChunk)], Send = [typeof(InternalMessage)])]
private ValueTask<CommandResult> HandleCommandAsync(CommandMessage msg, IWorkflowContext ctx, CancellationToken ct)
{
// Handle command and return result
}
}
```
The generator produces a partial class with `ConfigureRoutes()`, `ConfigureSentTypes()`, and `ConfigureYieldTypes()` implementations at compile time.
## What's Better About Code Generation?
### 1. Compile-Time Validation
Invalid handler signatures are caught during compilation, not at runtime:
```csharp
[MessageHandler]
private void InvalidHandler(string msg) // Error WFGEN005: Missing IWorkflowContext parameter
{
}
```
Diagnostic errors include:
- `WFGEN001`: Handler missing `IWorkflowContext` parameter
- `WFGEN002`: Invalid return type (must be `void`, `ValueTask`, or `ValueTask<T>`)
- `WFGEN003`: Executor class must be `partial`
- `WFGEN004`: `[MessageHandler]` on non-Executor class
- `WFGEN005`: Insufficient parameters
- `WFGEN006`: `ConfigureRoutes` already manually defined
### 2. Zero Runtime Reflection
All handler registration happens at compile time. The generated code is simple, direct method calls:
```csharp
// Generated code
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder
.AddHandler<QueryMessage>(this.HandleQueryAsync)
.AddHandler<CommandMessage, CommandResult>(this.HandleCommandAsync);
}
```
This eliminates:
- Reflection overhead during initialization
- Assembly scanning
- Dynamic delegate creation
### 3. Native AOT Compatibility
Because there's no runtime reflection, executors work seamlessly with .NET Native AOT compilation. This enables:
- Faster startup times
- Smaller deployment sizes
- Deployment to environments that don't support JIT compilation
### 4. Explicit Protocol Metadata
The `Yield` and `Send` properties on `[MessageHandler]` plus class-level `[SendsMessage]` and `[YieldsMessage]` attributes provide explicit protocol documentation:
```csharp
[SendsMessage(typeof(PollToken))] // This executor sends PollToken messages
[YieldsMessage(typeof(FinalResult))] // This executor yields FinalResult to workflow output
public partial class MyExecutor : Executor
{
[MessageHandler(
Yield = [typeof(StreamChunk)], // This handler yields StreamChunk
Send = [typeof(InternalQuery)])] // This handler sends InternalQuery
private ValueTask HandleAsync(Request req, IWorkflowContext ctx) { ... }
}
```
This metadata enables:
- Static protocol validation
- Better IDE tooling and documentation
- Clearer code intent
### 5. Handler Accessibility Freedom
Handlers can be `private`, `protected`, `internal`, or `public`. The old interface-based approach required public methods. Now you can encapsulate handler implementations:
```csharp
public partial class MyExecutor : Executor
{
[MessageHandler]
private ValueTask HandleInternalAsync(InternalMessage msg, IWorkflowContext ctx)
{
// Private handler - implementation detail
}
}
```
### 6. Cleaner Inheritance
The generator properly handles inheritance chains, calling `base.ConfigureRoutes()` when appropriate:
```csharp
public partial class DerivedExecutor : BaseExecutor
{
[MessageHandler]
private ValueTask HandleDerivedAsync(DerivedMessage msg, IWorkflowContext ctx) { ... }
}
// Generated:
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
routeBuilder = base.ConfigureRoutes(routeBuilder); // Preserves base handlers
return routeBuilder
.AddHandler<DerivedMessage>(this.HandleDerivedAsync);
}
```
## New Capabilities Enabled
### 1. Static Workflow Analysis
With explicit yield/send metadata, tools can analyze workflow graphs at compile time:
- Validate that all message types have handlers
- Detect unreachable executors
- Generate workflow documentation
### 2. Trimming-Safe Deployments
The generated code contains no reflection, making it fully compatible with IL trimming. This reduces deployment size significantly for serverless and edge scenarios.
### 3. Better IDE Experience
Because the generator runs in the IDE, you get:
- Immediate feedback on handler signature errors
- IntelliSense for generated methods
- Go-to-definition on generated code
### 4. Protocol Documentation Generation
The explicit type metadata can be used to generate:
- API documentation
- OpenAPI/Swagger specs for workflow endpoints
- Visual workflow diagrams
## Impact on Framework Users
### Migration Path
Existing code using `ReflectingExecutor<T>` continues to work but is marked `[Obsolete]`. To migrate:
1. Change base class from `ReflectingExecutor<T>` to `Executor`
2. Add `partial` modifier to the class
3. Replace `IMessageHandler<T>` interfaces with `[MessageHandler]` attributes
4. Optionally add `Yield`/`Send` metadata for protocol validation
**Before:**
```csharp
public class MyExecutor : ReflectingExecutor<MyExecutor>, IMessageHandler<Query, Result>
{
public ValueTask<Result> HandleAsync(Query q, IWorkflowContext ctx, CancellationToken ct) { ... }
}
```
**After:**
```csharp
public partial class MyExecutor : Executor
{
[MessageHandler]
private ValueTask<Result> HandleQueryAsync(Query q, IWorkflowContext ctx, CancellationToken ct) { ... }
}
```
### Breaking Changes
- Classes using `[MessageHandler]` **must** be `partial`
- Handler methods must have at least 2 parameters: `(TMessage, IWorkflowContext)`
- Return type must be `void`, `ValueTask`, or `ValueTask<T>`
### Performance Improvements
Users can expect:
- **Faster executor initialization**: No reflection overhead
- **Reduced memory allocation**: No dynamic delegate creation
- **AOT deployment support**: Full Native AOT compatibility
- **Smaller trimmed deployments**: No reflection metadata preserved
### NuGet Package
The generator is distributed as a separate NuGet package (`Microsoft.Agents.AI.Workflows.Generators`) that's automatically referenced by the main Workflows package. It's packaged as an analyzer, so it:
- Runs automatically during build
- Requires no additional configuration
- Works in all IDEs that support Roslyn analyzers
## Summary
The move from reflection to source generation represents a significant improvement in the Workflows framework:
| Aspect | Reflection (Old) | Source Generator (New) |
|--------|------------------|------------------------|
| Handler discovery | Runtime | Compile-time |
| Error detection | Runtime exceptions | Compiler errors |
| AOT support | No | Yes |
| Trimming support | Limited | Full |
| Protocol metadata | Implicit | Explicit |
| Handler visibility | Public only | Any |
| Initialization speed | Slower | Faster |
The source generator approach aligns with modern .NET best practices and positions the framework for future scenarios including edge computing, serverless, and mobile deployments where AOT compilation and minimal footprint are essential.
-439
View File
@@ -1,439 +0,0 @@
# Source Generator Best Practices Review
This document reviews the Workflow Executor Route Source Generator implementation against the official Roslyn Source Generator Cookbook best practices from the dotnet/roslyn repository.
## Reference Documentation
- [Source Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md)
- [Incremental Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.cookbook.md)
---
## Executive Summary
| Category | Status | Priority |
|----------|--------|----------|
| Generator Type | PASS | - |
| Attribute-Based Detection | FAIL | HIGH |
| Model Value Equality | FAIL | HIGH |
| Collection Equality | FAIL | HIGH |
| Symbol/SyntaxNode Storage | PASS | - |
| Code Generation Approach | PASS | - |
| Diagnostics | PASS | - |
| Pipeline Efficiency | FAIL | MEDIUM |
| CancellationToken Handling | PARTIAL | LOW |
**Overall Assessment**: The generator follows several best practices but has critical performance issues that should be addressed before production use. The most significant issue is not using `ForAttributeWithMetadataName`, which the Roslyn team states is "at least 99x more efficient" than `CreateSyntaxProvider`.
---
## Detailed Analysis
### 1. Generator Interface Selection
**Best Practice**: Use `IIncrementalGenerator` instead of the deprecated `ISourceGenerator`.
**Our Implementation**: PASS
```csharp
// ExecutorRouteGenerator.cs:19
public sealed class ExecutorRouteGenerator : IIncrementalGenerator
```
The generator correctly implements `IIncrementalGenerator`, the recommended interface for new generators.
---
### 2. Attribute-Based Detection with ForAttributeWithMetadataName
**Best Practice**: Use `ForAttributeWithMetadataName()` for attribute-based discovery.
> "This utility method is at least 99x more efficient than `SyntaxProvider.CreateSyntaxProvider`, and in many cases even more efficient."
> — Roslyn Incremental Generators Cookbook
**Our Implementation**: FAIL (HIGH PRIORITY)
```csharp
// ExecutorRouteGenerator.cs:25-30
var executorCandidates = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node),
transform: static (ctx, ct) => SemanticAnalyzer.Analyze(ctx, ct, out _))
```
**Problem**: We use `CreateSyntaxProvider` with manual attribute detection in `SyntaxDetector`. This requires the generator to examine every syntax node in the compilation, whereas `ForAttributeWithMetadataName` uses the compiler's built-in attribute index for O(1) lookup.
**Recommended Fix**:
```csharp
var executorCandidates = context.SyntaxProvider
.ForAttributeWithMetadataName(
fullyQualifiedMetadataName: "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute",
predicate: static (node, _) => node is MethodDeclarationSyntax,
transform: static (ctx, ct) => AnalyzeMethodWithAttribute(ctx, ct))
.Collect()
.SelectMany((methods, _) => GroupByContainingClass(methods));
```
**Impact**: Current approach causes IDE lag on every keystroke in large projects.
---
### 3. Model Value Equality (Records vs Classes)
**Best Practice**: Use `record` types for pipeline models to get automatic value equality.
> "Use `record`s, rather than `class`es, so that value equality is generated for you."
> — Roslyn Incremental Generators Cookbook
**Our Implementation**: FAIL (HIGH PRIORITY)
```csharp
// HandlerInfo.cs:28
internal sealed class HandlerInfo { ... }
// ExecutorInfo.cs:10
internal sealed class ExecutorInfo { ... }
```
**Problem**: Both `HandlerInfo` and `ExecutorInfo` are `sealed class` types, which use reference equality by default. The incremental generator caches results based on equality comparison—when the model equals the previous run's model, regeneration is skipped. With reference equality, every analysis produces a "new" object, defeating caching entirely.
**Recommended Fix**:
```csharp
// HandlerInfo.cs
internal sealed record HandlerInfo(
string MethodName,
string InputTypeName,
string? OutputTypeName,
HandlerSignatureKind SignatureKind,
bool HasCancellationToken,
EquatableArray<string>? YieldTypes,
EquatableArray<string>? SendTypes);
// ExecutorInfo.cs
internal sealed record ExecutorInfo(
string? Namespace,
string ClassName,
string? GenericParameters,
bool IsNested,
string ContainingTypeChain,
bool BaseHasConfigureRoutes,
EquatableArray<HandlerInfo> Handlers,
EquatableArray<string> ClassSendTypes,
EquatableArray<string> ClassYieldTypes);
```
**Impact**: Without value equality, the generator regenerates code on every compilation even when nothing changed.
---
### 4. Collection Equality
**Best Practice**: Use custom equatable wrappers for collections since `ImmutableArray<T>` uses reference equality.
> "Arrays, `ImmutableArray<T>`, and `List<T>` use reference equality by default. Wrap collections with custom types implementing value-based equality."
> — Roslyn Incremental Generators Cookbook
**Our Implementation**: FAIL (HIGH PRIORITY)
```csharp
// ExecutorInfo.cs:46
public ImmutableArray<HandlerInfo> Handlers { get; }
// HandlerInfo.cs:58-63
public ImmutableArray<string>? YieldTypes { get; }
public ImmutableArray<string>? SendTypes { get; }
```
**Problem**: `ImmutableArray<T>` compares by reference, not by contents. Two arrays with identical elements are considered unequal, breaking incremental caching.
**Recommended Fix**: Create an `EquatableArray<T>` wrapper:
```csharp
internal readonly struct EquatableArray<T> : IEquatable<EquatableArray<T>>, IEnumerable<T>
where T : IEquatable<T>
{
private readonly ImmutableArray<T> _array;
public EquatableArray(ImmutableArray<T> array) => _array = array;
public bool Equals(EquatableArray<T> other)
{
if (_array.Length != other._array.Length) return false;
for (int i = 0; i < _array.Length; i++)
{
if (!_array[i].Equals(other._array[i])) return false;
}
return true;
}
public override int GetHashCode()
{
var hash = new HashCode();
foreach (var item in _array) hash.Add(item);
return hash.ToHashCode();
}
// ... IEnumerable implementation
}
```
**Impact**: Same as model equality—caching is completely broken for handlers and type arrays.
---
### 5. Symbol and SyntaxNode Storage
**Best Practice**: Never store `ISymbol` or `SyntaxNode` in pipeline models.
> "Storing `ISymbol` references blocks garbage collection and roots old compilations unnecessarily. Extract only the information you need—typically string representations work well—into your equatable models."
> — Roslyn Incremental Generators Cookbook
**Our Implementation**: PASS
The models correctly store only primitive types and strings:
```csharp
// HandlerInfo.cs - stores strings, not symbols
public string MethodName { get; }
public string InputTypeName { get; }
public string? OutputTypeName { get; }
// ExecutorInfo.cs - stores strings, not symbols
public string? Namespace { get; }
public string ClassName { get; }
```
The `SemanticAnalyzer` correctly extracts string representations from symbols:
```csharp
// SemanticAnalyzer.cs:300-301
var inputType = methodSymbol.Parameters[0].Type;
var inputTypeName = inputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
```
---
### 6. Code Generation Approach
**Best Practice**: Use `StringBuilder` for code generation, not `SyntaxNode` construction.
> "Avoid constructing `SyntaxNode`s for output; they're complex to format correctly and `NormalizeWhitespace()` is expensive. Instead, use a `StringBuilder` wrapper that tracks indentation levels."
> — Roslyn Incremental Generators Cookbook
**Our Implementation**: PASS
```csharp
// SourceBuilder.cs:17-19
public static string Generate(ExecutorInfo info)
{
var sb = new StringBuilder();
```
The `SourceBuilder` correctly uses `StringBuilder` with manual indentation tracking.
---
### 7. Diagnostic Reporting
**Best Practice**: Use `ReportDiagnostic` for surfacing issues to users.
**Our Implementation**: PASS
```csharp
// ExecutorRouteGenerator.cs:44-50
context.RegisterSourceOutput(diagnosticsProvider, static (ctx, diagnostics) =>
{
foreach (var diagnostic in diagnostics)
{
ctx.ReportDiagnostic(diagnostic);
}
});
```
Diagnostics are well-defined with appropriate severities:
| ID | Severity | Description |
|----|----------|-------------|
| WFGEN001 | Error | Missing IWorkflowContext parameter |
| WFGEN002 | Error | Invalid return type |
| WFGEN003 | Error | Class must be partial |
| WFGEN004 | Warning | Not an Executor |
| WFGEN005 | Error | Insufficient parameters |
| WFGEN006 | Info | ConfigureRoutes already defined |
| WFGEN007 | Error | Handler cannot be static |
---
### 8. Pipeline Efficiency
**Best Practice**: Avoid duplicate work in the pipeline.
**Our Implementation**: FAIL (MEDIUM PRIORITY)
```csharp
// ExecutorRouteGenerator.cs:25-41
// Pipeline 1: Get executor candidates
var executorCandidates = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node),
transform: static (ctx, ct) => SemanticAnalyzer.Analyze(ctx, ct, out _))
...
// Pipeline 2: Get diagnostics (duplicates the same work!)
var diagnosticsProvider = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node),
transform: static (ctx, ct) =>
{
SemanticAnalyzer.Analyze(ctx, ct, out var diagnostics);
return diagnostics;
})
```
**Problem**: The same syntax detection and semantic analysis runs twice—once for extracting `ExecutorInfo` and once for extracting diagnostics.
**Recommended Fix**: Return both in a single pipeline:
```csharp
var analysisResults = context.SyntaxProvider
.ForAttributeWithMetadataName(...)
.Select((ctx, ct) => {
var info = SemanticAnalyzer.Analyze(ctx, ct, out var diagnostics);
return (Info: info, Diagnostics: diagnostics);
});
// Split for different outputs
context.RegisterSourceOutput(
analysisResults.Where(r => r.Info != null).Select((r, _) => r.Info!),
GenerateSource);
context.RegisterSourceOutput(
analysisResults.Where(r => r.Diagnostics.Length > 0).Select((r, _) => r.Diagnostics),
ReportDiagnostics);
```
---
### 9. Base Type Chain Scanning
**Best Practice**: Avoid scanning indirect type relationships when possible.
> "Never scan for types that indirectly implement interfaces, inherit from base types, or acquire attributes through inheritance hierarchies. This pattern forces the generator to inspect every type's `AllInterfaces` or base-type chain on every keystroke."
> — Roslyn Incremental Generators Cookbook
**Our Implementation**: PARTIAL CONCERN
```csharp
// SemanticAnalyzer.cs:126-141
private static bool DerivesFromExecutor(INamedTypeSymbol classSymbol)
{
var current = classSymbol.BaseType;
while (current != null)
{
var fullName = current.OriginalDefinition.ToDisplayString();
if (fullName == ExecutorTypeName || fullName.StartsWith(ExecutorTypeName + "<", ...))
{
return true;
}
current = current.BaseType;
}
return false;
}
```
**Analysis**: We do walk the base type chain, but this only happens after attribute filtering (classes must have `[MessageHandler]` methods). Since this is targeted to specific candidates rather than scanning all types, the performance impact is acceptable. However, if we switch to `ForAttributeWithMetadataName`, the attribute is on methods, so we'd need to check the containing class's base types—which is still targeted.
---
### 10. CancellationToken Handling
**Best Practice**: Respect `CancellationToken` in long-running operations.
**Our Implementation**: PARTIAL (LOW PRIORITY)
The `CancellationToken` is passed through to semantic model calls:
```csharp
// SemanticAnalyzer.cs:46
var classSymbol = semanticModel.GetDeclaredSymbol(classDecl, cancellationToken);
```
However, there are no explicit `cancellationToken.ThrowIfCancellationRequested()` calls in loops like `AnalyzeHandlers`. For most compilations this is fine, but very large classes with many handlers might benefit from periodic checks.
---
### 11. File Naming Convention
**Best Practice**: Use descriptive generated file names with `.g.cs` suffix.
**Our Implementation**: PASS
```csharp
// ExecutorRouteGenerator.cs:62-91
private static string GetHintName(ExecutorInfo info)
{
// Produces: "Namespace.ClassName.g.cs" or "Namespace.Outer.Inner.ClassName.g.cs"
...
sb.Append(".g.cs");
return sb.ToString();
}
```
---
## Recommended Action Plan
### High Priority (Performance Critical)
1. **Switch to `ForAttributeWithMetadataName`**
- Estimated impact: 99x+ performance improvement for attribute detection
- Requires restructuring the pipeline to collect methods then group by class
2. **Convert models to records**
- Change `HandlerInfo` and `ExecutorInfo` from `sealed class` to `sealed record`
- Enables automatic value equality for incremental caching
3. **Implement `EquatableArray<T>`**
- Create wrapper struct with value-based equality
- Replace all `ImmutableArray<T>` usages in models
### Medium Priority (Efficiency)
4. **Eliminate duplicate pipeline execution**
- Combine info extraction and diagnostic collection into single pipeline
- Split outputs using `Where` and `Select`
### Low Priority (Polish)
5. **Add periodic cancellation checks**
- Add `ThrowIfCancellationRequested()` in handler analysis loop
- Only needed for extremely large classes
---
## Compliance Matrix
| Best Practice | Cookbook Reference | Status | Fix Required |
|--------------|-------------------|--------|--------------|
| Use IIncrementalGenerator | Main cookbook | PASS | No |
| Use ForAttributeWithMetadataName | Incremental cookbook | FAIL | Yes (High) |
| Use records for models | Incremental cookbook | FAIL | Yes (High) |
| Implement collection equality | Incremental cookbook | FAIL | Yes (High) |
| Don't store ISymbol/SyntaxNode | Incremental cookbook | PASS | No |
| Use StringBuilder for codegen | Incremental cookbook | PASS | No |
| Report diagnostics properly | Main cookbook | PASS | No |
| Avoid duplicate pipeline work | Incremental cookbook | FAIL | Yes (Medium) |
| Respect CancellationToken | Main cookbook | PARTIAL | Optional |
| Use .g.cs file suffix | Main cookbook | PASS | No |
| Additive-only generation | Main cookbook | PASS | No |
| No language feature emulation | Main cookbook | PASS | No |
---
## Conclusion
The source generator implementation demonstrates solid understanding of Roslyn generator fundamentals—correct interface usage, proper diagnostic reporting, and appropriate code generation patterns. However, critical performance optimizations are missing that could cause significant IDE lag in production environments.
The three high-priority fixes (ForAttributeWithMetadataName, record models, and EquatableArray) should be implemented before the generator is used in large codebases. These changes will enable proper incremental caching, reducing regeneration from "every keystroke" to "only when relevant code changes."
-258
View File
@@ -1,258 +0,0 @@
# Workflow Executor Route Source Generator - Implementation Summary
This document summarizes all changes made to implement a Roslyn source generator that replaces the reflection-based `ReflectingExecutor<T>` pattern with compile-time code generation using `[MessageHandler]` attributes.
## Overview
The source generator automatically discovers methods marked with `[MessageHandler]` and generates `ConfigureRoutes`, `ConfigureSentTypes`, and `ConfigureYieldTypes` method implementations at compile time. This improves AOT compatibility and eliminates the need for the CRTP (Curiously Recurring Template Pattern) used by `ReflectingExecutor<T>`.
## New Files Created
### Attributes (3 files)
| File | Purpose |
|------|---------|
| `src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs` | Marks methods as message handlers with optional `Yield` and `Send` type arrays |
| `src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs` | Class-level attribute declaring message types an executor may send |
| `src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs` | Class-level attribute declaring output types an executor may yield |
### Source Generator Project (8 files)
| File | Purpose |
|------|---------|
| `src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj` | Project file targeting netstandard2.0 with Roslyn component settings |
| `src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs` | Main incremental generator implementing `IIncrementalGenerator` |
| `src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs` | Data model for handler method information |
| `src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs` | Data model for executor class information |
| `src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SyntaxDetector.cs` | Fast syntax-level candidate detection |
| `src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs` | Semantic validation and type extraction |
| `src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs` | Code generation logic |
| `src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs` | Analyzer diagnostic definitions |
## Files Modified
### Project Files
| File | Changes |
|------|---------|
| `src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj` | Added generator project reference and `InternalsVisibleTo` for generator tests |
| `Directory.Packages.props` | Added `Microsoft.CodeAnalysis.Analyzers` version 3.11.0 |
| `agent-framework-dotnet.slnx` | Added generator project to solution |
### Obsolete Annotations
| File | Changes |
|------|---------|
| `src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs` | Added `[Obsolete]` attribute with migration guidance |
| `src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs` | Added `[Obsolete]` to both `IMessageHandler<T>` and `IMessageHandler<T,TResult>` interfaces |
### Pragma Suppressions for Internal Obsolete Usage
| File | Changes |
|------|---------|
| `src/Microsoft.Agents.AI.Workflows/Executor.cs` | Added `#pragma warning disable CS0618` |
| `src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs` | Added `#pragma warning disable CS0618` |
| `src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs` | Added `#pragma warning disable CS0618` |
| `src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs` | Added `#pragma warning disable CS0618` |
### Test File Pragma Suppressions
| File | Changes |
|------|---------|
| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing |
| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing |
| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing |
| `tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing |
## Attribute Definitions
### MessageHandlerAttribute
```csharp
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public sealed class MessageHandlerAttribute : Attribute
{
public Type[]? Yield { get; set; } // Types yielded as workflow outputs
public Type[]? Send { get; set; } // Types sent to other executors
}
```
### SendsMessageAttribute
```csharp
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class SendsMessageAttribute : Attribute
{
public Type Type { get; }
public SendsMessageAttribute(Type type) => this.Type = Throw.IfNull(type);
}
```
### YieldsMessageAttribute
```csharp
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class YieldsMessageAttribute : Attribute
{
public Type Type { get; }
public YieldsMessageAttribute(Type type) => this.Type = Throw.IfNull(type);
}
```
## Diagnostic Rules
| ID | Severity | Description |
|----|----------|-------------|
| `WFGEN001` | Error | Handler method must have at least 2 parameters (message and IWorkflowContext) |
| `WFGEN002` | Error | Handler method's second parameter must be IWorkflowContext |
| `WFGEN003` | Error | Handler method must return void, ValueTask, or ValueTask<T> |
| `WFGEN004` | Error | Executor class with [MessageHandler] methods must be declared as partial |
| `WFGEN005` | Warning | [MessageHandler] attribute on method in non-Executor class (ignored) |
| `WFGEN006` | Info | ConfigureRoutes already defined manually, [MessageHandler] methods ignored |
| `WFGEN007` | Error | Handler method's third parameter (if present) must be CancellationToken |
## Handler Signature Support
The generator supports the following method signatures:
| Return Type | Parameters | Generated Call |
|-------------|------------|----------------|
| `void` | `(TMessage, IWorkflowContext)` | `AddHandler<TMessage>(this.Method)` |
| `void` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler<TMessage>(this.Method)` |
| `ValueTask` | `(TMessage, IWorkflowContext)` | `AddHandler<TMessage>(this.Method)` |
| `ValueTask` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler<TMessage>(this.Method)` |
| `TResult` | `(TMessage, IWorkflowContext)` | `AddHandler<TMessage, TResult>(this.Method)` |
| `TResult` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler<TMessage, TResult>(this.Method)` |
| `ValueTask<TResult>` | `(TMessage, IWorkflowContext)` | `AddHandler<TMessage, TResult>(this.Method)` |
| `ValueTask<TResult>` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler<TMessage, TResult>(this.Method)` |
## Generated Code Example
### Input (User Code)
```csharp
[SendsMessage(typeof(PollToken))]
public partial class MyChatExecutor : Executor
{
[MessageHandler]
private async ValueTask<ChatResponse> HandleQueryAsync(
ChatQuery query, IWorkflowContext ctx, CancellationToken ct)
{
return new ChatResponse(...);
}
[MessageHandler(Yield = new[] { typeof(StreamChunk) }, Send = new[] { typeof(InternalMessage) })]
private void HandleStream(StreamRequest req, IWorkflowContext ctx)
{
// Handler implementation
}
}
```
### Output (Generated Code)
```csharp
// <auto-generated/>
#nullable enable
namespace MyNamespace;
partial class MyChatExecutor
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder
.AddHandler<ChatQuery, ChatResponse>(this.HandleQueryAsync)
.AddHandler<StreamRequest>(this.HandleStream);
}
protected override ISet<Type> ConfigureSentTypes()
{
var types = base.ConfigureSentTypes();
types.Add(typeof(PollToken));
types.Add(typeof(InternalMessage));
return types;
}
protected override ISet<Type> ConfigureYieldTypes()
{
var types = base.ConfigureYieldTypes();
types.Add(typeof(ChatResponse));
types.Add(typeof(StreamChunk));
return types;
}
}
```
## Build Issues Resolved
### 1. NU1008 - Central Package Management
Package references in the generator project had inline versions, which conflicts with central package management. Fixed by removing `Version` attributes from `PackageReference` items.
### 2. RS2008 - Analyzer Release Tracking
Roslyn requires analyzer release tracking documentation. Fixed by adding `<NoWarn>$(NoWarn);RS2008</NoWarn>` to the generator project.
### 3. CA1068 - CancellationToken Parameter Order
Method parameters were in wrong order. Fixed by reordering `CancellationToken` to be last.
### 4. RCS1146 - Conditional Access
Used null check with `&&` instead of `?.` operator. Fixed by using conditional access.
### 5. CA1310 - StringComparison
`StartsWith(string)` calls without `StringComparison`. Fixed by adding `StringComparison.Ordinal`.
### 6. CS0103 - Missing Using Directive
Missing `using System;` in SemanticAnalyzer.cs. Fixed by adding the using directive.
### 7. CS0618 - Obsolete Warnings as Errors
Internal uses of obsolete types caused build failures (TreatWarningsAsErrors). Fixed by adding `#pragma warning disable CS0618` to affected internal files and test files.
### 8. NU1109 - Package Version Conflict
`Microsoft.CodeAnalysis.Analyzers` 3.3.4 conflicts with `Microsoft.CodeAnalysis.CSharp` 4.14.0 which requires >= 3.11.0. Fixed by updating version to 3.11.0 in `Directory.Packages.props`.
### 9. RS1041 - Wrong Target Framework for Analyzer
The generator was being multi-targeted due to inherited `TargetFrameworks` from `Directory.Build.props`. Fixed by clearing `TargetFrameworks` and only setting `TargetFramework` to `netstandard2.0`.
## Migration Guide
### Before (Reflection-based)
```csharp
public class MyExecutor : ReflectingExecutor<MyExecutor>, IMessageHandler<MyMessage, MyResult>
{
public MyExecutor() : base("MyExecutor") { }
public ValueTask<MyResult> HandleAsync(MyMessage message, IWorkflowContext context, CancellationToken ct)
{
// Handler implementation
}
}
```
### After (Source Generator)
```csharp
public partial class MyExecutor : Executor
{
public MyExecutor() : base("MyExecutor") { }
[MessageHandler]
private ValueTask<MyResult> HandleAsync(MyMessage message, IWorkflowContext context, CancellationToken ct)
{
// Handler implementation
}
}
```
Key migration steps:
1. Change base class from `ReflectingExecutor<T>` to `Executor`
2. Add `partial` modifier to the class
3. Remove `IMessageHandler<T>` interface implementations
4. Add `[MessageHandler]` attribute to handler methods
5. Handler methods can now be any accessibility (private, protected, internal, public)
## Future Work
- Create comprehensive unit tests for the source generator
- Add integration tests verifying generated routes match reflection-discovered routes
- Consider adding IDE quick-fix for migrating from `ReflectingExecutor<T>` pattern
+1 -1
View File
@@ -76,7 +76,7 @@ from agent_framework.observability import enable_instrumentation
# Connectors (lazy-loaded)
from agent_framework.openai import OpenAIChatClient
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.foundry import FoundryChatClient
```
## Public API and Exports
+238
View File
@@ -0,0 +1,238 @@
# Copyright (c) Microsoft. All rights reserved.
---
name: python-feature-lifecycle
description: >
Guidance for package and feature lifecycle in the Agent Framework Python
codebase, including stage meanings, feature-stage decorators, feature enums,
and how to move APIs from one stage to the next.
---
# Python Feature Lifecycle
## Two lifecycle levels
Agent Framework uses lifecycle at two different levels:
1. **Package lifecycle** — the maturity of the package as a whole
2. **Feature lifecycle** — the maturity of a specific API or feature inside that package
These are related, but they are **not the same thing**.
- The **package stage is the default** for everything in the package.
- **Feature-stage decorators are only for exceptions** when a feature is behind the package's default stage.
- Do **not** decorate every class or function just because the package is experimental or release candidate.
### Important default
If a package is still in **beta / experimental preview**, all public APIs in that package are experimental by default.
- Do **not** add `@experimental(...)` everywhere in that package.
- The package stage already communicates that default.
Once a package moves forward, you can keep individual features behind:
- If a package moves to **release candidate**, a feature may remain **experimental**
- If a package moves to **released / GA**, a feature may remain **experimental** or **release candidate**
That is the main use case for feature-stage decorators.
## The four stages
### 1. Experimental
Use for features that are still unstable and may change or be removed without notice.
Feature-level code pattern:
```python
from ._feature_stage import ExperimentalFeature, experimental
@experimental(feature_id=ExperimentalFeature.MY_FEATURE)
class MyFeature:
...
```
Behavior:
- Adds an experimental warning block to the docstring
- Records feature metadata on the decorated object
- Emits a runtime warning the first time the feature is used (once per feature by default)
Enum setup:
- Add an all-caps member to `ExperimentalFeature`
- Reuse the same feature ID across all APIs that belong to the same conceptual feature
### 2. Release candidate
Use for features that are nearly stable but may still receive small refinements before GA.
Feature-level code pattern:
```python
from ._feature_stage import ReleaseCandidateFeature, release_candidate
@release_candidate(feature_id=ReleaseCandidateFeature.MY_FEATURE)
class MyFeature:
...
```
Behavior:
- Adds a release-candidate note to the docstring
- Records feature metadata on the decorated object
- Does **not** emit the experimental warning
Enum setup:
- Add an all-caps member to `ReleaseCandidateFeature`
### 3. Released
Use for stable GA APIs.
Code pattern:
- **No feature-stage decorator**
- **No entry** in `ExperimentalFeature`
- **No entry** in `ReleaseCandidateFeature`
If a feature is fully released, remove any stage-specific feature annotation.
### 4. Deprecated
Use for APIs that still exist but should not be used for new code.
Code pattern:
```python
import sys
if sys.version_info >= (3, 13):
from warnings import deprecated # type: ignore # pragma: no cover
else:
from typing_extensions import deprecated # type: ignore # pragma: no cover
@deprecated("MyOldFeature is deprecated. Use MyNewFeature instead.")
class MyOldFeature:
...
```
Behavior:
- Uses the repository's version-conditional deprecation import pattern
- Should describe what to use instead
Deprecated APIs should not also carry feature-stage decorators.
## Expected decorators by stage
| Feature stage | Expected annotation |
| --- | --- |
| Experimental | `@experimental(feature_id=ExperimentalFeature.X)` |
| Release candidate | `@release_candidate(feature_id=ReleaseCandidateFeature.X)` |
| Released | No feature-stage decorator |
| Deprecated | `@deprecated("...")` |
## Feature enums
The feature enums are the inventory of currently staged features:
- `ExperimentalFeature`
- `ReleaseCandidateFeature`
Guidance:
- Use one enum member per conceptual feature, not per class
- Ideally, an ADR already defines the overall feature boundary and therefore the feature ID that staged APIs for that feature should reuse
- Keep feature IDs all caps
- Reuse the same member across related APIs for the same feature
- Remove enum members when the feature no longer belongs to that stage
- Treat these enums as **current-stage inventories**, not as a stable consumer introspection API
Minimal consumer guidance:
- Treat `__feature_stage__` and `__feature_id__` as optional staged metadata, not as stable contracts
- Use `getattr(obj, "__feature_stage__", None)` and `getattr(obj, "__feature_id__", None)` rather than direct attribute access
- Treat missing metadata as "no explicit feature-stage annotation"
- For warning filters while a feature is staged, match the literal feature ID string
- Do **not** rely on `ExperimentalFeature.X`, `ReleaseCandidateFeature.X`, or the continued presence of `__feature_id__` after a feature moves stages or is released
For consumers, the enums are also re-exported from `agent_framework`.
For internal implementation code inside `agent_framework`, continue to import the enums and decorators from `._feature_stage`.
## Package stage vs feature stage
Use the following rules:
### Package is experimental / beta
- All public APIs are experimental by default
- Do **not** add feature-stage decorators just to restate that
- Only introduce feature-level annotations later if the package advances first
### Package is release candidate
- All public APIs are RC by default
- Do **not** decorate everything
- Add `@experimental(...)` only for features that are intentionally still behind the package
### Package is released / GA
- All public APIs are released by default
- Add `@experimental(...)` or `@release_candidate(...)` only for features still being held back
## Moving a feature from one stage to the next
### Experimental -> Release candidate
1. Move the feature ID from `ExperimentalFeature` to `ReleaseCandidateFeature`
2. Replace `@experimental(...)` with `@release_candidate(...)`
3. Update any tests or docs that mention the old stage
### Experimental -> Released
1. Remove `@experimental(...)`
2. Remove the feature from `ExperimentalFeature`
3. Do not add a replacement feature-stage decorator
### Release candidate -> Released
1. Remove `@release_candidate(...)`
2. Remove the feature from `ReleaseCandidateFeature`
3. Leave the API undecorated
### Any stage -> Deprecated
1. Remove any feature-stage decorator
2. Remove the feature from the stage enum
3. Add `@deprecated("...")`
4. Update docs/tests to reflect the replacement path
## Promotion guidance
Features do **not** have to pass through every stage.
- It is usually a good idea to move features in order when that reflects reality
- But it is completely acceptable to go **experimental -> released**
- Do **not** force a feature through release candidate if there is no real RC period
Likewise, when a package advances, do not automatically move every feature with it.
- Promote features based on actual readiness
- Keep lagging features explicitly marked only when they are behind the package default
## Practical rules of thumb
- **Package default first, feature exceptions second**
- **Do not decorate everything in preview packages**
- **Do not double-annotate members of an already-staged class**
- **Use enums only for currently staged features**
- **Do not treat stage enums as a compatibility contract**
- **Treat `__feature_stage__` and `__feature_id__` as optional metadata; use `getattr`**
- **Remove stage annotations once a feature is released or deprecated**
+9 -1
View File
@@ -134,7 +134,7 @@ Recommended dependency workflow during connector implementation:
pip install agent-framework-core # Core only
pip install agent-framework-core[all] # Core + all connectors
pip install agent-framework # Same as core[all]
pip install agent-framework-azure-ai # Specific connector (pulls in core)
pip install agent-framework-foundry # Specific connector (pulls in core)
```
## Maintaining Documentation
@@ -143,3 +143,11 @@ When changing a package, check if its `AGENTS.md` needs updates:
- Adding/removing/renaming public classes or functions
- Changing the package's purpose or architecture
- Modifying import paths or usage patterns
When a package adds, removes, or renames environment variables, update the related documentation in the same
change:
- The package's `README.md` for package-level configuration/env var guidance
- `samples/README.md` if the package is included in `packages/core/pyproject.toml` `[all]` and the env var is
part of the consolidated package env-var inventory
- Any affected sample/package-local `.env.example`, `.env.template`, or sample README files when sample setup
changes alongside the package
+1
View File
@@ -11,6 +11,7 @@ Instructions for AI coding agents working in the Python codebase.
- `python-development` — coding standards, type annotations, docstrings, logging, performance
- `python-testing` — test structure, fixtures, async mode, running tests
- `python-code-quality` — linting, formatting, type checking, prek hooks, CI workflow
- `python-feature-lifecycle` — package vs feature lifecycle stages, decorators, enums, and promotion guidance
- `python-package-management` — monorepo structure, lazy loading, versioning, new packages
- `python-samples` — sample file structure, PEP 723, documentation guidelines
+31 -1
View File
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0rc6] - 2026-03-30
### Added
- **agent-framework-openai**: New package extracted from core for OpenAI and Azure OpenAI provider support ([#4818](https://github.com/microsoft/agent-framework/pull/4818))
- **agent-framework-foundry**: New package for Azure AI Foundry integration ([#4818](https://github.com/microsoft/agent-framework/pull/4818))
- **agent-framework-core**: Support `structuredContent` in MCP tool results and fix sampling options type ([#4763](https://github.com/microsoft/agent-framework/pull/4763))
- **agent-framework-core**: Include reasoning messages in `MESSAGES_SNAPSHOT` events ([#4844](https://github.com/microsoft/agent-framework/pull/4844))
- **agent-framework-core**: [BREAKING] Add context mode to `AgentExecutor` ([#4668](https://github.com/microsoft/agent-framework/pull/4668))
### Changed
- **agent-framework-core**: [BREAKING] Remove deprecated kwargs compatibility paths ([#4858](https://github.com/microsoft/agent-framework/pull/4858))
- **agent-framework-core**: [BREAKING] Reduce core dependencies and simplify optional integrations ([#4904](https://github.com/microsoft/agent-framework/pull/4904))
- **agent-framework-openai**: [BREAKING] Provider-leading client design & OpenAI package extraction ([#4818](https://github.com/microsoft/agent-framework/pull/4818))
- **agent-framework-openai**: [BREAKING] Fix OpenAI Azure routing and provider samples ([#4925](https://github.com/microsoft/agent-framework/pull/4925))
- **agent-framework-azure-ai**: Deprecate Azure AI v1 (Persistent Agents API) helper methods ([#4804](https://github.com/microsoft/agent-framework/pull/4804))
- **agent-framework-core**: Avoid duplicate agent response telemetry ([#4685](https://github.com/microsoft/agent-framework/pull/4685))
- **agent-framework-devui**: Bump `flatted` from 3.3.3 to 3.4.2 in frontend ([#4805](https://github.com/microsoft/agent-framework/pull/4805))
- **samples**: Move `ag_ui_workflow_handoff` demo from `demos/` to `05-end-to-end/` ([#4900](https://github.com/microsoft/agent-framework/pull/4900))
### Fixed
- **agent-framework-core**: Fix streaming path to emit `mcp_server_tool_result` on `output_item.done` instead of `output_item.added` ([#4821](https://github.com/microsoft/agent-framework/pull/4821))
- **agent-framework-a2a**: Fix `A2AAgent` to surface message content from in-progress `TaskStatusUpdateEvents` ([#4798](https://github.com/microsoft/agent-framework/pull/4798))
- **agent-framework-core**: Fix `PydanticSchemaGenerationError` when using `from __future__ import annotations` with `@tool` ([#4822](https://github.com/microsoft/agent-framework/pull/4822))
- **samples**: Fix broken samples for GitHub Copilot, declarative, and Responses API ([#4915](https://github.com/microsoft/agent-framework/pull/4915))
- **repo**: Fix: update PyRIT repository link from Azure/PyRIT to microsoft/PyRIT ([#4960](https://github.com/microsoft/agent-framework/pull/4960))
## [1.0.0rc5] - 2026-03-19
### Added
@@ -817,7 +846,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc5...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...HEAD
[1.0.0rc6]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc5...python-1.0.0rc6
[1.0.0rc5]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...python-1.0.0rc5
[1.0.0rc4]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc3...python-1.0.0rc4
[1.0.0rc3]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc2...python-1.0.0rc3
+5 -1
View File
@@ -192,7 +192,7 @@ The package follows a flat import structure:
- **Connectors**: Import from `agent_framework.<vendor/platform>`
```python
from agent_framework.openai import OpenAIChatClient
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.foundry import FoundryChatClient
```
## Exception Hierarchy
@@ -429,6 +429,10 @@ Each file should have a single first line containing: # Copyright (c) Microsoft.
We follow the [Google Docstring](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#383-functions-and-methods) style guide for functions and methods.
They are currently not checked for private functions (functions starting with '_').
When a change adds, removes, or renames a sample-facing environment variable in repo-level samples or
package-local sample docs for a package included by `agent-framework-core[all]`, update the consolidated
inventory in `samples/README.md` in the same change.
They should contain:
- Single line explaining what the function does, ending with a period.
+2 -2
View File
@@ -58,7 +58,7 @@ You can then run the following commands manually:
# Install Python 3.10, 3.11, 3.12, and 3.13
uv python install 3.10 3.11 3.12 3.13
# Create a virtual environment with Python 3.10 (you can change this to 3.11, 3.12 or 3.13)
$PYTHON_VERSION = "3.10"
PYTHON_VERSION="3.10"
uv venv --python $PYTHON_VERSION
# Install AF and all dependencies
uv sync --dev
@@ -180,7 +180,7 @@ This will show you which files are not covered by the tests, including the speci
## Catching up with the latest changes
There are many people committing to Semantic Kernel, so it is important to keep your local repository up to date. To do this, you can run the following commands:
There are many people committing to Agent Framework, so it is important to keep your local repository up to date. To do this, you can run the following commands:
```bash
git fetch upstream main
+1 -1
View File
@@ -51,7 +51,7 @@ OPENAI_MODEL=...
...
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_ENDPOINT=...
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=...
AZURE_OPENAI_DEPLOYMENT_NAME=...
...
FOUNDRY_PROJECT_ENDPOINT=...
FOUNDRY_MODEL=...
+2 -2
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260319"
version = "1.0.0b260330"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc5",
"agent-framework-core>=1.0.0rc6",
"a2a-sdk>=0.3.5,<0.3.24",
]
+4 -4
View File
@@ -15,16 +15,16 @@ pip install agent-framework-ag-ui
```python
from fastapi import FastAPI
from agent_framework import Agent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
# Create your agent
agent = Agent(
name="my_agent",
instructions="You are a helpful assistant.",
client=AzureOpenAIChatClient(
endpoint="https://your-resource.openai.azure.com/",
deployment_name="gpt-4o-mini",
client=OpenAIChatCompletionClient(
azure_endpoint="https://your-resource.openai.azure.com/",
model="gpt-4o-mini",
api_key="your-api-key",
),
)
@@ -16,7 +16,7 @@ All example agents are factory functions that accept any `SupportsChatGetRespons
```python
from fastapi import FastAPI
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.openai import OpenAIChatClient
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework_ag_ui_examples.agents import simple_agent, weather_agent
@@ -24,11 +24,11 @@ from agent_framework_ag_ui_examples.agents import simple_agent, weather_agent
app = FastAPI()
# Option 1: Use Azure OpenAI
azure_client = AzureOpenAIChatClient(model_id="gpt-4")
azure_client = OpenAIChatCompletionClient(model="gpt-4")
add_agent_framework_fastapi_endpoint(app, simple_agent(azure_client), "/chat")
# Option 2: Use OpenAI
openai_client = OpenAIChatClient(model_id="gpt-4o")
openai_client = OpenAIChatClient(model="gpt-4o")
add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weather")
# Run with: uvicorn main:app --reload
@@ -39,14 +39,14 @@ add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weathe
```python
from fastapi import FastAPI
from agent_framework import Agent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
# Create your agent
agent = Agent(
name="my_agent",
instructions="You are a helpful assistant.",
client=AzureOpenAIChatClient(model_id="gpt-4o"),
client=OpenAIChatCompletionClient(model="gpt-4o"),
)
# Create FastAPI app and add AG-UI endpoint
@@ -90,7 +90,7 @@ Complete examples for all AG-UI features are available:
### Using Example Agents
```python
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.openai import OpenAIChatClient
from agent_framework_ag_ui_examples.agents import (
simple_agent,
@@ -99,8 +99,8 @@ from agent_framework_ag_ui_examples.agents import (
)
# Create a chat client (use any SupportsChatGetResponse implementation)
azure_client = AzureOpenAIChatClient(model_id="gpt-4")
openai_client = OpenAIChatClient(model_id="gpt-4o")
azure_client = OpenAIChatCompletionClient(model="gpt-4")
openai_client = OpenAIChatClient(model="gpt-4o")
# Create agent instances by calling the factory functions
agent1 = simple_agent(azure_client)
@@ -137,7 +137,7 @@ The server exposes endpoints at:
```python
from fastapi import FastAPI
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework_ag_ui_examples.agents import (
simple_agent,
@@ -153,7 +153,7 @@ from agent_framework_ag_ui_examples.agents import (
app = FastAPI(title="AG-UI Examples")
# Create a chat client (shared across all agents, or create individual ones)
client = AzureOpenAIChatClient(model_id="gpt-4")
client = OpenAIChatCompletionClient(model="gpt-4")
# Add all example endpoints
add_agent_framework_fastapi_endpoint(app, simple_agent(client), "/agentic_chat")
@@ -223,8 +223,8 @@ def my_custom_agent(client: SupportsChatGetResponse) -> AgentFrameworkAgent:
)
# Use it
from agent_framework.azure import AzureOpenAIChatClient
client = AzureOpenAIChatClient()
from agent_framework.openai import OpenAIChatCompletionClient
client = OpenAIChatCompletionClient()
agent = my_custom_agent(client)
```
@@ -234,13 +234,13 @@ State is injected as system messages and updated via predictive state updates:
```python
from agent_framework import Agent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.ag_ui import AgentFrameworkAgent
# Create your agent
agent = Agent(
name="recipe_agent",
client=AzureOpenAIChatClient(model_id="gpt-4o"),
client=OpenAIChatCompletionClient(model="gpt-4o"),
)
state_schema = {
@@ -271,13 +271,13 @@ Predictive state updates automatically stream tool arguments as optimistic state
```python
from agent_framework import Agent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.ag_ui import AgentFrameworkAgent
# Create your agent
agent = Agent(
name="document_writer",
client=AzureOpenAIChatClient(model_id="gpt-4o"),
client=OpenAIChatCompletionClient(model="gpt-4o"),
)
predict_state_config = {
@@ -6,7 +6,7 @@ from typing import Any, cast
from agent_framework._clients import SupportsChatGetResponse
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatCompletionClient
from fastapi import FastAPI
from ...agents.weather_agent import weather_agent
@@ -19,7 +19,7 @@ def register_backend_tool_rendering(app: FastAPI) -> None:
app: The FastAPI application.
"""
# Create a chat client and call the factory function
client = cast(SupportsChatGetResponse[Any], AzureOpenAIChatClient())
client = cast(SupportsChatGetResponse[Any], OpenAIChatCompletionClient())
add_agent_framework_fastapi_endpoint(
app,
@@ -12,7 +12,7 @@ import uvicorn
from agent_framework import ChatOptions
from agent_framework._clients import SupportsChatGetResponse
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatCompletionClient
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -80,7 +80,7 @@ client: SupportsChatGetResponse[ChatOptions] = cast(
SupportsChatGetResponse[ChatOptions],
AnthropicClient()
if AnthropicClient is not None and os.getenv("CHAT_CLIENT", "").lower() == "anthropic"
else AzureOpenAIChatClient(),
else OpenAIChatCompletionClient(),
)
# Agentic Chat - basic chat agent
@@ -185,7 +185,7 @@ Create a file named `server.py`:
import os
from agent_framework import Agent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from fastapi import FastAPI
@@ -205,9 +205,9 @@ if not api_key:
agent = Agent(
name="AGUIAssistant",
instructions="You are a helpful assistant.",
client=AzureOpenAIChatClient(
endpoint=endpoint,
deployment_name=deployment_name,
client=OpenAIChatCompletionClient(
azure_endpoint=endpoint,
model=deployment_name,
api_key=api_key,
),
)
@@ -230,7 +230,7 @@ if __name__ == "__main__":
- **`Agent`**: The agent that will handle incoming requests
- **FastAPI Integration**: Uses FastAPI's native async support for streaming responses
- **Instructions**: The agent is created with default instructions, which can be overridden by client messages
- **Configuration**: `AzureOpenAIChatClient` can read from environment variables (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_KEY`) or accept parameters directly
- **Configuration**: `OpenAIChatCompletionClient` can read from environment variables (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_KEY`) or accept parameters directly
**Alternative (simpler)**: Use environment variables only:
@@ -239,7 +239,7 @@ if __name__ == "__main__":
agent = Agent(
name="AGUIAssistant",
instructions="You are a helpful assistant.",
client=AzureOpenAIChatClient(), # Reads from environment automatically
client=OpenAIChatCompletionClient(), # Reads from environment automatically
)
```
@@ -249,7 +249,7 @@ Set the required environment variables:
```bash
export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="gpt-4o-mini"
export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
# Optional: Set API key if not using DefaultAzureCredential
# export AZURE_OPENAI_API_KEY="your-api-key"
```
@@ -9,7 +9,7 @@ import os
from agent_framework import Agent, tool
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatCompletionClient
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException, Security
from fastapi.security import APIKeyHeader
@@ -26,12 +26,12 @@ logger = logging.getLogger(__name__)
# Read required configuration
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
deployment_name = os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME")
deployment_name = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME")
if not endpoint:
raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required")
if not deployment_name:
raise ValueError("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME environment variable is required")
raise ValueError("AZURE_OPENAI_DEPLOYMENT_NAME environment variable is required")
# ============================================================================
@@ -119,9 +119,9 @@ def get_time_zone(location: str) -> str:
agent = Agent(
name="AGUIAssistant",
instructions="You are a helpful assistant. Use get_weather for weather and get_time_zone for time zones.",
client=AzureOpenAIChatClient(
endpoint=endpoint,
deployment_name=deployment_name,
client=OpenAIChatCompletionClient(
azure_endpoint=endpoint,
model=deployment_name,
),
tools=[get_time_zone], # ONLY server-side tools
)
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260319"
version = "1.0.0b260330"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc5",
"agent-framework-core>=1.0.0rc6",
"ag-ui-protocol==0.1.13",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<0.42.0"
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260319"
version = "1.0.0b260330"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc5",
"agent-framework-core>=1.0.0rc6",
"anthropic>=0.80.0,<0.80.1",
]
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260319"
version = "1.0.0b260330"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc5",
"agent-framework-core>=1.0.0rc6",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
+13 -15
View File
@@ -1,32 +1,30 @@
# Azure AI Package (agent-framework-azure-ai)
Integration with Azure AI Foundry for persistent agents and project-based agent management.
Integration with Azure AI inference embeddings plus shared Azure authentication helpers.
## Main Classes
- **`AzureAIAgentClient`** - Chat client for Azure AI Agents (persistent agents with threads)
- **`AzureAIClient`** - Client for Azure AI Foundry project-based agents
- **`AzureAIAgentsProvider`** - Provider for listing/managing Azure AI agents
- **`AzureAIProjectAgentProvider`** - Provider for project-scoped agent management
- **`AzureAISettings`** - Pydantic settings for Azure AI configuration
- **`AzureAIAgentOptions`** / **`AzureAIProjectAgentOptions`** - Options TypedDicts
- **`AzureAIInferenceEmbeddingClient`** - Full-featured Azure AI inference embeddings client
- **`RawAzureAIInferenceEmbeddingClient`** - Raw embeddings client without middleware layers
- **`AzureAIInferenceEmbeddingOptions`** / **`AzureAIInferenceEmbeddingSettings`** - Embedding options and settings
- **`AzureAISettings`** - Shared Azure AI project settings TypedDict
- **`AzureCredentialTypes`** / **`AzureTokenProvider`** - Shared Azure authentication helpers
## Usage
```python
from agent_framework.azure import AzureAIAgentClient
from agent_framework_azure_ai import AzureAIInferenceEmbeddingClient
client = AzureAIAgentClient(
endpoint="https://your-project.services.ai.azure.com",
agent_id="your-agent-id",
client = AzureAIInferenceEmbeddingClient(
endpoint="https://<resource>.inference.ai.azure.com",
api_key="...",
model_id="text-embedding-3-large",
)
response = await client.get_response("Hello")
result = await client.get_embeddings(["Hello"])
```
## Import Path
```python
from agent_framework.azure import AzureAIAgentClient, AzureAIClient
# or directly:
from agent_framework_azure_ai import AzureAIAgentClient
from agent_framework_azure_ai import AzureAIInferenceEmbeddingClient
```
@@ -2,21 +2,6 @@
import importlib.metadata
from ._agent_provider import AzureAIAgentsProvider # pyright: ignore[reportDeprecated]
from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions # pyright: ignore[reportDeprecated]
from ._client import AzureAIClient, AzureAIProjectAgentOptions, RawAzureAIClient # pyright: ignore[reportDeprecated]
from ._deprecated_azure_openai import (
AzureOpenAIAssistantsClient, # pyright: ignore[reportDeprecated]
AzureOpenAIAssistantsOptions,
AzureOpenAIChatClient, # pyright: ignore[reportDeprecated]
AzureOpenAIChatOptions,
AzureOpenAIConfigMixin,
AzureOpenAIEmbeddingClient, # pyright: ignore[reportDeprecated]
AzureOpenAIResponsesClient, # pyright: ignore[reportDeprecated]
AzureOpenAIResponsesOptions,
AzureOpenAISettings,
AzureUserSecurityContext,
)
from ._embedding_client import (
AzureAIInferenceEmbeddingClient,
AzureAIInferenceEmbeddingOptions,
@@ -24,7 +9,6 @@ from ._embedding_client import (
RawAzureAIInferenceEmbeddingClient,
)
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
from ._project_provider import AzureAIProjectAgentProvider # pyright: ignore[reportDeprecated]
from ._shared import AzureAISettings
try:
@@ -33,29 +17,12 @@ except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"AzureAIAgentClient",
"AzureAIAgentOptions",
"AzureAIAgentsProvider",
"AzureAIClient",
"AzureAIInferenceEmbeddingClient",
"AzureAIInferenceEmbeddingOptions",
"AzureAIInferenceEmbeddingSettings",
"AzureAIProjectAgentOptions",
"AzureAIProjectAgentProvider",
"AzureAISettings",
"AzureCredentialTypes",
"AzureOpenAIAssistantsClient",
"AzureOpenAIAssistantsOptions",
"AzureOpenAIChatClient",
"AzureOpenAIChatOptions",
"AzureOpenAIConfigMixin",
"AzureOpenAIEmbeddingClient",
"AzureOpenAIResponsesClient",
"AzureOpenAIResponsesOptions",
"AzureOpenAISettings",
"AzureTokenProvider",
"AzureUserSecurityContext",
"RawAzureAIClient",
"RawAzureAIInferenceEmbeddingClient",
"__version__",
]
@@ -1,558 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import sys
import warnings
from collections.abc import Callable, Sequence
from typing import Any, Generic, cast
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
Agent,
BaseContextProvider,
FunctionTool,
MiddlewareTypes,
normalize_tools,
)
from agent_framework._mcp import MCPTool
from agent_framework._settings import load_settings
from agent_framework._tools import ToolTypes
from azure.ai.agents.aio import AgentsClient
from azure.ai.agents.models import Agent as AzureAgent
from azure.ai.agents.models import ResponseFormatJsonSchema, ResponseFormatJsonSchemaType
from pydantic import BaseModel
from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions # pyright: ignore[reportDeprecated]
from ._entra_id_authentication import AzureCredentialTypes
from ._shared import AzureAISettings, to_azure_ai_agent_tools
if sys.version_info >= (3, 13):
from typing import Self, TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import Self, TypeVar # type: ignore # pragma: no cover
if sys.version_info >= (3, 13):
from warnings import deprecated # type: ignore # pragma: no cover
else:
from typing_extensions import deprecated # type: ignore # pragma: no cover
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
# Type variable for options - allows typed Agent[TOptions] returns
# Default matches AzureAIAgentClient's default options type
OptionsCoT = TypeVar(
"OptionsCoT",
bound=TypedDict, # type: ignore[valid-type]
default="AzureAIAgentOptions",
covariant=True,
)
@deprecated(
"AzureAIAgentClient and the AzureAIAgentsProvider are deprecated. "
"They target the V1 Agents Service API and have no direct replacement; "
"for new Foundry projects, use FoundryAgent."
)
class AzureAIAgentsProvider(Generic[OptionsCoT]):
"""Provider for Azure AI Agent Service V1 (Persistent Agents API).
.. deprecated::
AzureAIAgentsProvider is deprecated and will be removed in a future release.
Use :class:`AzureAIProjectAgentProvider` instead for the V2 (Projects/Responses) API.
This provider enables creating, retrieving, and wrapping Azure AI agents as Agent
instances. It manages the underlying AgentsClient lifecycle and provides a high-level
interface for agent operations.
The provider can be initialized with either:
- An existing AgentsClient instance
- Azure credentials and endpoint for automatic client creation
Examples:
Using credentials (auto-creates client):
.. code-block:: python
from agent_framework.azure import AzureAIAgentsProvider
from azure.identity.aio import AzureCliCredential
async with (
AzureCliCredential() as credential,
AzureAIAgentsProvider(credential=credential) as provider,
):
agent = await provider.create_agent(
name="MyAgent",
instructions="You are a helpful assistant.",
)
result = await agent.run("Hello!")
Using existing AgentsClient:
.. code-block:: python
from agent_framework.azure import AzureAIAgentsProvider
from azure.ai.agents.aio import AgentsClient
async with AgentsClient(endpoint=endpoint, credential=credential) as client:
provider = AzureAIAgentsProvider(agents_client=client)
agent = await provider.create_agent(name="MyAgent", instructions="...")
"""
def __init__(
self,
agents_client: AgentsClient | None = None,
*,
project_endpoint: str | None = None,
credential: AzureCredentialTypes | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize the Azure AI Agents Provider.
Args:
agents_client: An existing AgentsClient to use. If provided, the provider
will not manage its lifecycle.
Keyword Args:
project_endpoint: The Azure AI Project endpoint URL.
Can also be set via AZURE_AI_PROJECT_ENDPOINT environment variable.
credential: Azure credential for authentication. Accepts a TokenCredential,
AsyncTokenCredential, or a callable token provider.
Required if agents_client is not provided.
env_file_path: Path to .env file for loading settings.
env_file_encoding: Encoding of the .env file.
Raises:
ValueError: If required parameters are missing or invalid.
"""
warnings.warn(
"AzureAIAgentsProvider is deprecated and will be removed in a future release; "
"use AzureAIProjectAgentProvider instead for the V2 (Projects/Responses) API.",
DeprecationWarning,
stacklevel=2,
)
self._settings = load_settings(
AzureAISettings,
env_prefix="AZURE_AI_",
project_endpoint=project_endpoint,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
self._should_close_client = False
if agents_client is not None:
self._agents_client = agents_client
else:
resolved_endpoint = self._settings.get("project_endpoint")
if not resolved_endpoint:
raise ValueError(
"Azure AI project endpoint is required. Provide 'project_endpoint' parameter "
"or set 'AZURE_AI_PROJECT_ENDPOINT' environment variable."
)
if not credential:
raise ValueError("Azure credential is required when agents_client is not provided.")
self._agents_client = AgentsClient(
endpoint=resolved_endpoint,
credential=credential, # type: ignore[arg-type]
user_agent=AGENT_FRAMEWORK_USER_AGENT,
)
self._should_close_client = True
async def __aenter__(self) -> Self:
"""Async context manager entry."""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
"""Async context manager exit."""
await self.close()
async def close(self) -> None:
"""Close the provider and release resources.
Only closes the AgentsClient if it was created by this provider.
"""
if self._should_close_client:
await self._agents_client.close()
async def create_agent(
self,
name: str,
*,
model: str | None = None,
instructions: str | None = None,
description: str | None = None,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Create a new agent on the Azure AI service and return a Agent.
.. deprecated::
This method is deprecated and will be removed in a future release.
Use :meth:`AzureAIProjectAgentProvider.create_agent` instead.
This method creates a persistent agent on the Azure AI service with the specified
configuration and returns a local Agent instance for interaction.
Args:
name: The name for the agent.
Keyword Args:
model: The model deployment name to use. Falls back to
AZURE_AI_MODEL_DEPLOYMENT_NAME environment variable if not provided.
instructions: Instructions for the agent's behavior.
description: A description of the agent's purpose.
tools: Tools to make available to the agent.
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_providers: Context providers to include during agent invocation.
Returns:
Agent: A Agent instance configured with the created agent.
Raises:
ValueError: If model deployment name is not available.
Examples:
.. code-block:: python
agent = await provider.create_agent(
name="WeatherAgent",
instructions="You are a helpful weather assistant.",
tools=get_weather,
)
"""
warnings.warn(
"AzureAIAgentsProvider.create_agent() is deprecated and will be removed in a future release; "
"use AzureAIProjectAgentProvider.create_agent() instead.",
DeprecationWarning,
stacklevel=2,
)
resolved_model = model or self._settings.get("model_deployment_name")
if not resolved_model:
raise ValueError(
"Model deployment name is required. Provide 'model' parameter "
"or set 'AZURE_AI_MODEL_DEPLOYMENT_NAME' environment variable."
)
# Extract response_format from default_options if present
opts = dict(default_options) if default_options else {}
response_format = opts.get("response_format")
args: dict[str, Any] = {
"model": resolved_model,
"name": name,
}
if description:
args["description"] = description
if instructions:
args["instructions"] = instructions
# Handle response format
if response_format and isinstance(response_format, type) and issubclass(response_format, BaseModel):
args["response_format"] = self._create_response_format_config(response_format)
# Normalize and convert tools
# Local MCP tools (MCPTool) are handled by Agent at runtime, not stored on the Azure agent
normalized_tools = normalize_tools(tools)
if normalized_tools:
# Collect all non-MCP tools for Azure AI agent creation.
# to_azure_ai_agent_tools handles FunctionTool, SDK Tool types (FileSearchTool, etc.), and dicts.
non_mcp_tools: list[Any] = [t for t in normalized_tools if not isinstance(t, MCPTool)]
if non_mcp_tools:
# Pass run_options to capture tool_resources (e.g., for file search vector stores)
run_options: dict[str, Any] = {}
args["tools"] = to_azure_ai_agent_tools(non_mcp_tools, run_options)
if "tool_resources" in run_options:
args["tool_resources"] = run_options["tool_resources"]
# Create the agent on the service
created_agent = await self._agents_client.create_agent(**args)
# Create Agent wrapper
return self._to_chat_agent_from_agent(
created_agent,
normalized_tools,
default_options=default_options,
middleware=middleware,
context_providers=context_providers,
)
async def get_agent(
self,
id: str,
*,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Retrieve an existing agent from the service and return a Agent.
.. deprecated::
This method is deprecated and will be removed in a future release.
Use :meth:`AzureAIProjectAgentProvider.get_agent` instead.
This method fetches an agent by ID from the Azure AI service
and returns a local Agent instance for interaction.
Args:
id: The ID of the agent to retrieve from the service.
Keyword Args:
tools: Tools to make available to the agent. Required if the agent
has function tools that need implementations.
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_providers: Context providers to include during agent invocation.
Returns:
Agent: A Agent instance configured with the retrieved agent.
Raises:
ValueError: If required function tools are not provided.
Examples:
.. code-block:: python
agent = await provider.get_agent("agent-123")
# With function tools
agent = await provider.get_agent("agent-123", tools=my_function)
"""
warnings.warn(
"AzureAIAgentsProvider.get_agent() is deprecated and will be removed in a future release; "
"use AzureAIProjectAgentProvider.get_agent() instead.",
DeprecationWarning,
stacklevel=2,
)
agent = await self._agents_client.get_agent(id)
# Validate function tools
normalized_tools = normalize_tools(tools)
self._validate_function_tools(agent.tools, normalized_tools)
return self._to_chat_agent_from_agent(
agent,
normalized_tools,
default_options=default_options,
middleware=middleware,
context_providers=context_providers,
)
def as_agent(
self,
agent: AzureAgent,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Wrap an existing Agent SDK object as a Agent without making HTTP calls.
.. deprecated::
This method is deprecated and will be removed in a future release.
Use :meth:`AzureAIProjectAgentProvider.as_agent` instead.
Use this method when you already have an Agent object from a previous
SDK operation and want to use it with the Agent Framework.
Args:
agent: The Agent object to wrap.
tools: Tools to make available to the agent. Required if the agent
has function tools that need implementations.
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_providers: Context providers to include during agent invocation.
Returns:
Agent: A Agent instance configured with the agent.
Raises:
ValueError: If required function tools are not provided.
Examples:
.. code-block:: python
# Create agent directly with SDK
sdk_agent = await agents_client.create_agent(
model="gpt-4",
name="MyAgent",
instructions="...",
)
# Wrap as Agent
chat_agent = provider.as_agent(sdk_agent)
"""
warnings.warn(
"AzureAIAgentsProvider.as_agent() is deprecated and will be removed in a future release; "
"use AzureAIProjectAgentProvider.as_agent() instead.",
DeprecationWarning,
stacklevel=2,
)
# Validate function tools
normalized_tools = normalize_tools(tools)
self._validate_function_tools(agent.tools, normalized_tools)
return self._to_chat_agent_from_agent(
agent,
normalized_tools,
default_options=default_options,
middleware=middleware,
context_providers=context_providers,
)
def _to_chat_agent_from_agent(
self,
agent: AzureAgent,
provided_tools: Sequence[ToolTypes] | None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Create a Agent from an Agent SDK object.
Args:
agent: The Agent SDK object.
provided_tools: User-provided tools (including function implementations).
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_providers: Context providers to include during agent invocation.
"""
# Create the underlying client
client = AzureAIAgentClient( # pyright: ignore[reportDeprecated]
agents_client=self._agents_client,
agent_id=agent.id,
agent_name=agent.name,
agent_description=agent.description,
should_cleanup_agent=False, # Provider manages agent lifecycle
)
# Merge tools: convert agent's hosted tools + user-provided function tools
merged_tools = self._merge_tools(agent.tools, provided_tools)
merged_default_options: dict[str, Any] = dict(default_options) if default_options is not None else {}
merged_default_options.setdefault("model_id", agent.model)
return Agent( # type: ignore[return-value]
client=client,
id=agent.id,
name=agent.name,
description=agent.description,
instructions=agent.instructions,
tools=merged_tools,
default_options=cast(Any, merged_default_options),
middleware=middleware,
context_providers=context_providers,
)
def _merge_tools(
self,
agent_tools: Sequence[Any] | None,
provided_tools: Sequence[ToolTypes] | None,
) -> list[ToolTypes]:
"""Merge hosted tools from agent with user-provided function tools.
Args:
agent_tools: Tools from the agent definition (Azure AI format).
provided_tools: User-provided tools (Agent Framework format).
Returns:
Combined list of tools for the Agent.
"""
merged: list[ToolTypes] = []
# Hosted tools (file_search, code_interpreter, bing_grounding, openapi, etc.)
# are already defined on the server agent and will be read back by the client
# at run time via agent_definition.tools. We skip them here to avoid sending
# them again at request time (which causes API errors like unknown vector_store_ids).
# Add user-provided function tools and MCP tools
if provided_tools:
for provided_tool in provided_tools:
# FunctionTool - has implementation for function calling
# MCPTool - Agent handles MCP connection and tool discovery at runtime
if isinstance(provided_tool, (FunctionTool, MCPTool)):
merged.append(provided_tool) # type: ignore[reportUnknownArgumentType]
return merged
def _validate_function_tools(
self,
agent_tools: Sequence[Any] | None,
provided_tools: Sequence[ToolTypes] | None,
) -> None:
"""Validate that required function tools are provided.
Raises:
ValueError: If agent has function tools but user
didn't provide implementations.
"""
if not agent_tools:
return
# Get function tool names from agent definition
function_tool_names: set[str] = set()
for tool in agent_tools:
if isinstance(tool, dict):
tool_dict = cast(dict[str, Any], tool)
if tool_dict.get("type") == "function":
func_def = cast(dict[str, Any], tool_dict.get("function", {}))
name = func_def.get("name")
if isinstance(name, str):
function_tool_names.add(name)
elif hasattr(tool, "type") and tool.type == "function":
func_attr = getattr(tool, "function", None)
if func_attr and hasattr(func_attr, "name"):
function_tool_names.add(str(func_attr.name))
if not function_tool_names:
return
# Get provided function names
provided_names: set[str] = set()
if provided_tools:
for tool in provided_tools:
if isinstance(tool, FunctionTool):
provided_names.add(tool.name)
# Check for missing implementations
missing = function_tool_names - provided_names
if missing:
raise ValueError(
f"Agent has function tools that require implementations: {missing}. "
"Provide these functions via the 'tools' parameter."
)
def _create_response_format_config(
self,
response_format: type[BaseModel],
) -> ResponseFormatJsonSchemaType:
"""Create response format configuration for Azure AI.
Args:
response_format: Pydantic model for structured output.
Returns:
Azure AI response format configuration.
"""
return ResponseFormatJsonSchemaType(
json_schema=ResponseFormatJsonSchema(
name=response_format.__name__,
schema=response_format.model_json_schema(),
)
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,918 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Deprecated Azure OpenAI client classes.
All classes in this module are deprecated and will be removed in a future release.
Migrate to the ``agent_framework_openai`` package equivalents with an ``AsyncAzureOpenAI`` client,
or use ``FoundryChatClient`` for Azure AI Foundry projects.
"""
from __future__ import annotations
import json
import logging
import sys
from collections.abc import Mapping, Sequence
from contextlib import contextmanager
from copy import copy
from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, cast
from urllib.parse import urljoin, urlparse
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT, APP_INFO, prepend_agent_framework_to_user_agent
from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer
from agent_framework._types import Annotation, Content
from agent_framework.observability import ChatTelemetryLayer, EmbeddingTelemetryLayer
from agent_framework_openai._assistants_client import (
OpenAIAssistantsClient, # type: ignore[reportDeprecated]
OpenAIAssistantsOptions,
)
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
from agent_framework_openai._chat_completion_client import OpenAIChatCompletionOptions, RawOpenAIChatCompletionClient
from agent_framework_openai._embedding_client import OpenAIEmbeddingOptions, RawOpenAIEmbeddingClient
from agent_framework_openai._shared import OpenAIBase
from azure.ai.projects.aio import AIProjectClient
from openai import AsyncOpenAI
from openai.lib.azure import AsyncAzureOpenAI
from pydantic import BaseModel
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider, resolve_credential_to_token_provider
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
from warnings import deprecated # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore # pragma: no cover
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
if TYPE_CHECKING:
from agent_framework._middleware import MiddlewareTypes
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
logger: logging.Logger = logging.getLogger(__name__)
# region Constants and Settings
DEFAULT_AZURE_API_VERSION: Final[str] = "2024-10-21"
DEFAULT_AZURE_TOKEN_ENDPOINT: Final[str] = "https://cognitiveservices.azure.com/.default" # noqa: S105
class AzureOpenAISettings(TypedDict, total=False):
"""AzureOpenAI model settings.
Settings are resolved in this order: explicit keyword arguments, values from an
explicitly provided .env file, then environment variables with the prefix
'AZURE_OPENAI_'. If settings are missing after resolution, validation will fail.
Keyword Args:
endpoint: The endpoint of the Azure deployment.
chat_deployment_name: The name of the Azure Chat deployment.
responses_deployment_name: The name of the Azure Responses deployment.
embedding_deployment_name: The name of the Azure Embedding deployment.
api_key: The API key for the Azure deployment.
api_version: The API version to use.
base_url: The url of the Azure deployment.
token_endpoint: The token endpoint to use to retrieve the authentication token.
"""
chat_deployment_name: str | None
responses_deployment_name: str | None
embedding_deployment_name: str | None
endpoint: str | None
base_url: str | None
api_key: SecretString | None
api_version: str | None
token_endpoint: str | None
def _apply_azure_defaults(
settings: AzureOpenAISettings,
default_api_version: str = DEFAULT_AZURE_API_VERSION,
default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT,
) -> None:
"""Apply default values for api_version and token_endpoint after loading settings.
Args:
settings: The loaded Azure OpenAI settings dict.
default_api_version: The default API version to use if not set.
default_token_endpoint: The default token endpoint to use if not set.
"""
if not settings.get("api_version"):
settings["api_version"] = default_api_version
if not settings.get("token_endpoint"):
settings["token_endpoint"] = default_token_endpoint
@contextmanager
def _prefer_single_azure_endpoint_env(*, endpoint: str | None, base_url: str | None) -> Any:
"""Preserve the legacy call shape without mutating process-wide environment state."""
yield
# endregion
# region AzureOpenAIConfigMixin
class AzureOpenAIConfigMixin(OpenAIBase):
"""Internal class for configuring a connection to an Azure OpenAI service."""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai"
def __init__(
self,
deployment_name: str,
endpoint: str | None = None,
base_url: str | None = None,
api_version: str = DEFAULT_AZURE_API_VERSION,
api_key: str | None = None,
token_endpoint: str | None = None,
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
default_headers: Mapping[str, str] | None = None,
client: AsyncOpenAI | None = None,
instruction_role: str | None = None,
**kwargs: Any,
) -> None:
"""Configure a connection to an Azure OpenAI service.
Args:
deployment_name: Name of the deployment.
endpoint: The specific endpoint URL for the deployment.
base_url: The base URL for Azure services.
api_version: Azure API version.
api_key: API key for Azure services.
token_endpoint: Azure AD token scope.
credential: Azure credential or token provider for authentication.
default_headers: Default headers for HTTP requests.
client: An existing client to use.
instruction_role: The role to use for 'instruction' messages.
kwargs: Additional keyword arguments.
"""
merged_headers = dict(copy(default_headers)) if default_headers else {}
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)
if not client:
ad_token_provider = None
if not api_key and credential:
ad_token_provider = resolve_credential_to_token_provider(credential, token_endpoint)
if not api_key and not ad_token_provider:
raise ValueError("Please provide either api_key, credential, or a client.")
if not endpoint and not base_url:
raise ValueError("Please provide an endpoint or a base_url")
args: dict[str, Any] = {
"default_headers": merged_headers,
}
if api_version:
args["api_version"] = api_version
if ad_token_provider:
args["azure_ad_token_provider"] = ad_token_provider
if api_key:
args["api_key"] = api_key
if base_url:
args["base_url"] = str(base_url)
if endpoint and not base_url:
args["azure_endpoint"] = str(endpoint)
if deployment_name:
args["azure_deployment"] = deployment_name
if "websocket_base_url" in kwargs:
args["websocket_base_url"] = kwargs.pop("websocket_base_url")
client = AsyncAzureOpenAI(**args)
self.endpoint = str(endpoint)
self.base_url = str(base_url)
self.api_version = api_version
self.deployment_name = deployment_name
self.instruction_role = instruction_role
if default_headers:
from agent_framework._telemetry import USER_AGENT_KEY
def_headers = {k: v for k, v in default_headers.items() if k != USER_AGENT_KEY}
else:
def_headers = None
self.default_headers = def_headers
super().__init__(model_id=deployment_name, client=client, **kwargs)
# endregion
# region AzureOpenAIResponsesClient
AzureOpenAIResponsesOptionsT = TypeVar(
"AzureOpenAIResponsesOptionsT",
bound=TypedDict, # type: ignore[valid-type]
default="OpenAIChatOptions",
covariant=True,
)
AzureOpenAIResponsesOptions = OpenAIChatOptions
@deprecated(
"AzureOpenAIResponsesClient is deprecated. "
"Use OpenAIChatClient with an AsyncAzureOpenAI client, or FoundryChatClient for Foundry projects."
)
class AzureOpenAIResponsesClient( # type: ignore[misc]
FunctionInvocationLayer[AzureOpenAIResponsesOptionsT],
ChatMiddlewareLayer[AzureOpenAIResponsesOptionsT],
ChatTelemetryLayer[AzureOpenAIResponsesOptionsT],
RawOpenAIChatClient[AzureOpenAIResponsesOptionsT],
Generic[AzureOpenAIResponsesOptionsT],
):
"""Deprecated Azure Responses client. Use OpenAIChatClient with an AsyncAzureOpenAI client instead."""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai"
def __init__(
self,
*,
api_key: str | None = None,
deployment_name: str | None = None,
endpoint: str | None = None,
base_url: str | None = None,
api_version: str | None = None,
token_endpoint: str | None = None,
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
default_headers: Mapping[str, str] | None = None,
async_client: AsyncOpenAI | None = None,
project_client: Any | None = None,
project_endpoint: str | None = None,
allow_preview: bool | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
instruction_role: str | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
**kwargs: Any,
) -> None:
"""Initialize an Azure OpenAI Responses client.
Keyword Args:
api_key: The API key.
deployment_name: The deployment name.
endpoint: The deployment endpoint.
base_url: The deployment base URL.
api_version: The deployment API version.
token_endpoint: The token endpoint to request an Azure token.
credential: Azure credential or token provider for authentication.
default_headers: Default headers for HTTP requests.
async_client: An existing client to use.
project_client: An existing AIProjectClient to use.
project_endpoint: The Azure AI Foundry project endpoint URL.
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
instruction_role: The role to use for 'instruction' messages.
middleware: Optional sequence of middleware.
function_invocation_configuration: Optional function invocation configuration.
kwargs: Additional keyword arguments.
"""
if (model_id := kwargs.pop("model_id", None)) and not deployment_name:
deployment_name = str(model_id)
if async_client is None and (project_client is not None or project_endpoint is not None):
async_client = self._create_client_from_project(
project_client=project_client,
project_endpoint=project_endpoint,
credential=credential,
allow_preview=allow_preview,
)
azure_openai_settings = load_settings(
AzureOpenAISettings,
env_prefix="AZURE_OPENAI_",
api_key=api_key,
base_url=base_url,
endpoint=endpoint,
responses_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_endpoint,
)
_apply_azure_defaults(azure_openai_settings, default_api_version="preview")
endpoint_value = azure_openai_settings.get("endpoint")
if (
not azure_openai_settings.get("base_url")
and endpoint_value
and (hostname := urlparse(str(endpoint_value)).hostname)
and hostname.endswith(".openai.azure.com")
):
azure_openai_settings["base_url"] = urljoin(str(endpoint_value), "/openai/v1/")
responses_deployment_name = azure_openai_settings.get("responses_deployment_name")
if not responses_deployment_name:
raise ValueError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable."
)
endpoint_value = azure_openai_settings.get("endpoint")
client_base_url = azure_openai_settings.get("base_url")
if not async_client:
# Create the Azure OpenAI client directly
merged_headers = dict(copy(default_headers)) if default_headers else {}
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)
api_key_secret = azure_openai_settings.get("api_key")
ad_token_provider = None
if not api_key_secret and credential:
ad_token_provider = resolve_credential_to_token_provider(
credential, azure_openai_settings.get("token_endpoint")
)
if not api_key_secret and not ad_token_provider:
raise ValueError("Please provide either api_key, credential, or a client.")
if not endpoint_value and not client_base_url:
raise ValueError("Please provide an endpoint or a base_url")
client_args: dict[str, Any] = {"default_headers": merged_headers}
if resolved_api_version := azure_openai_settings.get("api_version"):
client_args["api_version"] = resolved_api_version
if ad_token_provider:
client_args["azure_ad_token_provider"] = ad_token_provider
if api_key_secret:
client_args["api_key"] = api_key_secret.get_secret_value()
if client_base_url:
client_args["base_url"] = str(client_base_url)
if endpoint_value and not client_base_url:
client_args["azure_endpoint"] = str(endpoint_value)
if responses_deployment_name:
client_args["azure_deployment"] = responses_deployment_name
if "websocket_base_url" in kwargs:
client_args["websocket_base_url"] = kwargs.pop("websocket_base_url")
async_client = AsyncAzureOpenAI(**client_args)
# Store Azure-specific attributes for serialization
self.endpoint = str(endpoint_value) if endpoint_value else None
self.api_version = azure_openai_settings.get("api_version") or ""
self.deployment_name = responses_deployment_name
with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=client_base_url):
super().__init__(
async_client=async_client,
model=responses_deployment_name,
azure_endpoint=str(endpoint_value) if endpoint_value else None,
base_url=str(client_base_url) if client_base_url else None,
api_version=azure_openai_settings.get("api_version"),
instruction_role=instruction_role,
default_headers=default_headers,
middleware=middleware, # type: ignore[arg-type]
function_invocation_configuration=function_invocation_configuration,
**kwargs,
)
@staticmethod
def _create_client_from_project(
*,
project_client: AIProjectClient | None,
project_endpoint: str | None,
credential: AzureCredentialTypes | AzureTokenProvider | None,
allow_preview: bool | None = None,
) -> AsyncOpenAI:
"""Create an AsyncOpenAI client from an Azure AI Foundry project."""
if project_client is not None:
return project_client.get_openai_client()
if not project_endpoint:
raise ValueError("Azure AI project endpoint is required when project_client is not provided.")
if not credential:
raise ValueError("Azure credential is required when using project_endpoint without a project_client.")
project_client_kwargs: dict[str, Any] = {
"endpoint": project_endpoint,
"credential": credential, # type: ignore[arg-type]
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
project_client = AIProjectClient(**project_client_kwargs)
return project_client.get_openai_client()
@override
def _check_model_presence(self, options: dict[str, Any]) -> None:
if not options.get("model"):
if not self.model:
raise ValueError("deployment_name must be a non-empty string")
options["model"] = self.model
# endregion
# region AzureOpenAIChatClient
ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None)
class AzureUserSecurityContext(TypedDict, total=False):
"""User security context for Azure AI applications.
These fields help security operations teams investigate and mitigate security
incidents by providing context about the application and end user.
"""
application_name: str
"""Name of the application making the request."""
end_user_id: str
"""Unique identifier for the end user (recommend hashing username/email)."""
end_user_tenant_id: str
"""Microsoft 365 tenant ID the end user belongs to. Required for multi-tenant apps."""
source_ip: str
"""The original client's IP address."""
class AzureOpenAIChatOptions(OpenAIChatCompletionOptions[ResponseModelT], Generic[ResponseModelT], total=False):
"""Azure OpenAI-specific chat options dict.
Extends OpenAIChatCompletionOptions with Azure-specific options including
the "On Your Data" feature and enhanced security context.
"""
data_sources: list[dict[str, Any]]
"""Azure "On Your Data" data sources for retrieval-augmented generation."""
user_security_context: AzureUserSecurityContext
"""Enhanced security context for Azure Defender integration."""
n: int
"""Number of chat completion choices to generate for each input message."""
AzureOpenAIChatOptionsT = TypeVar(
"AzureOpenAIChatOptionsT",
bound=TypedDict, # type: ignore[valid-type]
default="AzureOpenAIChatOptions",
covariant=True,
)
@deprecated("AzureOpenAIChatClient is deprecated. Use OpenAIChatCompletionClient with an AsyncAzureOpenAI client.")
class AzureOpenAIChatClient( # type: ignore[misc]
FunctionInvocationLayer[AzureOpenAIChatOptionsT],
ChatMiddlewareLayer[AzureOpenAIChatOptionsT],
ChatTelemetryLayer[AzureOpenAIChatOptionsT],
RawOpenAIChatCompletionClient[AzureOpenAIChatOptionsT],
Generic[AzureOpenAIChatOptionsT],
):
"""Deprecated Azure OpenAI Chat client. Use OpenAIChatCompletionClient with AsyncAzureOpenAI instead."""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai"
def __init__(
self,
*,
api_key: str | None = None,
deployment_name: str | None = None,
endpoint: str | None = None,
base_url: str | None = None,
api_version: str | None = None,
token_endpoint: str | None = None,
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
default_headers: Mapping[str, str] | None = None,
async_client: AsyncAzureOpenAI | None = None,
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
instruction_role: str | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
) -> None:
"""Initialize an Azure OpenAI Chat completion client.
Keyword Args:
api_key: The API key.
deployment_name: The deployment name.
endpoint: The deployment endpoint.
base_url: The deployment base URL.
api_version: The deployment API version.
token_endpoint: The token endpoint to request an Azure token.
credential: Azure credential or token provider for authentication.
default_headers: Default headers for HTTP requests.
async_client: An existing client to use.
additional_properties: Additional properties stored on the client instance.
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
instruction_role: The role to use for 'instruction' messages.
middleware: Optional sequence of middleware.
function_invocation_configuration: Optional function invocation configuration.
"""
azure_openai_settings = load_settings(
AzureOpenAISettings,
env_prefix="AZURE_OPENAI_",
api_key=api_key,
base_url=base_url,
endpoint=endpoint,
chat_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_endpoint,
)
_apply_azure_defaults(azure_openai_settings)
chat_deployment_name = azure_openai_settings.get("chat_deployment_name")
if not chat_deployment_name:
raise ValueError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
)
endpoint_value = azure_openai_settings.get("endpoint")
base_url_value = azure_openai_settings.get("base_url")
if not async_client:
# Create the Azure OpenAI client directly
merged_headers = dict(copy(default_headers)) if default_headers else {}
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)
api_key_secret = azure_openai_settings.get("api_key")
ad_token_provider = None
if not api_key_secret and credential:
ad_token_provider = resolve_credential_to_token_provider(
credential, azure_openai_settings.get("token_endpoint")
)
if not api_key_secret and not ad_token_provider:
raise ValueError("Please provide either api_key, credential, or a client.")
if not endpoint_value and not base_url_value:
raise ValueError("Please provide an endpoint or a base_url")
client_args: dict[str, Any] = {"default_headers": merged_headers}
if resolved_api_version := azure_openai_settings.get("api_version"):
client_args["api_version"] = resolved_api_version
if ad_token_provider:
client_args["azure_ad_token_provider"] = ad_token_provider
if api_key_secret:
client_args["api_key"] = api_key_secret.get_secret_value()
if base_url_value:
client_args["base_url"] = str(base_url_value)
if endpoint_value and not base_url_value:
client_args["azure_endpoint"] = str(endpoint_value)
if chat_deployment_name:
client_args["azure_deployment"] = chat_deployment_name
async_client = AsyncAzureOpenAI(**client_args)
# Store Azure-specific attributes for serialization
self.endpoint = str(azure_openai_settings.get("endpoint") or "")
self.api_version = azure_openai_settings.get("api_version") or ""
self.deployment_name = chat_deployment_name
with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value):
super().__init__(
async_client=async_client,
model=chat_deployment_name,
azure_endpoint=str(endpoint_value) if endpoint_value else None,
base_url=str(base_url_value) if base_url_value else None,
api_version=azure_openai_settings.get("api_version"),
instruction_role=instruction_role,
default_headers=default_headers,
additional_properties=additional_properties,
middleware=middleware, # type: ignore[arg-type]
function_invocation_configuration=function_invocation_configuration,
)
@override
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None:
"""Parse the choice into a Content object with type='text'.
Overwritten from RawOpenAIChatCompletionClient to deal with Azure On Your Data function.
"""
message = getattr(choice, "message", None)
if message is None:
message = getattr(choice, "delta", None)
if message is None: # type: ignore
return None
if hasattr(message, "refusal") and message.refusal:
return Content.from_text(text=message.refusal, raw_representation=choice)
if not message.content:
return None
text_content = Content.from_text(text=message.content, raw_representation=choice)
if not message.model_extra or "context" not in message.model_extra:
return text_content
context_raw: object = cast(object, message.context) # type: ignore[union-attr]
if isinstance(context_raw, str):
try:
context_raw = json.loads(context_raw)
except json.JSONDecodeError:
logger.warning("Context is not a valid JSON string, ignoring context.")
return text_content
if not isinstance(context_raw, dict):
logger.warning("Context is not a valid dictionary, ignoring context.")
return text_content
context = cast(dict[str, Any], context_raw)
if intent := context.get("intent"):
text_content.additional_properties = {"intent": intent}
citations = context.get("citations")
if isinstance(citations, list) and citations:
annotations: list[Annotation] = []
for citation_raw in cast(list[object], citations):
if not isinstance(citation_raw, dict):
continue
citation = cast(dict[str, Any], citation_raw)
annotations.append(
Annotation(
type="citation",
title=citation.get("title", ""),
url=citation.get("url", ""),
snippet=citation.get("content", ""),
file_id=citation.get("filepath", ""),
tool_name="Azure-on-your-Data",
additional_properties={"chunk_id": citation.get("chunk_id", "")},
raw_representation=citation,
)
)
text_content.annotations = annotations
return text_content
# endregion
# region AzureOpenAIAssistantsClient
AzureOpenAIAssistantsOptionsT = TypeVar(
"AzureOpenAIAssistantsOptionsT",
bound=TypedDict, # type: ignore[valid-type]
default="OpenAIAssistantsOptions",
covariant=True,
)
AzureOpenAIAssistantsOptions = OpenAIAssistantsOptions
@deprecated(
"AzureOpenAIAssistantsClient is deprecated. "
"Use OpenAIAssistantsClient (also deprecated) or migrate to OpenAIChatClient."
)
class AzureOpenAIAssistantsClient(
OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], # type: ignore[reportDeprecated]
Generic[AzureOpenAIAssistantsOptionsT],
):
"""Deprecated Azure OpenAI Assistants client. Use OpenAIAssistantsClient or migrate to OpenAIChatClient."""
DEFAULT_AZURE_API_VERSION: ClassVar[str] = "2024-05-01-preview"
def __init__(
self,
*,
deployment_name: str | None = None,
assistant_id: str | None = None,
assistant_name: str | None = None,
assistant_description: str | None = None,
thread_id: str | None = None,
api_key: str | None = None,
endpoint: str | None = None,
base_url: str | None = None,
api_version: str | None = None,
token_endpoint: str | None = None,
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
default_headers: Mapping[str, str] | None = None,
async_client: AsyncAzureOpenAI | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize an Azure OpenAI Assistants client.
Keyword Args:
deployment_name: The Azure OpenAI deployment name.
assistant_id: The ID of an Azure OpenAI assistant to use.
assistant_name: The name to use when creating new assistants.
assistant_description: The description to use when creating new assistants.
thread_id: Default thread ID to use for conversations.
api_key: The API key to use.
endpoint: The deployment endpoint.
base_url: The deployment base URL.
api_version: The deployment API version.
token_endpoint: The token endpoint to request an Azure token.
credential: Azure credential or token provider for authentication.
default_headers: Default headers for HTTP requests.
async_client: An existing client to use.
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
"""
azure_openai_settings = load_settings(
AzureOpenAISettings,
env_prefix="AZURE_OPENAI_",
api_key=api_key,
base_url=base_url,
endpoint=endpoint,
chat_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_endpoint,
)
_apply_azure_defaults(azure_openai_settings, default_api_version=self.DEFAULT_AZURE_API_VERSION)
chat_deployment_name = azure_openai_settings.get("chat_deployment_name")
if not chat_deployment_name:
raise ValueError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
)
api_key_secret = azure_openai_settings.get("api_key")
token_scope = azure_openai_settings.get("token_endpoint")
ad_token_provider = None
if not async_client and not api_key_secret and credential:
ad_token_provider = resolve_credential_to_token_provider(credential, token_scope)
if not async_client and not api_key_secret and not ad_token_provider:
raise ValueError("Please provide either api_key, credential, or a client.")
if not async_client:
client_params: dict[str, Any] = {
"default_headers": default_headers,
}
if resolved_api_version := azure_openai_settings.get("api_version"):
client_params["api_version"] = resolved_api_version
if api_key_secret:
client_params["api_key"] = api_key_secret.get_secret_value()
elif ad_token_provider:
client_params["azure_ad_token_provider"] = ad_token_provider
if resolved_base_url := azure_openai_settings.get("base_url"):
client_params["base_url"] = str(resolved_base_url)
elif resolved_endpoint := azure_openai_settings.get("endpoint"):
client_params["azure_endpoint"] = str(resolved_endpoint)
async_client = AsyncAzureOpenAI(**client_params)
super().__init__(
model_id=chat_deployment_name,
assistant_id=assistant_id,
assistant_name=assistant_name,
assistant_description=assistant_description,
thread_id=thread_id,
async_client=async_client, # type: ignore[reportArgumentType]
default_headers=default_headers,
)
# endregion
# region AzureOpenAIEmbeddingClient
AzureOpenAIEmbeddingOptionsT = TypeVar(
"AzureOpenAIEmbeddingOptionsT",
bound=TypedDict, # type: ignore[valid-type]
default="OpenAIEmbeddingOptions",
covariant=True,
)
@deprecated("AzureOpenAIEmbeddingClient is deprecated. Use OpenAIEmbeddingClient with an AsyncAzureOpenAI client.")
class AzureOpenAIEmbeddingClient(
EmbeddingTelemetryLayer[str, list[float], AzureOpenAIEmbeddingOptionsT],
RawOpenAIEmbeddingClient[AzureOpenAIEmbeddingOptionsT],
Generic[AzureOpenAIEmbeddingOptionsT],
):
"""Deprecated Azure OpenAI embedding client. Use OpenAIEmbeddingClient with AsyncAzureOpenAI instead."""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai"
def __init__(
self,
*,
api_key: str | None = None,
deployment_name: str | None = None,
endpoint: str | None = None,
base_url: str | None = None,
api_version: str | None = None,
token_endpoint: str | None = None,
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
default_headers: Mapping[str, str] | None = None,
async_client: AsyncAzureOpenAI | None = None,
otel_provider_name: str | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize an Azure OpenAI embedding client.
Keyword Args:
api_key: The API key.
deployment_name: The deployment name.
endpoint: The deployment endpoint.
base_url: The deployment base URL.
api_version: The deployment API version.
token_endpoint: The token endpoint to request an Azure token.
credential: Azure credential or token provider for authentication.
default_headers: Default headers for HTTP requests.
async_client: An existing client to use.
otel_provider_name: Override the OpenTelemetry provider name.
env_file_path: Path to .env file for settings.
env_file_encoding: Encoding for .env file.
"""
azure_openai_settings = load_settings(
AzureOpenAISettings,
env_prefix="AZURE_OPENAI_",
api_key=api_key,
base_url=base_url,
endpoint=endpoint,
embedding_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_endpoint,
)
_apply_azure_defaults(azure_openai_settings)
embedding_deployment_name = azure_openai_settings.get("embedding_deployment_name")
if not embedding_deployment_name:
raise ValueError(
"Azure OpenAI embedding deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME' environment variable."
)
endpoint_value = azure_openai_settings.get("endpoint")
base_url_value = azure_openai_settings.get("base_url")
if not async_client:
# Create the Azure OpenAI client directly
merged_headers = dict(copy(default_headers)) if default_headers else {}
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)
api_key_secret = azure_openai_settings.get("api_key")
ad_token_provider = None
if not api_key_secret and credential:
ad_token_provider = resolve_credential_to_token_provider(
credential, azure_openai_settings.get("token_endpoint")
)
if not api_key_secret and not ad_token_provider:
raise ValueError("Please provide either api_key, credential, or a client.")
if not endpoint_value and not base_url_value:
raise ValueError("Please provide an endpoint or a base_url")
client_args: dict[str, Any] = {"default_headers": merged_headers}
if resolved_api_version := azure_openai_settings.get("api_version"):
client_args["api_version"] = resolved_api_version
if ad_token_provider:
client_args["azure_ad_token_provider"] = ad_token_provider
if api_key_secret:
client_args["api_key"] = api_key_secret.get_secret_value()
if base_url_value:
client_args["base_url"] = str(base_url_value)
if endpoint_value and not base_url_value:
client_args["azure_endpoint"] = str(endpoint_value)
if embedding_deployment_name:
client_args["azure_deployment"] = embedding_deployment_name
async_client = AsyncAzureOpenAI(**client_args)
# Store Azure-specific attributes for serialization
self.endpoint = str(azure_openai_settings.get("endpoint") or "")
self.api_version = azure_openai_settings.get("api_version") or ""
self.deployment_name = embedding_deployment_name
with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value):
super().__init__(
async_client=async_client,
model=embedding_deployment_name,
azure_endpoint=str(endpoint_value) if endpoint_value else None,
base_url=str(base_url_value) if base_url_value else None,
api_version=azure_openai_settings.get("api_version"),
default_headers=default_headers,
)
if otel_provider_name is not None:
self.OTEL_PROVIDER_NAME = otel_provider_name # type: ignore[misc]
# endregion
@@ -1,488 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
import sys
from collections.abc import Callable, Mapping, MutableMapping, Sequence
from typing import Any, Generic, cast
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
Agent,
BaseContextProvider,
FunctionTool,
MiddlewareTypes,
normalize_tools,
)
from agent_framework._mcp import MCPTool
from agent_framework._settings import load_settings
from agent_framework._tools import ToolTypes
from azure.ai.projects.aio import AIProjectClient
from azure.ai.projects.models import (
AgentVersionDetails,
PromptAgentDefinition,
PromptAgentDefinitionTextOptions,
)
from azure.ai.projects.models import (
FunctionTool as AzureFunctionTool,
)
from ._client import AzureAIClient, AzureAIProjectAgentOptions # pyright: ignore[reportDeprecated]
from ._entra_id_authentication import AzureCredentialTypes
from ._shared import AzureAISettings, create_text_format_config, from_azure_ai_tools, to_azure_ai_tools
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
from warnings import deprecated # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self, TypedDict # type: ignore # pragma: no cover
else:
from typing_extensions import Self, TypedDict # type: ignore # pragma: no cover
logger = logging.getLogger("agent_framework.azure")
# Type variable for options - allows typed Agent[OptionsT] returns
# Default matches AzureAIClient's default options type
OptionsCoT = TypeVar(
"OptionsCoT",
bound=TypedDict, # type: ignore[valid-type]
default="AzureAIProjectAgentOptions",
covariant=True,
)
@deprecated("AzureAIProjectAgentProvider is deprecated. Use FoundryAgent instead.")
class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
"""Deprecated provider for Azure AI Agent Service (Responses API).
This provider is deprecated. Use ``FoundryAgent`` instead to connect to
pre-configured agents in Foundry.
Examples:
Using with explicit AIProjectClient:
.. code-block:: python
from agent_framework.azure import AzureAIProjectAgentProvider
from azure.ai.projects.aio import AIProjectClient
from azure.identity.aio import DefaultAzureCredential
async with AIProjectClient(endpoint, credential) as client:
provider = AzureAIProjectAgentProvider(client)
agent = await provider.create_agent(
name="MyAgent",
model="gpt-4",
instructions="You are a helpful assistant.",
)
response = await agent.run("Hello!")
Using with credential and endpoint (auto-creates client):
.. code-block:: python
from agent_framework.azure import AzureAIProjectAgentProvider
from azure.identity.aio import DefaultAzureCredential
async with AzureAIProjectAgentProvider(credential=credential) as provider:
agent = await provider.create_agent(
name="MyAgent",
model="gpt-4",
instructions="You are a helpful assistant.",
)
response = await agent.run("Hello!")
"""
def __init__(
self,
project_client: AIProjectClient | None = None,
*,
project_endpoint: str | None = None,
model: str | None = None,
credential: AzureCredentialTypes | None = None,
allow_preview: bool | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize an Azure AI Project Agent Provider.
Args:
project_client: An existing AIProjectClient to use. If not provided, one will be created.
project_endpoint: The Azure AI Project endpoint URL.
Can also be set via environment variable AZURE_AI_PROJECT_ENDPOINT.
Ignored when a project_client is passed.
model: The default model deployment name to use for agent creation.
Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME.
credential: Azure credential for authentication. Accepts a TokenCredential,
AsyncTokenCredential, or a callable token provider.
Required when project_client is not provided.
allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
Raises:
ValueError: If required parameters are missing or invalid.
"""
self._settings = load_settings(
AzureAISettings,
env_prefix="AZURE_AI_",
project_endpoint=project_endpoint,
model_deployment_name=model,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
# Track whether we should close client connection
self._should_close_client = False
if project_client is None:
resolved_endpoint = self._settings.get("project_endpoint")
if not resolved_endpoint:
raise ValueError(
"Azure AI project endpoint is required. Set via 'project_endpoint' parameter "
"or 'AZURE_AI_PROJECT_ENDPOINT' environment variable."
)
if not credential:
raise ValueError("Azure credential is required when project_client is not provided.")
project_client_kwargs: dict[str, Any] = {
"endpoint": resolved_endpoint,
"credential": credential, # type: ignore[arg-type]
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
project_client = AIProjectClient(**project_client_kwargs)
self._should_close_client = True
self._project_client = project_client
async def create_agent(
self,
name: str,
model: str | None = None,
instructions: str | None = None,
description: str | None = None,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Create a new agent on the Azure AI service and return a local Agent wrapper.
Args:
name: The name of the agent to create.
model: The model deployment name to use. Falls back to AZURE_AI_MODEL_DEPLOYMENT_NAME
environment variable if not provided.
instructions: Instructions for the agent.
description: A description of the agent.
tools: Tools to make available to the agent.
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_providers: Context providers to include during agent invocation.
Returns:
Agent: A Agent instance configured with the created agent.
Raises:
ValueError: If required parameters are missing.
"""
# Resolve model from parameter or environment variable
resolved_model = model or self._settings.get("model_deployment_name")
if not resolved_model:
raise ValueError(
"Model deployment name is required. Provide 'model' parameter "
"or set 'AZURE_AI_MODEL_DEPLOYMENT_NAME' environment variable."
)
# Extract options from default_options if present
opts: dict[str, Any] = dict(default_options) if default_options else {}
response_format = opts.get("response_format")
rai_config = opts.get("rai_config")
reasoning = opts.get("reasoning")
args: dict[str, Any] = {"model": resolved_model}
if instructions:
args["instructions"] = instructions
if response_format and isinstance(response_format, (type, dict)):
args["text"] = PromptAgentDefinitionTextOptions(
format=create_text_format_config(response_format) # type: ignore[arg-type]
)
if rai_config:
args["rai_config"] = rai_config
if reasoning:
args["reasoning"] = reasoning
# Normalize tools and separate MCP tools from other tools
normalized_tools = normalize_tools(tools)
mcp_tools: list[MCPTool] = []
non_mcp_tools: list[FunctionTool | MutableMapping[str, Any]] = []
if normalized_tools:
for tool in normalized_tools:
if isinstance(tool, MCPTool):
mcp_tools.append(tool)
elif isinstance(tool, (FunctionTool, MutableMapping)):
non_mcp_tools.append(tool) # type: ignore[reportUnknownArgumentType]
# Connect MCP tools and discover their functions BEFORE creating the agent
# This is required because Azure AI Responses API doesn't accept tools at request time
mcp_discovered_functions: list[FunctionTool] = []
for mcp_tool in mcp_tools:
if not mcp_tool.is_connected:
await mcp_tool.connect()
mcp_discovered_functions.extend(mcp_tool.functions)
# Combine non-MCP tools with discovered MCP functions for Azure AI
all_tools_for_azure: list[FunctionTool | MutableMapping[str, Any]] = list(non_mcp_tools)
all_tools_for_azure.extend(mcp_discovered_functions)
if all_tools_for_azure:
args["tools"] = to_azure_ai_tools(all_tools_for_azure)
create_version_kwargs: dict[str, Any] = {
"agent_name": name,
"definition": PromptAgentDefinition(**args),
"description": description,
}
created_agent = await self._project_client.agents.create_version(**create_version_kwargs)
return self._to_chat_agent_from_details(
created_agent,
normalized_tools,
default_options=default_options,
middleware=middleware,
context_providers=context_providers,
)
async def get_agent(
self,
*,
name: str | None = None,
reference: Mapping[str, str | None] | None = None,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Retrieve an existing agent from the Azure AI service and return a local Agent wrapper.
You must provide either name or reference. Use `as_agent()` if you already have
AgentVersionDetails and want to avoid an async call.
Args:
name: The name of the agent to retrieve (fetches latest version).
reference: Mapping containing the agent's ``name`` and optionally a specific ``version``.
tools: Tools to make available to the agent. Required if the agent has function tools.
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_providers: Context providers to include during agent invocation.
Returns:
Agent: A Agent instance configured with the retrieved agent.
Raises:
ValueError: If no identifier is provided or required tools are missing.
"""
existing_agent: AgentVersionDetails
reference_name = str(reference.get("name")) if reference and reference.get("name") else None
reference_version = str(reference.get("version")) if reference and reference.get("version") else None
if reference_name and reference_version:
# Fetch specific version
existing_agent = await self._project_client.agents.get_version(
agent_name=reference_name, agent_version=reference_version
)
elif agent_name := (reference_name if reference_name else name):
# Fetch latest version
details = await self._project_client.agents.get(agent_name=agent_name)
existing_agent = details.versions.latest
else:
raise ValueError("Either name or reference must be provided to get an agent.")
if not isinstance(existing_agent.definition, PromptAgentDefinition):
raise ValueError("Agent definition must be PromptAgentDefinition to get a Agent.")
# Validate that required function tools are provided
self._validate_function_tools(existing_agent.definition.tools, tools)
return self._to_chat_agent_from_details(
existing_agent,
normalize_tools(tools),
default_options=default_options,
middleware=middleware,
context_providers=context_providers,
)
def as_agent(
self,
details: AgentVersionDetails,
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Wrap an SDK agent version object into a Agent without making HTTP calls.
Use this when you already have an AgentVersionDetails from a previous API call.
Args:
details: The AgentVersionDetails to wrap.
tools: Tools to make available to the agent. Required if the agent has function tools.
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_providers: Context providers to include during agent invocation.
Returns:
Agent: A Agent instance configured with the agent version.
Raises:
ValueError: If the agent definition is not a PromptAgentDefinition or required tools are missing.
"""
if not isinstance(details.definition, PromptAgentDefinition):
raise ValueError("Agent definition must be PromptAgentDefinition to create a Agent.")
# Validate that required function tools are provided
self._validate_function_tools(details.definition.tools, tools)
return self._to_chat_agent_from_details(
details,
normalize_tools(tools),
default_options=default_options,
middleware=middleware,
context_providers=context_providers,
)
def _to_chat_agent_from_details(
self,
details: AgentVersionDetails,
provided_tools: Sequence[ToolTypes] | None = None,
default_options: OptionsCoT | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
context_providers: Sequence[BaseContextProvider] | None = None,
) -> Agent[OptionsCoT]:
"""Create a Agent from an AgentVersionDetails.
Args:
details: The AgentVersionDetails containing the agent definition.
provided_tools: User-provided tools (including function implementations).
These are merged with hosted tools from the definition.
default_options: A TypedDict containing default chat options for the agent.
These options are applied to every run unless overridden.
middleware: List of middleware to intercept agent and function invocations.
context_providers: Context providers to include during agent invocation.
"""
if not isinstance(details.definition, PromptAgentDefinition):
raise ValueError("Agent definition must be PromptAgentDefinition to get a Agent.")
client = AzureAIClient( # pyright: ignore[reportDeprecated]
project_client=self._project_client,
agent_name=details.name,
agent_version=details.version,
agent_description=details.description,
model_deployment_name=details.definition.model,
)
# Merge tools: hosted tools from definition + user-provided function tools
# from_azure_ai_tools converts hosted tools (MCP, code interpreter, file search, web search)
# but function tools need the actual implementations from provided_tools
merged_tools = self._merge_tools(details.definition.tools, provided_tools)
merged_default_options: dict[str, Any] = dict(default_options) if default_options is not None else {}
merged_default_options.setdefault("model_id", details.definition.model)
return Agent( # type: ignore[return-value]
client=client,
id=details.id,
name=details.name,
description=details.description,
instructions=details.definition.instructions,
tools=merged_tools,
default_options=cast(Any, merged_default_options),
middleware=middleware,
context_providers=context_providers,
)
def _merge_tools(
self,
definition_tools: Sequence[Any] | None,
provided_tools: Sequence[ToolTypes] | None,
) -> list[ToolTypes]:
"""Merge hosted tools from definition with user-provided function tools.
Args:
definition_tools: Tools from the agent definition (Azure AI format).
provided_tools: User-provided tools (Agent Framework format), including function implementations.
Returns:
Combined list of tools for the Agent.
"""
merged: list[ToolTypes] = []
# Convert hosted tools from definition (MCP, code interpreter, file search, web search)
# Function tools from the definition are skipped - we use user-provided implementations instead
hosted_tools = from_azure_ai_tools(definition_tools)
for hosted_tool in hosted_tools:
# Skip function tool dicts - they don't have implementations
if isinstance(hosted_tool, dict) and hosted_tool.get("type") == "function":
continue
merged.append(hosted_tool)
# Add user-provided function tools and MCP tools
if provided_tools:
for provided_tool in provided_tools:
# FunctionTool - has implementation for function calling
# MCPTool - Agent handles MCP connection and tool discovery at runtime
if isinstance(provided_tool, (FunctionTool, MCPTool)):
merged.append(provided_tool) # type: ignore[reportUnknownArgumentType]
return merged
def _validate_function_tools(
self,
agent_tools: Sequence[Any] | None,
provided_tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
) -> None:
"""Validate that required function tools are provided."""
# Normalize and validate function tools
normalized_tools = normalize_tools(provided_tools)
tool_names = {tool.name for tool in normalized_tools if isinstance(tool, FunctionTool)}
# If function tools exist in agent definition but were not provided,
# we need to raise an error, as it won't be possible to invoke the function.
missing_tools = [
tool.name
for tool in (agent_tools or [])
if isinstance(tool, AzureFunctionTool) and tool.name not in tool_names
]
if missing_tools:
raise ValueError(
f"The following prompt agent definition required tools were not provided: {', '.join(missing_tools)}"
)
async def __aenter__(self) -> Self:
"""Async context manager entry."""
return self
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
"""Async context manager exit."""
await self.close()
async def close(self) -> None:
"""Close the provider and release resources.
Only closes the underlying AIProjectClient if it was created by this provider.
"""
if self._should_close_client:
await self._project_client.close()
@@ -2,45 +2,13 @@
from __future__ import annotations
import logging
import sys
import warnings
from collections.abc import Mapping, MutableMapping, Sequence
from typing import Any, cast
from agent_framework import (
Content,
FunctionTool,
)
from agent_framework.exceptions import IntegrationInvalidRequestException
from azure.ai.agents.models import (
CodeInterpreterToolDefinition,
ToolDefinition,
)
from azure.ai.projects.models import (
CodeInterpreterTool,
MCPTool,
TextResponseFormatJsonObject,
TextResponseFormatJsonSchema,
TextResponseFormatText,
Tool,
WebSearchPreviewTool,
)
from azure.ai.projects.models import (
FileSearchTool as ProjectsFileSearchTool,
)
from azure.ai.projects.models import (
FunctionTool as AzureFunctionTool,
)
from pydantic import BaseModel
if sys.version_info >= (3, 11):
from typing import TypedDict # pragma: no cover
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
logger = logging.getLogger("agent_framework.azure")
class AzureAISettings(TypedDict, total=False):
"""Azure AI Project settings.
@@ -78,518 +46,3 @@ class AzureAISettings(TypedDict, total=False):
project_endpoint: str | None
model_deployment_name: str | None
def _extract_project_connection_id(additional_properties: Mapping[str, Any] | None) -> str | None:
"""Extract project_connection_id from tool additional_properties.
Checks for both direct 'project_connection_id' key (programmatic usage)
and 'connection.name' structure (declarative/YAML usage).
Args:
additional_properties: The additional_properties dict from a tool.
Returns:
The project_connection_id if found, None otherwise.
"""
if not additional_properties:
return None
# Check for direct project_connection_id (programmatic usage)
if (proj_conn_id := additional_properties.get("project_connection_id")) and isinstance(proj_conn_id, str):
return proj_conn_id # type: ignore[no-any-return]
# Check for connection.name structure (declarative/YAML usage)
if (
(connection := additional_properties.get("connection"))
and isinstance(connection, Mapping)
and (name := connection.get("name")) # type: ignore
and isinstance(name, str)
):
return name # type: ignore[no-any-return]
return None
def resolve_file_ids(file_ids: Sequence[str | Content] | None) -> list[str] | None:
"""Resolve a list of file ID values that may include Content objects.
Accepts plain strings and Content objects with type "hosted_file", extracting
the file_id from each. This enables users to pass Content.from_hosted_file()
alongside plain file ID strings.
Args:
file_ids: Sequence of file ID strings or Content objects, or None.
Returns:
A list of resolved file ID strings, or None if input is None or empty.
Raises:
ValueError: If a Content object has an unsupported type (not "hosted_file").
"""
if not file_ids:
return None
resolved: list[str] = []
for item in file_ids:
if isinstance(item, str):
if not item:
raise ValueError("file_ids must not contain empty strings.")
resolved.append(item)
elif isinstance(item, Content):
if item.type != "hosted_file":
raise ValueError(
f"Unsupported Content type '{item.type}' for code interpreter file_ids. "
"Only Content.from_hosted_file() is supported."
)
if item.file_id is None:
raise ValueError(
"Content.from_hosted_file() item is missing a file_id. "
"Ensure the Content object has a valid file_id before using it in file_ids."
)
resolved.append(item.file_id)
return resolved if resolved else None
def to_azure_ai_agent_tools(
tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None,
run_options: dict[str, Any] | None = None,
) -> list[ToolDefinition | dict[str, Any]]:
"""Convert Agent Framework tools to Azure AI V1 SDK tool definitions.
.. deprecated::
This function is deprecated and will be removed in a future release.
Use :func:`to_azure_ai_tools` instead for the V2 (Projects/Responses) API.
Handles FunctionTool instances and dict-based tools from static factory methods.
Args:
tools: Sequence of Agent Framework tools to convert.
run_options: Optional dict with run options.
Returns:
List of Azure AI V1 SDK tool definitions.
Raises:
ValueError: If tool configuration is invalid.
"""
warnings.warn(
"to_azure_ai_agent_tools() is deprecated and will be removed in a future release; "
"use to_azure_ai_tools() instead for the V2 (Projects/Responses) API.",
DeprecationWarning,
stacklevel=2,
)
if not tools:
return []
tool_definitions: list[ToolDefinition | dict[str, Any]] = []
for tool in tools:
if isinstance(tool, FunctionTool):
tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType]
elif isinstance(tool, ToolDefinition):
# Pass through ToolDefinition subclasses unchanged (includes CodeInterpreterToolDefinition, etc.)
tool_definitions.append(tool)
elif hasattr(tool, "definitions") and not isinstance(tool, (dict, MutableMapping)):
# SDK Tool wrappers (McpTool, FileSearchTool, BingGroundingTool, etc.)
tool_definitions.extend(tool.definitions)
# Handle tool resources (MCP resources handled separately)
if (
run_options is not None
and hasattr(tool, "resources")
and tool.resources
and "mcp" not in tool.resources
):
run_options.setdefault("tool_resources", {})
if isinstance(tool.resources, Mapping):
run_options["tool_resources"].update(tool.resources)
elif isinstance(tool, (dict, MutableMapping)):
# Handle dict-based tools - pass through directly
tool_dict = tool if isinstance(tool, dict) else dict(tool)
tool_definitions.append(tool_dict)
else:
# Pass through other types unchanged
tool_definitions.append(tool)
return tool_definitions
def from_azure_ai_agent_tools(
tools: Sequence[ToolDefinition | dict[str, Any]] | None,
) -> list[dict[str, Any]]:
"""Convert Azure AI V1 SDK tool definitions to dict-based tools.
.. deprecated::
This function is deprecated and will be removed in a future release.
Use :func:`from_azure_ai_tools` instead for the V2 (Projects/Responses) API.
Args:
tools: Sequence of Azure AI V1 SDK tool definitions.
Returns:
List of dict-based tool definitions.
"""
warnings.warn(
"from_azure_ai_agent_tools() is deprecated and will be removed in a future release; "
"use from_azure_ai_tools() instead for the V2 (Projects/Responses) API.",
DeprecationWarning,
stacklevel=2,
)
if not tools:
return []
result: list[dict[str, Any]] = []
for tool in tools:
# Handle SDK objects
if isinstance(tool, CodeInterpreterToolDefinition):
result.append({"type": "code_interpreter"})
elif isinstance(tool, dict):
# Handle dict format
converted = _convert_dict_tool(tool)
if converted is not None:
result.append(converted)
elif hasattr(tool, "type"):
# Handle other SDK objects by type
converted = _convert_sdk_tool(tool)
if converted is not None:
result.append(converted)
return result
def _convert_dict_tool(tool: dict[str, Any]) -> dict[str, Any] | None:
"""Convert a dict-format Azure AI tool to dict-based tool format."""
tool_type = tool.get("type")
if tool_type == "code_interpreter":
return {"type": "code_interpreter"}
if tool_type == "file_search":
file_search_config = tool.get("file_search", {})
vector_store_ids = file_search_config.get("vector_store_ids", [])
return {"type": "file_search", "vector_store_ids": vector_store_ids}
if tool_type == "bing_grounding":
bing_config = tool.get("bing_grounding", {})
connection_id = bing_config.get("connection_id")
return {"type": "bing_grounding", "connection_id": connection_id} if connection_id else None
if tool_type == "bing_custom_search":
bing_config = tool.get("bing_custom_search", {})
connection_id = bing_config.get("connection_id")
instance_name = bing_config.get("instance_name")
# Only return if both required fields are present
if connection_id and instance_name:
return {
"type": "bing_custom_search",
"connection_id": connection_id,
"instance_name": instance_name,
}
return None
if tool_type == "mcp":
# MCP tools are defined on the Azure agent, no local handling needed
# Azure may not return full server_url, so skip conversion
return None
if tool_type == "function":
# Function tools are returned as dicts - users must provide implementations
return tool
# Unknown tool type - pass through
return tool
def _convert_sdk_tool(tool: ToolDefinition) -> dict[str, Any] | None:
"""Convert an SDK-object Azure AI tool to dict-based tool format."""
tool_type = getattr(tool, "type", None)
if tool_type == "code_interpreter":
return {"type": "code_interpreter"}
if tool_type == "file_search":
file_search_config = getattr(tool, "file_search", None)
vector_store_ids = getattr(file_search_config, "vector_store_ids", []) if file_search_config else []
return {"type": "file_search", "vector_store_ids": vector_store_ids}
if tool_type == "bing_grounding":
bing_config = getattr(tool, "bing_grounding", None)
connection_id = getattr(bing_config, "connection_id", None) if bing_config else None
return {"type": "bing_grounding", "connection_id": connection_id} if connection_id else None
if tool_type == "bing_custom_search":
bing_config = getattr(tool, "bing_custom_search", None)
connection_id = getattr(bing_config, "connection_id", None) if bing_config else None
instance_name = getattr(bing_config, "instance_name", None) if bing_config else None
# Only return if both required fields are present
if connection_id and instance_name:
return {
"type": "bing_custom_search",
"connection_id": connection_id,
"instance_name": instance_name,
}
return None
if tool_type == "mcp":
# MCP tools are defined on the Azure agent, no local handling needed
# Azure may not return full server_url, so skip conversion
return None
if tool_type == "function":
# Function tools from SDK don't have implementations - skip
return None
# Unknown tool type - convert to dict if possible
if hasattr(tool, "as_dict"):
return tool.as_dict() # type: ignore[union-attr]
return {"type": tool_type} if tool_type else {}
def from_azure_ai_tools(tools: Sequence[Tool | dict[str, Any]] | None) -> list[dict[str, Any]]:
"""Parses and converts a sequence of Azure AI tools into dict-based tools.
Args:
tools: A sequence of tool objects or dictionaries
defining the tools to be parsed. Can be None.
Returns:
list[dict[str, Any]]: A list of dict-based tool definitions.
"""
agent_tools: list[dict[str, Any]] = []
if not tools:
return agent_tools
for tool in tools:
# Handle raw dictionary tools
tool_dict = tool if isinstance(tool, dict) else dict(tool)
tool_type = tool_dict.get("type")
if tool_type == "mcp":
mcp_tool = cast(MCPTool, tool_dict)
result: dict[str, Any] = {
"type": "mcp",
"server_label": mcp_tool.get("server_label", ""),
"server_url": mcp_tool.get("server_url", ""),
}
if description := mcp_tool.get("server_description"):
result["server_description"] = description
if headers := mcp_tool.get("headers"):
result["headers"] = headers
if allowed_tools := mcp_tool.get("allowed_tools"):
result["allowed_tools"] = allowed_tools
if require_approval := mcp_tool.get("require_approval"):
result["require_approval"] = require_approval
if project_connection_id := mcp_tool.get("project_connection_id"):
result["project_connection_id"] = project_connection_id
agent_tools.append(result)
elif tool_type == "code_interpreter":
ci_tool = cast(CodeInterpreterTool, tool_dict)
container = ci_tool.get("container", {})
result = {"type": "code_interpreter"}
if "file_ids" in container:
result["file_ids"] = container["file_ids"]
agent_tools.append(result)
elif tool_type == "file_search":
fs_tool = cast(ProjectsFileSearchTool, tool_dict)
result = {"type": "file_search"}
if "vector_store_ids" in fs_tool:
result["vector_store_ids"] = fs_tool["vector_store_ids"]
if max_results := fs_tool.get("max_num_results"):
result["max_num_results"] = max_results
agent_tools.append(result)
elif tool_type == "web_search_preview":
ws_tool = cast(WebSearchPreviewTool, tool_dict)
result = {"type": "web_search_preview"}
if user_location := ws_tool.get("user_location"):
result["user_location"] = {
"city": user_location.get("city"),
"country": user_location.get("country"),
"region": user_location.get("region"),
"timezone": user_location.get("timezone"),
}
agent_tools.append(result)
else:
agent_tools.append(tool_dict)
return agent_tools
def to_azure_ai_tools(
tools: Sequence[FunctionTool | MutableMapping[str, Any] | Tool] | None,
) -> list[Tool | dict[str, Any]]:
"""Converts Agent Framework tools into Azure AI compatible tools.
Handles FunctionTool instances and passes through SDK Tool types directly.
Args:
tools: A sequence of Agent Framework tool objects, SDK Tool types, or dictionaries
defining the tools to be converted. Can be None.
Returns:
list[Tool | dict[str, Any]]: A list of converted tools compatible with Azure AI.
"""
azure_tools: list[Tool | dict[str, Any]] = []
if not tools:
return azure_tools
for tool in tools:
if isinstance(tool, FunctionTool):
params = tool.parameters()
params["additionalProperties"] = False
azure_tools.append(
AzureFunctionTool(
name=tool.name,
parameters=params,
strict=False,
description=tool.description,
)
)
elif isinstance(tool, Tool):
# Pass through SDK Tool types directly (CodeInterpreterTool, FileSearchTool, etc.)
azure_tools.append(tool)
elif isinstance(tool, MutableMapping):
# Convert mutable mappings into plain dicts for stable typing.
tool_dict: dict[str, Any] = dict(tool)
if tool_dict.get("type") == "mcp":
azure_tools.append(_prepare_mcp_tool_dict_for_azure_ai(tool_dict))
else:
azure_tools.append(tool_dict)
else:
# Pass through any other supported tool objects unchanged.
azure_tools.append(tool)
return azure_tools
def _prepare_mcp_tool_dict_for_azure_ai(tool_dict: dict[str, Any]) -> MCPTool:
"""Convert dict-based MCP tool to Azure AI MCPTool format.
Args:
tool_dict: The dict-based MCP tool configuration.
Returns:
MCPTool: The converted Azure AI MCPTool.
"""
server_label = tool_dict.get("server_label", "")
server_url = tool_dict.get("server_url", "")
mcp: MCPTool = MCPTool(server_label=server_label, server_url=server_url)
if description := tool_dict.get("server_description"):
mcp["server_description"] = description
# Check for project_connection_id
project_connection_id = tool_dict.get("project_connection_id")
if not isinstance(project_connection_id, str):
additional_properties = tool_dict.get("additional_properties")
project_connection_id = (
_extract_project_connection_id(additional_properties) # pyright: ignore[reportUnknownArgumentType]
if isinstance(additional_properties, Mapping)
else None
)
if project_connection_id:
mcp["project_connection_id"] = project_connection_id
elif headers := tool_dict.get("headers"):
mcp["headers"] = headers
if allowed_tools := tool_dict.get("allowed_tools"):
mcp["allowed_tools"] = list(allowed_tools)
if require_approval := tool_dict.get("require_approval"):
mcp["require_approval"] = require_approval
return mcp
def create_text_format_config(
response_format: type[BaseModel] | Mapping[str, Any],
) -> TextResponseFormatJsonSchema | TextResponseFormatJsonObject | TextResponseFormatText:
"""Convert response_format into Azure text format configuration."""
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
schema = response_format.model_json_schema()
# Ensure additionalProperties is explicitly false to satisfy Azure validation
if isinstance(schema, dict):
schema.setdefault("additionalProperties", False)
return TextResponseFormatJsonSchema(
name=response_format.__name__,
schema=schema,
strict=True,
)
if isinstance(response_format, Mapping):
format_config = _convert_response_format(response_format)
format_type = format_config.get("type")
if format_type == "json_schema":
# Ensure schema includes additionalProperties=False to satisfy Azure validation
schema = dict(format_config.get("schema", {})) # type: ignore[assignment]
schema.setdefault("additionalProperties", False)
config_kwargs: dict[str, Any] = {
"name": format_config.get("name") or "response",
"schema": schema,
}
if "strict" in format_config:
config_kwargs["strict"] = format_config["strict"]
if "description" in format_config:
config_kwargs["description"] = format_config["description"]
return TextResponseFormatJsonSchema(**config_kwargs)
if format_type == "json_object":
return TextResponseFormatJsonObject()
if format_type == "text":
return TextResponseFormatText()
raise IntegrationInvalidRequestException("response_format must be a Pydantic model or mapping.")
def _convert_response_format(response_format: Mapping[str, Any]) -> dict[str, Any]:
"""Convert Chat style response_format into Responses text format config."""
if "format" in response_format and isinstance(response_format["format"], Mapping):
return dict(cast("Mapping[str, Any]", response_format["format"]))
format_type = response_format.get("type")
if format_type == "json_schema":
schema_section = response_format.get("json_schema", response_format)
if not isinstance(schema_section, Mapping):
raise IntegrationInvalidRequestException("json_schema response_format must be a mapping.")
schema_section_typed = cast("Mapping[str, Any]", schema_section)
schema: Any = schema_section_typed.get("schema")
if schema is None:
raise IntegrationInvalidRequestException("json_schema response_format requires a schema.")
name: str = str(
schema_section_typed.get("name")
or schema_section_typed.get("title")
or (cast("Mapping[str, Any]", schema).get("title") if isinstance(schema, Mapping) else None)
or "response"
)
format_config: dict[str, Any] = {
"type": "json_schema",
"name": name,
"schema": schema,
}
if "strict" in schema_section:
format_config["strict"] = schema_section["strict"]
if "description" in schema_section and schema_section["description"] is not None:
format_config["description"] = schema_section["description"]
return format_config
if format_type in {"json_object", "text"}:
return {"type": format_type}
# Handle raw JSON schemas (e.g. {"type": "object", "properties": {...}})
# by wrapping them in the expected json_schema envelope.
# Detect by checking for JSON Schema primitive types or known schema keywords.
json_schema_keywords = {"properties", "anyOf", "oneOf", "allOf", "$ref", "$defs"}
json_schema_primitive_types = {"object", "array", "string", "number", "integer", "boolean", "null"}
if format_type in json_schema_primitive_types or (
format_type is None and any(k in response_format for k in json_schema_keywords)
):
schema = dict(response_format)
if schema.get("type") == "object" and "additionalProperties" not in schema:
schema["additionalProperties"] = False
# Pop title from schema since OpenAI strict mode rejects unknown keys;
# use it as the schema name in the envelope instead.
name = str(schema.pop("title", None) or "response")
return {
"type": "json_schema",
"name": name,
"schema": schema,
"strict": True,
}
raise IntegrationInvalidRequestException("Unsupported response_format provided for Azure AI client.")
+3 -3
View File
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0rc5"
version = "1.0.0rc6"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc5",
"agent-framework-openai>=1.0.0rc5",
"agent-framework-core>=1.0.0rc6",
"agent-framework-openai>=1.0.0rc6",
"azure-ai-projects>=2.0.0,<3.0",
"azure-ai-agents>=1.2.0b5,<1.2.0b6",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
@@ -1,61 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from agent_framework import Message
from pytest import fixture
# region: Connector Settings fixtures
@fixture
def exclude_list(request: Any) -> list[str]:
"""Fixture that returns a list of environment variables to exclude."""
return request.param if hasattr(request, "param") else []
@fixture
def override_env_param_dict(request: Any) -> dict[str, str]:
"""Fixture that returns a dict of environment variables to override."""
return request.param if hasattr(request, "param") else {}
# These two fixtures are used for multiple things, also non-connector tests
@fixture()
def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
"""Fixture to set environment variables for AzureOpenAISettings."""
if exclude_list is None:
exclude_list = []
if override_env_param_dict is None:
override_env_param_dict = {}
env_vars = {
"AZURE_OPENAI_ENDPOINT": "https://test-endpoint.com",
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "test_chat_deployment",
"AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME": "test_chat_deployment",
"AZURE_OPENAI_TEXT_DEPLOYMENT_NAME": "test_text_deployment",
"AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME": "test_embedding_deployment",
"AZURE_OPENAI_TEXT_TO_IMAGE_DEPLOYMENT_NAME": "test_text_to_image_deployment",
"AZURE_OPENAI_AUDIO_TO_TEXT_DEPLOYMENT_NAME": "test_audio_to_text_deployment",
"AZURE_OPENAI_TEXT_TO_AUDIO_DEPLOYMENT_NAME": "test_text_to_audio_deployment",
"AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME": "test_realtime_deployment",
"AZURE_OPENAI_API_KEY": "test_api_key",
"AZURE_OPENAI_API_VERSION": "2023-03-15-preview",
"AZURE_OPENAI_BASE_URL": "https://test_text_deployment.test-base-url.com",
"AZURE_OPENAI_TOKEN_ENDPOINT": "https://test-token-endpoint.com",
}
env_vars.update(override_env_param_dict) # type: ignore
for key, value in env_vars.items():
if key in exclude_list:
monkeypatch.delenv(key, raising=False) # type: ignore
continue
monkeypatch.setenv(key, value) # type: ignore
return env_vars
@fixture(scope="function")
def chat_history() -> list[Message]:
return []
@@ -1,409 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Annotated
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import (
SupportsChatGetResponse,
tool,
)
from agent_framework._settings import SecretString
from agent_framework.azure import AzureOpenAIAssistantsClient
from pydantic import Field
def create_test_azure_assistants_client(
mock_async_azure_openai: MagicMock,
deployment_name: str | None = None,
assistant_id: str | None = None,
assistant_name: str | None = None,
thread_id: str | None = None,
should_delete_assistant: bool = False,
) -> AzureOpenAIAssistantsClient:
"""Helper function to create AzureOpenAIAssistantsClient instances for testing."""
client = AzureOpenAIAssistantsClient(
deployment_name=deployment_name or "test_chat_deployment",
assistant_id=assistant_id,
assistant_name=assistant_name,
thread_id=thread_id,
api_key="test-api-key",
endpoint="https://test-endpoint.com",
async_client=mock_async_azure_openai,
)
# Set the _should_delete_assistant flag directly if needed
if should_delete_assistant:
object.__setattr__(client, "_should_delete_assistant", True)
return client
@pytest.fixture
def mock_async_azure_openai() -> MagicMock:
"""Mock AsyncAzureOpenAI client."""
mock_client = MagicMock()
# Mock beta.assistants
mock_client.beta.assistants.create = AsyncMock(return_value=MagicMock(id="test-assistant-id"))
mock_client.beta.assistants.delete = AsyncMock()
# Mock beta.threads
mock_client.beta.threads.create = AsyncMock(return_value=MagicMock(id="test-thread-id"))
mock_client.beta.threads.delete = AsyncMock()
# Mock beta.threads.runs
mock_client.beta.threads.runs.create = AsyncMock(return_value=MagicMock(id="test-run-id"))
mock_client.beta.threads.runs.retrieve = AsyncMock()
mock_client.beta.threads.runs.submit_tool_outputs = AsyncMock()
# Mock beta.threads.messages
mock_client.beta.threads.messages.create = AsyncMock()
mock_client.beta.threads.messages.list = AsyncMock(return_value=MagicMock(data=[]))
return mock_client
def test_azure_assistants_client_init_with_client(mock_async_azure_openai: MagicMock) -> None:
"""Test AzureOpenAIAssistantsClient initialization with existing client."""
client = create_test_azure_assistants_client(
mock_async_azure_openai,
deployment_name="test_chat_deployment",
assistant_id="existing-assistant-id",
thread_id="test-thread-id",
)
assert client.client is mock_async_azure_openai
assert client.model == "test_chat_deployment"
assert client.assistant_id == "existing-assistant-id"
assert client.thread_id == "test-thread-id"
assert not client._should_delete_assistant # type: ignore
assert isinstance(client, SupportsChatGetResponse)
def test_azure_assistants_client_init_auto_create_client(
azure_openai_unit_test_env: dict[str, str],
mock_async_azure_openai: MagicMock,
) -> None:
"""Test AzureOpenAIAssistantsClient initialization with auto-created client."""
client = AzureOpenAIAssistantsClient(
deployment_name=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
assistant_name="TestAssistant",
api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
async_client=mock_async_azure_openai,
)
assert client.client is mock_async_azure_openai
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
assert client.assistant_id is None
assert client.assistant_name == "TestAssistant"
assert not client._should_delete_assistant # type: ignore
def test_azure_assistants_client_init_validation_fail() -> None:
"""Test AzureOpenAIAssistantsClient initialization with validation failure."""
with pytest.raises(ValueError):
# Force failure by providing invalid deployment name type - this should cause validation to fail
AzureOpenAIAssistantsClient(deployment_name=123, api_key="valid-key") # type: ignore
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]], indirect=True)
def test_azure_assistants_client_init_missing_deployment_name(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test AzureOpenAIAssistantsClient initialization with missing deployment name."""
with pytest.raises(ValueError):
AzureOpenAIAssistantsClient(api_key=azure_openai_unit_test_env.get("AZURE_OPENAI_API_KEY", "test-key"))
def test_azure_assistants_client_init_with_default_headers(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test AzureOpenAIAssistantsClient initialization with default headers."""
default_headers = {"X-Unit-Test": "test-guid"}
client = AzureOpenAIAssistantsClient(
deployment_name="test_chat_deployment",
api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
default_headers=default_headers,
)
assert client.model == "test_chat_deployment"
assert isinstance(client, SupportsChatGetResponse)
# Assert that the default header we added is present in the client's default headers
for key, value in default_headers.items():
assert key in client.client.default_headers
assert client.client.default_headers[key] == value
async def test_azure_assistants_client_get_assistant_id_or_create_existing_assistant(
mock_async_azure_openai: MagicMock,
) -> None:
"""Test _get_assistant_id_or_create when assistant_id is already provided."""
client = create_test_azure_assistants_client(mock_async_azure_openai, assistant_id="existing-assistant-id")
assistant_id = await client._get_assistant_id_or_create() # type: ignore
assert assistant_id == "existing-assistant-id"
assert not client._should_delete_assistant # type: ignore
mock_async_azure_openai.beta.assistants.create.assert_not_called()
async def test_azure_assistants_client_get_assistant_id_or_create_create_new(
mock_async_azure_openai: MagicMock,
) -> None:
"""Test _get_assistant_id_or_create when creating a new assistant."""
client = create_test_azure_assistants_client(
mock_async_azure_openai, deployment_name="test_chat_deployment", assistant_name="TestAssistant"
)
assistant_id = await client._get_assistant_id_or_create() # type: ignore
assert assistant_id == "test-assistant-id"
assert client._should_delete_assistant # type: ignore
mock_async_azure_openai.beta.assistants.create.assert_called_once()
async def test_azure_assistants_client_aclose_should_not_delete(
mock_async_azure_openai: MagicMock,
) -> None:
"""Test close when assistant should not be deleted."""
client = create_test_azure_assistants_client(
mock_async_azure_openai, assistant_id="assistant-to-keep", should_delete_assistant=False
)
await client.close() # type: ignore
# Verify assistant deletion was not called
mock_async_azure_openai.beta.assistants.delete.assert_not_called()
assert not client._should_delete_assistant # type: ignore
async def test_azure_assistants_client_aclose_should_delete(mock_async_azure_openai: MagicMock) -> None:
"""Test close method calls cleanup."""
client = create_test_azure_assistants_client(
mock_async_azure_openai, assistant_id="assistant-to-delete", should_delete_assistant=True
)
await client.close()
# Verify assistant deletion was called
mock_async_azure_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete")
assert not client._should_delete_assistant # type: ignore
async def test_azure_assistants_client_async_context_manager(mock_async_azure_openai: MagicMock) -> None:
"""Test async context manager functionality."""
client = create_test_azure_assistants_client(
mock_async_azure_openai, assistant_id="assistant-to-delete", should_delete_assistant=True
)
# Test context manager
async with client:
pass # Just test that we can enter and exit
# Verify cleanup was called on exit
mock_async_azure_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete")
def test_azure_assistants_client_serialize(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test serialization of AzureOpenAIAssistantsClient."""
default_headers = {"X-Unit-Test": "test-guid"}
# Test basic initialization and to_dict
client = AzureOpenAIAssistantsClient(
deployment_name="test_chat_deployment",
assistant_id="test-assistant-id",
assistant_name="TestAssistant",
thread_id="test-thread-id",
api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
default_headers=default_headers,
)
dumped_settings = client.to_dict()
assert dumped_settings["model"] == "test_chat_deployment"
assert dumped_settings["assistant_id"] == "test-assistant-id"
assert dumped_settings["assistant_name"] == "TestAssistant"
assert dumped_settings["thread_id"] == "test-thread-id"
# Assert that the default header we added is present in the dumped_settings default headers
for key, value in default_headers.items():
assert key in dumped_settings["default_headers"]
assert dumped_settings["default_headers"][key] == value
# Assert that the 'User-Agent' header is not present in the dumped_settings default headers
assert "User-Agent" not in dumped_settings["default_headers"]
@tool(approval_mode="never_require")
def get_weather(
location: Annotated[str, Field(description="The location to get the weather for.")],
) -> str:
"""Get the weather for a given location."""
return f"The weather in {location} is sunny with a high of 25°C."
def test_azure_assistants_client_entra_id_authentication() -> None:
"""Test credential authentication path with sync credential."""
mock_credential = MagicMock()
mock_provider = MagicMock(return_value="token-string")
with (
patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings,
patch(
"agent_framework_azure_ai._deprecated_azure_openai.resolve_credential_to_token_provider",
return_value=mock_provider,
) as mock_resolve,
patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
mock_load_settings.return_value = {
"chat_deployment_name": "test-deployment",
"responses_deployment_name": None,
"api_key": None,
"token_endpoint": "https://cognitiveservices.azure.com/.default",
"api_version": "2024-05-01-preview",
"endpoint": "https://test-endpoint.openai.azure.com",
"base_url": None,
}
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
endpoint="https://test-endpoint.openai.azure.com",
credential=mock_credential,
token_endpoint="https://cognitiveservices.azure.com/.default",
)
# Verify credential was resolved to a token provider
mock_resolve.assert_called_once_with(mock_credential, "https://cognitiveservices.azure.com/.default")
# Verify client was created with the token provider
mock_azure_client.assert_called_once()
call_args = mock_azure_client.call_args[1]
assert call_args["azure_ad_token_provider"] is mock_provider
assert client is not None
assert isinstance(client, AzureOpenAIAssistantsClient)
def test_azure_assistants_client_no_authentication_error() -> None:
"""Test authentication validation error when no auth provided."""
with patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings:
mock_load_settings.return_value = {
"chat_deployment_name": "test-deployment",
"responses_deployment_name": None,
"api_key": None,
"token_endpoint": None,
"api_version": "2024-05-01-preview",
"endpoint": "https://test-endpoint.openai.azure.com",
"base_url": None,
}
# Test missing authentication raises error
with pytest.raises(ValueError, match="api_key, credential, or a client"):
AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
endpoint="https://test-endpoint.openai.azure.com",
# No authentication provided at all
)
def test_azure_assistants_client_callable_credential() -> None:
"""Test callable token provider as credential."""
mock_provider = MagicMock(return_value="my-token")
with (
patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings,
patch(
"agent_framework_azure_ai._deprecated_azure_openai.resolve_credential_to_token_provider",
return_value=mock_provider,
),
patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
mock_load_settings.return_value = {
"chat_deployment_name": "test-deployment",
"responses_deployment_name": None,
"api_key": None,
"token_endpoint": "https://cognitiveservices.azure.com/.default",
"api_version": "2024-05-01-preview",
"endpoint": "https://test-endpoint.openai.azure.com",
"base_url": None,
}
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
endpoint="https://test-endpoint.openai.azure.com",
credential=mock_provider,
token_endpoint="https://cognitiveservices.azure.com/.default",
)
# Verify client was created with the token provider
mock_azure_client.assert_called_once()
call_args = mock_azure_client.call_args[1]
assert call_args["azure_ad_token_provider"] is mock_provider
assert client is not None
assert isinstance(client, AzureOpenAIAssistantsClient)
def test_azure_assistants_client_base_url_configuration() -> None:
"""Test base_url client parameter path."""
with (
patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings,
patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
mock_load_settings.return_value = {
"chat_deployment_name": "test-deployment",
"responses_deployment_name": None,
"api_key": SecretString("test-api-key"),
"token_endpoint": None,
"api_version": "2024-05-01-preview",
"endpoint": None,
"base_url": "https://custom-base-url.com",
}
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment", api_key="test-api-key", base_url="https://custom-base-url.com"
)
# base_url path
mock_azure_client.assert_called_once()
call_args = mock_azure_client.call_args[1]
assert call_args["base_url"] == "https://custom-base-url.com"
assert "azure_endpoint" not in call_args
assert client is not None
assert isinstance(client, AzureOpenAIAssistantsClient)
def test_azure_assistants_client_azure_endpoint_configuration() -> None:
"""Test azure_endpoint client parameter path."""
with (
patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings,
patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
mock_load_settings.return_value = {
"chat_deployment_name": "test-deployment",
"responses_deployment_name": None,
"api_key": SecretString("test-api-key"),
"token_endpoint": None,
"api_version": "2024-05-01-preview",
"endpoint": "https://test-endpoint.openai.azure.com",
"base_url": None,
}
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
api_key="test-api-key",
endpoint="https://test-endpoint.openai.azure.com",
)
# azure_endpoint path
mock_azure_client.assert_called_once()
call_args = mock_azure_client.call_args[1]
assert call_args["azure_endpoint"] == "https://test-endpoint.openai.azure.com"
assert "base_url" not in call_args
assert client is not None
assert isinstance(client, AzureOpenAIAssistantsClient)
File diff suppressed because it is too large Load Diff
@@ -1,219 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import os
from functools import wraps
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from agent_framework.azure import AzureOpenAIEmbeddingClient
from agent_framework.openai import OpenAIEmbeddingOptions
from azure.identity.aio import AzureCliCredential
from openai.types import CreateEmbeddingResponse
from openai.types import Embedding as OpenAIEmbedding
from openai.types.create_embedding_response import Usage
pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIEmbeddingClient is deprecated\\..*:DeprecationWarning")
def _make_openai_response(
embeddings: list[list[float]],
model: str = "text-embedding-3-small",
prompt_tokens: int = 5,
total_tokens: int = 5,
) -> CreateEmbeddingResponse:
"""Helper to create a mock OpenAI embeddings response."""
data = [OpenAIEmbedding(embedding=emb, index=i, object="embedding") for i, emb in enumerate(embeddings)]
return CreateEmbeddingResponse(
data=data,
model=model,
object="list",
usage=Usage(prompt_tokens=prompt_tokens, total_tokens=total_tokens),
)
@pytest.fixture
def azure_embedding_unit_test_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Clear ambient Azure OpenAI embedding env vars for deterministic unit tests."""
for key in (
"AZURE_OPENAI_ENDPOINT",
"AZURE_OPENAI_API_KEY",
"AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME",
"AZURE_OPENAI_BASE_URL",
"AZURE_OPENAI_TOKEN_ENDPOINT",
):
monkeypatch.delenv(key, raising=False)
def test_azure_construction_with_deployment_name(azure_embedding_unit_test_env: None) -> None:
client = AzureOpenAIEmbeddingClient(
deployment_name="text-embedding-3-small",
api_key="test-key",
endpoint="https://test.openai.azure.com/",
)
assert client.model == "text-embedding-3-small"
def test_azure_construction_with_existing_client(azure_embedding_unit_test_env: None) -> None:
mock_client = MagicMock()
client = AzureOpenAIEmbeddingClient(
deployment_name="my-deployment",
async_client=mock_client,
)
assert client.model == "my-deployment"
assert client.client is mock_client
def test_azure_construction_missing_deployment_name_raises(azure_embedding_unit_test_env: None) -> None:
with pytest.raises(ValueError, match="deployment name is required"):
AzureOpenAIEmbeddingClient(
api_key="test-key",
endpoint="https://test.openai.azure.com/",
)
def test_azure_construction_missing_credentials_raises(azure_embedding_unit_test_env: None) -> None:
with pytest.raises(ValueError, match="api_key, credential, or a client"):
AzureOpenAIEmbeddingClient(
deployment_name="test",
endpoint="https://test.openai.azure.com/",
)
async def test_azure_get_embeddings(azure_embedding_unit_test_env: None) -> None:
mock_response = _make_openai_response(
embeddings=[[0.1, 0.2]],
)
mock_async_client = MagicMock()
mock_async_client.embeddings = MagicMock()
mock_async_client.embeddings.create = AsyncMock(return_value=mock_response)
client = AzureOpenAIEmbeddingClient(
deployment_name="text-embedding-3-small",
async_client=mock_async_client,
)
result = await client.get_embeddings(["hello"])
assert len(result) == 1
assert result[0].vector == [0.1, 0.2]
def test_azure_otel_provider_name(azure_embedding_unit_test_env: None) -> None:
mock_client = MagicMock()
client = AzureOpenAIEmbeddingClient(
deployment_name="test",
async_client=mock_client,
)
assert client.OTEL_PROVIDER_NAME == "azure.ai.openai"
skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif(
os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com")
or (
os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "") == ""
and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == ""
),
reason="No Azure OpenAI endpoint or embedding deployment provided; skipping integration tests.",
)
def _with_azure_openai_debug() -> Any:
def decorator(func: Any) -> Any:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await func(*args, **kwargs)
except Exception as exc:
model = os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.getenv(
"AZURE_OPENAI_DEPLOYMENT_NAME", "<unset>"
)
api_version = os.getenv("AZURE_OPENAI_API_VERSION", "<unset>")
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "<unset>")
debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}"
if hasattr(exc, "add_note"):
exc.add_note(debug_message)
elif exc.args:
exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:])
else:
exc.args = (debug_message,)
raise
return wrapper
return decorator
def _get_azure_embedding_deployment_name() -> str:
return os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") or os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"]
def _create_azure_openai_embedding_client(
*,
api_key: str | None = None,
credential: AzureCliCredential | None = None,
) -> AzureOpenAIEmbeddingClient:
resolved_api_key = (
api_key if api_key is not None else None if credential is not None else os.getenv("AZURE_OPENAI_API_KEY")
)
return AzureOpenAIEmbeddingClient(
deployment_name=_get_azure_embedding_deployment_name(),
api_key=resolved_api_key,
endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
credential=credential,
)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_openai_integration_tests_disabled
@_with_azure_openai_debug()
async def test_integration_azure_openai_get_embeddings() -> None:
"""End-to-end test of Azure OpenAI embedding generation."""
async with AzureCliCredential() as credential:
client = _create_azure_openai_embedding_client(credential=credential)
result = await client.get_embeddings(["hello world"])
assert len(result) == 1
assert isinstance(result[0].vector, list)
assert len(result[0].vector) > 0
assert all(isinstance(v, float) for v in result[0].vector)
assert result[0].model_id is not None
assert result.usage is not None
assert result.usage["input_token_count"] > 0
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_openai_integration_tests_disabled
@_with_azure_openai_debug()
async def test_integration_azure_openai_get_embeddings_multiple() -> None:
"""Test Azure OpenAI embedding generation for multiple inputs."""
async with AzureCliCredential() as credential:
client = _create_azure_openai_embedding_client(credential=credential)
result = await client.get_embeddings(["hello", "world", "test"])
assert len(result) == 3
dims = [len(e.vector) for e in result]
assert all(d == dims[0] for d in dims)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_openai_integration_tests_disabled
@_with_azure_openai_debug()
async def test_integration_azure_openai_get_embeddings_with_dimensions() -> None:
"""Test Azure OpenAI embedding generation with custom dimensions."""
async with AzureCliCredential() as credential:
client = _create_azure_openai_embedding_client(credential=credential)
options: OpenAIEmbeddingOptions = {"dimensions": 256}
result = await client.get_embeddings(["hello world"], options=options)
assert len(result) == 1
assert len(result[0].vector) == 256
@@ -1,542 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import logging
import os
from functools import wraps
from pathlib import Path
from typing import Annotated, Any
import pytest
from agent_framework import (
Agent,
AgentResponse,
ChatResponse,
Content,
Message,
SupportsChatGetResponse,
tool,
)
from agent_framework.azure import AzureOpenAIResponsesClient
from azure.identity import AzureCliCredential
from pydantic import BaseModel
from pytest import param
pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIResponsesClient is deprecated\\..*:DeprecationWarning")
skip_if_azure_integration_tests_disabled = pytest.mark.skipif(
os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"),
reason="No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests.",
)
def _with_azure_openai_debug() -> Any:
def decorator(func: Any) -> Any:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await func(*args, **kwargs)
except Exception as exc:
model = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") or os.getenv(
"AZURE_OPENAI_DEPLOYMENT_NAME", "<unset>"
)
api_version = os.getenv("AZURE_OPENAI_API_VERSION", "<unset>")
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "<unset>")
debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}"
if hasattr(exc, "add_note"):
exc.add_note(debug_message)
elif exc.args:
exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:])
else:
exc.args = (debug_message,)
raise
return wrapper
return decorator
logger = logging.getLogger(__name__)
class OutputStruct(BaseModel):
"""A structured output for testing purposes."""
location: str
weather: str
@tool(approval_mode="never_require")
async def get_weather(location: Annotated[str, "The location as a city name"]) -> str:
"""Get the current weather in a given location."""
# Implementation of the tool to get weather
return f"The weather in {location} is sunny and 72°F."
async def create_vector_store(
client: AzureOpenAIResponsesClient,
) -> tuple[str, Content]:
"""Create a vector store with sample documents for testing."""
file = await client.client.files.create(
file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."),
purpose="assistants",
)
vector_store = await client.client.vector_stores.create(
name="knowledge_base",
expires_after={"anchor": "last_active_at", "days": 1},
)
result = await client.client.vector_stores.files.create_and_poll(vector_store_id=vector_store.id, file_id=file.id)
if result.last_error is not None:
raise Exception(f"Vector store file processing failed with status: {result.last_error.message}")
return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id)
async def delete_vector_store(client: AzureOpenAIResponsesClient, file_id: str, vector_store_id: str) -> None:
"""Delete the vector store after tests."""
await client.client.vector_stores.delete(vector_store_id=vector_store_id)
await client.client.files.delete(file_id=file_id)
def test_init(azure_openai_unit_test_env: dict[str, str]) -> None:
# Test successful initialization
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert azure_responses_client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
assert isinstance(azure_responses_client, SupportsChatGetResponse)
def test_init_validation_fail() -> None:
# Test successful initialization
with pytest.raises(ValueError):
AzureOpenAIResponsesClient(api_key="34523", deployment_name={"test": "dict"}) # type: ignore
def test_init_model_id_constructor(azure_openai_unit_test_env: dict[str, str]) -> None:
# Test successful initialization
model_id = "test_model_id"
azure_responses_client = AzureOpenAIResponsesClient(deployment_name=model_id)
assert azure_responses_client.model == model_id
assert isinstance(azure_responses_client, SupportsChatGetResponse)
def test_init_model_id_kwarg(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test that model_id kwarg correctly sets the deployment name (issue #4299)."""
azure_responses_client = AzureOpenAIResponsesClient(model_id="gpt-4o")
assert azure_responses_client.model == "gpt-4o"
assert isinstance(azure_responses_client, SupportsChatGetResponse)
def test_init_model_id_kwarg_does_not_override_deployment_name(
azure_openai_unit_test_env: dict[str, str],
) -> None:
"""Test that deployment_name takes precedence over model_id kwarg (issue #4299)."""
azure_responses_client = AzureOpenAIResponsesClient(deployment_name="my-deployment", model_id="gpt-4o")
assert azure_responses_client.model == "my-deployment"
assert isinstance(azure_responses_client, SupportsChatGetResponse)
def test_init_model_id_kwarg_none(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test that model_id=None does not override the env-var deployment name."""
azure_responses_client = AzureOpenAIResponsesClient(model_id=None)
assert azure_responses_client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) -> None:
default_headers = {"X-Unit-Test": "test-guid"}
# Test successful initialization
azure_responses_client = AzureOpenAIResponsesClient(
default_headers=default_headers,
)
assert azure_responses_client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
assert isinstance(azure_responses_client, SupportsChatGetResponse)
# Assert that the default header we added is present in the client's default headers
for key, value in default_headers.items():
assert key in azure_responses_client.client.default_headers
assert azure_responses_client.client.default_headers[key] == value
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]], indirect=True)
def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ValueError):
AzureOpenAIResponsesClient()
def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None:
default_headers = {"X-Unit-Test": "test-guid"}
settings = {
"deployment_name": azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"],
"api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
"default_headers": default_headers,
}
azure_responses_client = AzureOpenAIResponsesClient.from_dict(settings)
dumped_settings = azure_responses_client.to_dict()
assert dumped_settings["deployment_name"] == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
assert "api_key" not in dumped_settings
# Assert that the default header we added is present in the dumped_settings default headers
for key, value in default_headers.items():
assert key in dumped_settings["default_headers"]
assert dumped_settings["default_headers"][key] == value
# Assert that the 'User-Agent' header is not present in the dumped_settings default headers
assert "User-Agent" not in dumped_settings["default_headers"]
# region Integration Tests
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_integration_tests_disabled
@pytest.mark.parametrize(
"option_name,option_value,needs_validation",
[
# Simple ChatOptions - just verify they don't fail
param("max_tokens", 500, False, id="max_tokens"),
param("seed", 123, False, id="seed"),
param("user", "test-user-id", False, id="user"),
param("metadata", {"test_key": "test_value"}, False, id="metadata"),
param("frequency_penalty", 0.5, False, id="frequency_penalty"),
param("presence_penalty", 0.3, False, id="presence_penalty"),
param("stop", ["END"], False, id="stop"),
param("allow_multiple_tool_calls", True, False, id="allow_multiple_tool_calls"),
param("tool_choice", "none", True, id="tool_choice_none"),
# OpenAIResponsesOptions - just verify they don't fail
param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"),
param("truncation", "auto", False, id="truncation"),
param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"),
param("max_tool_calls", 3, False, id="max_tool_calls"),
# Complex options requiring output validation
param("tools", [get_weather], True, id="tools_function"),
param("tool_choice", "auto", True, id="tool_choice_auto"),
param(
"tool_choice",
{"mode": "required", "required_function_name": "get_weather"},
True,
id="tool_choice_required",
),
param("response_format", OutputStruct, True, id="response_format_pydantic"),
param(
"response_format",
{
"type": "json_schema",
"json_schema": {
"name": "WeatherDigest",
"strict": True,
"schema": {
"title": "WeatherDigest",
"type": "object",
"properties": {
"location": {"type": "string"},
"conditions": {"type": "string"},
"temperature_c": {"type": "number"},
"advisory": {"type": "string"},
},
"required": [
"location",
"conditions",
"temperature_c",
"advisory",
],
"additionalProperties": False,
},
},
},
True,
id="response_format_runtime_json_schema",
),
],
)
@_with_azure_openai_debug()
async def test_integration_options(
option_name: str,
option_value: Any,
needs_validation: bool,
) -> None:
"""Parametrized test covering all ChatOptions and OpenAIResponsesOptions.
Tests both streaming and non-streaming modes for each option to ensure
they don't cause failures. Options marked with needs_validation also
check that the feature actually works correctly.
"""
client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
# Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response
client.function_invocation_configuration["max_iterations"] = 2
# Prepare test message
if option_name == "tools" or option_name == "tool_choice":
# Use weather-related prompt for tool tests
messages = [Message(role="user", text="What is the weather in Seattle?")]
elif option_name == "response_format":
# Use prompt that works well with structured output
messages = [
Message(role="user", text="The weather in Seattle is sunny"),
Message(role="user", text="What is the weather in Seattle?"),
]
else:
# Generic prompt for simple options
messages = [Message(role="user", text="Say 'Hello World' briefly.")]
# Build options dict
options: dict[str, Any] = {option_name: option_value}
# Add tools if testing tool_choice to avoid errors
if option_name == "tool_choice":
options["tools"] = [get_weather]
# Test streaming mode
response = await client.get_response(messages=messages, stream=True, options=options).get_final_response()
assert response is not None
assert isinstance(response, ChatResponse)
assert response.text is not None, f"No text in response for option '{option_name}'"
assert len(response.text) > 0, f"Empty response for option '{option_name}'"
# Validate based on option type
if needs_validation:
if option_name == "tools" or option_name == "tool_choice":
# Should have called the weather function
text = response.text.lower()
assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}"
elif option_name == "response_format":
if option_value == OutputStruct:
# Should have structured output
assert response.value is not None, "No structured output"
assert isinstance(response.value, OutputStruct)
assert "seattle" in response.value.location.lower()
else:
# Runtime JSON schema
assert response.value is None, "No structured output, can't parse any json."
response_value = json.loads(response.text)
assert isinstance(response_value, dict)
assert "location" in response_value
assert "seattle" in response_value["location"].lower()
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_integration_tests_disabled
@_with_azure_openai_debug()
async def test_integration_web_search() -> None:
client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
response = await client.get_response(
messages=[
Message(
role="user",
text="What is the current weather? Do not ask for my current location.",
)
],
options={
"tools": [
AzureOpenAIResponsesClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"})
]
},
stream=True,
).get_final_response()
assert response.text is not None
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_integration_tests_disabled
@_with_azure_openai_debug()
async def test_integration_client_file_search() -> None:
"""Test Azure responses client with file search tool."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
file_id, vector_store = await create_vector_store(azure_responses_client)
try:
# Test that the client will use the file search tool
response = await azure_responses_client.get_response(
messages=[
Message(
role="user",
text="What is the weather today? Do a file search to find the answer.",
)
],
options={
"tools": [
AzureOpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
],
"tool_choice": "auto",
},
)
assert "sunny" in response.text.lower()
assert "75" in response.text
finally:
await delete_vector_store(azure_responses_client, file_id, vector_store.vector_store_id)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_integration_tests_disabled
@_with_azure_openai_debug()
async def test_integration_client_file_search_streaming() -> None:
"""Test Azure responses client with file search tool and streaming."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
file_id, vector_store = await create_vector_store(azure_responses_client)
# Test that the client will use the file search tool
try:
response_stream = azure_responses_client.get_response(
messages=[
Message(
role="user",
text="What is the weather today? Do a file search to find the answer.",
)
],
stream=True,
options={
"tools": [
AzureOpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
],
"tool_choice": "auto",
},
)
full_response = await response_stream.get_final_response()
assert "sunny" in full_response.text.lower()
assert "75" in full_response.text
finally:
await delete_vector_store(azure_responses_client, file_id, vector_store.vector_store_id)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_integration_tests_disabled
@_with_azure_openai_debug()
async def test_integration_client_agent_hosted_mcp_tool() -> None:
"""Integration test for MCP tool with Azure Response Agent using Microsoft Learn MCP."""
client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
response = await client.get_response(
messages=[Message(role="user", text="How to create an Azure storage account using az cli?")],
options={
# this needs to be high enough to handle the full MCP tool response.
"max_tokens": 5000,
"tools": AzureOpenAIResponsesClient.get_mcp_tool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
),
},
)
assert isinstance(response, ChatResponse)
# MCP server may return empty response intermittently - skip test rather than fail
if not response.text:
pytest.skip("MCP server returned empty response - service-side issue")
# Should contain Azure-related content since it's asking about Azure CLI
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_integration_tests_disabled
@_with_azure_openai_debug()
async def test_integration_client_agent_hosted_code_interpreter_tool():
"""Test Azure Responses Client agent with code interpreter tool."""
client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
response = await client.get_response(
messages=[
Message(
role="user",
text="Calculate the sum of numbers from 1 to 10 using Python code.",
)
],
options={
"tools": [AzureOpenAIResponsesClient.get_code_interpreter_tool()],
},
)
# Should contain calculation result (sum of 1-10 = 55) or code execution content
contains_relevant_content = any(
term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"]
)
assert contains_relevant_content or len(response.text.strip()) > 10
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_integration_tests_disabled
@_with_azure_openai_debug()
async def test_integration_client_agent_existing_session():
"""Test Azure Responses Client agent with existing session to continue conversations across agent instances."""
# First conversation - capture the session
preserved_session = None
async with Agent(
client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as first_agent:
# Start a conversation and capture the session
session = first_agent.create_session()
first_response = await first_agent.run(
"My hobby is photography. Remember this.", session=session, options={"store": True}
)
assert isinstance(first_response, AgentResponse)
assert first_response.text is not None
# Preserve the session for reuse
preserved_session = session
# Second conversation - reuse the session in a new agent instance
if preserved_session:
async with Agent(
client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as second_agent:
# Reuse the preserved session
second_response = await second_agent.run(
"What is my hobby?", session=preserved_session, options={"store": True}
)
assert isinstance(second_response, AgentResponse)
assert second_response.text is not None
assert "photography" in second_response.text.lower()
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_azure_integration_tests_disabled
@_with_azure_openai_debug()
async def test_azure_openai_responses_client_tool_rich_content_image() -> None:
"""Test that Azure OpenAI Responses client can handle tool results containing images."""
image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg"
image_bytes = image_path.read_bytes()
@tool(approval_mode="never_require")
def get_test_image() -> Content:
"""Return a test image for analysis."""
return Content.from_data(data=image_bytes, media_type="image/jpeg")
client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
client.function_invocation_configuration["max_iterations"] = 2
for streaming in [False, True]:
messages = [
Message(
role="user",
text="Call the get_test_image tool and describe what you see.",
)
]
options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"}
if streaming:
response = await client.get_response(messages=messages, stream=True, options=options).get_final_response()
else:
response = await client.get_response(messages=messages, options=options)
assert response is not None
assert isinstance(response, ChatResponse)
assert response.text is not None
assert len(response.text) > 0
# sample_image.jpg contains a photo of a house; the model should mention it.
assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}"
@@ -1,131 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import warnings
from unittest.mock import MagicMock
import pytest
from agent_framework import SupportsChatGetResponse
warnings.filterwarnings(
"ignore",
message=r"RawAzureAIClient is deprecated\..*",
category=DeprecationWarning,
)
from agent_framework.azure import AzureOpenAIResponsesClient # noqa: E402
from azure.identity import AzureCliCredential # noqa: E402
pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIResponsesClient is deprecated\\..*:DeprecationWarning")
def test_init_with_project_client(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test initialization with an existing AIProjectClient."""
from unittest.mock import patch
from openai import AsyncOpenAI
# Create a mock AIProjectClient that returns a mock AsyncOpenAI client
mock_openai_client = MagicMock(spec=AsyncOpenAI)
mock_openai_client.default_headers = {}
mock_project_client = MagicMock()
mock_project_client.get_openai_client.return_value = mock_openai_client
with patch(
"agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project",
return_value=mock_openai_client,
):
azure_responses_client = AzureOpenAIResponsesClient(
project_client=mock_project_client,
deployment_name="gpt-4o",
)
assert azure_responses_client.model == "gpt-4o"
assert azure_responses_client.client is mock_openai_client
assert isinstance(azure_responses_client, SupportsChatGetResponse)
def test_init_with_project_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test initialization with a project endpoint and credential."""
from unittest.mock import patch
from openai import AsyncOpenAI
mock_openai_client = MagicMock(spec=AsyncOpenAI)
mock_openai_client.default_headers = {}
with patch(
"agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project",
return_value=mock_openai_client,
):
azure_responses_client = AzureOpenAIResponsesClient(
project_endpoint="https://test-project.services.ai.azure.com",
deployment_name="gpt-4o",
credential=AzureCliCredential(),
)
assert azure_responses_client.model == "gpt-4o"
assert azure_responses_client.client is mock_openai_client
assert isinstance(azure_responses_client, SupportsChatGetResponse)
def test_create_client_from_project_with_project_client() -> None:
"""Test _create_client_from_project with an existing project client."""
from openai import AsyncOpenAI
mock_openai_client = MagicMock(spec=AsyncOpenAI)
mock_project_client = MagicMock()
mock_project_client.get_openai_client.return_value = mock_openai_client
result = AzureOpenAIResponsesClient._create_client_from_project(
project_client=mock_project_client,
project_endpoint=None,
credential=None,
)
assert result is mock_openai_client
mock_project_client.get_openai_client.assert_called_once()
def test_create_client_from_project_with_endpoint() -> None:
"""Test _create_client_from_project with a project endpoint."""
from unittest.mock import patch
from openai import AsyncOpenAI
mock_openai_client = MagicMock(spec=AsyncOpenAI)
mock_credential = MagicMock()
with patch("agent_framework_azure_ai._deprecated_azure_openai.AIProjectClient") as MockAIProjectClient:
mock_instance = MockAIProjectClient.return_value
mock_instance.get_openai_client.return_value = mock_openai_client
result = AzureOpenAIResponsesClient._create_client_from_project(
project_client=None,
project_endpoint="https://test-project.services.ai.azure.com",
credential=mock_credential,
)
assert result is mock_openai_client
MockAIProjectClient.assert_called_once()
mock_instance.get_openai_client.assert_called_once()
def test_create_client_from_project_missing_endpoint() -> None:
"""Test _create_client_from_project raises error when endpoint is missing."""
with pytest.raises(ValueError, match="project endpoint is required"):
AzureOpenAIResponsesClient._create_client_from_project(
project_client=None,
project_endpoint=None,
credential=MagicMock(),
)
def test_create_client_from_project_missing_credential() -> None:
"""Test _create_client_from_project raises error when credential is missing."""
with pytest.raises(ValueError, match="credential is required"):
AzureOpenAIResponsesClient._create_client_from_project(
project_client=None,
project_endpoint="https://test-project.services.ai.azure.com",
credential=None,
)
@@ -1,773 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import (
Agent,
tool,
)
from azure.ai.agents.models import (
Agent as AzureAgent,
)
from azure.ai.agents.models import (
CodeInterpreterToolDefinition,
)
from pydantic import BaseModel
from agent_framework_azure_ai import (
AzureAIAgentClient,
AzureAIAgentsProvider,
AzureAISettings,
)
from agent_framework_azure_ai._shared import (
from_azure_ai_agent_tools,
to_azure_ai_agent_tools,
)
skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif(
os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/"),
reason="No real AZURE_AI_PROJECT_ENDPOINT provided; skipping integration tests.",
)
# region Provider Initialization Tests
def test_provider_init_with_agents_client(mock_agents_client: MagicMock) -> None:
"""Test AzureAIAgentsProvider initialization with existing AgentsClient."""
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
assert provider._agents_client is mock_agents_client # type: ignore
assert provider._should_close_client is False # type: ignore
def test_provider_init_with_credential(
azure_ai_unit_test_env: dict[str, str],
mock_azure_credential: MagicMock,
) -> None:
"""Test AzureAIAgentsProvider initialization with credential."""
with patch("agent_framework_azure_ai._agent_provider.AgentsClient") as mock_client_class:
mock_client_instance = MagicMock()
mock_client_class.return_value = mock_client_instance
provider = AzureAIAgentsProvider(credential=mock_azure_credential)
mock_client_class.assert_called_once()
assert provider._agents_client is mock_client_instance # type: ignore
assert provider._should_close_client is True # type: ignore
def test_provider_init_with_explicit_endpoint(mock_azure_credential: MagicMock) -> None:
"""Test AzureAIAgentsProvider initialization with explicit endpoint."""
with patch("agent_framework_azure_ai._agent_provider.AgentsClient") as mock_client_class:
mock_client_instance = MagicMock()
mock_client_class.return_value = mock_client_instance
provider = AzureAIAgentsProvider(
project_endpoint="https://custom-endpoint.com/",
credential=mock_azure_credential,
)
mock_client_class.assert_called_once()
call_kwargs = mock_client_class.call_args.kwargs
assert call_kwargs["endpoint"] == "https://custom-endpoint.com/"
assert provider._should_close_client is True # type: ignore
def test_provider_init_missing_endpoint_raises(
mock_azure_credential: MagicMock,
) -> None:
"""Test AzureAIAgentsProvider raises error when endpoint is missing."""
# Mock load_settings to return a dict with None for project_endpoint
with patch("agent_framework_azure_ai._agent_provider.load_settings") as mock_load_settings:
mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"}
with pytest.raises(ValueError) as exc_info:
AzureAIAgentsProvider(credential=mock_azure_credential)
assert "project endpoint is required" in str(exc_info.value).lower()
def test_provider_init_missing_credential_raises(azure_ai_unit_test_env: dict[str, str]) -> None:
"""Test AzureAIAgentsProvider raises error when credential is missing."""
with pytest.raises(ValueError) as exc_info:
AzureAIAgentsProvider()
assert "credential is required" in str(exc_info.value).lower()
# endregion
# region Context Manager Tests
async def test_provider_context_manager_closes_client(mock_agents_client: MagicMock) -> None:
"""Test that context manager closes client when it was created by provider."""
with patch("agent_framework_azure_ai._agent_provider.AgentsClient") as mock_client_class:
mock_client_instance = AsyncMock()
mock_client_class.return_value = mock_client_instance
with patch.object(AzureAIAgentsProvider, "__init__", lambda self: None): # type: ignore
provider = AzureAIAgentsProvider.__new__(AzureAIAgentsProvider)
provider._agents_client = mock_client_instance # type: ignore
provider._should_close_client = True # type: ignore
provider._settings = AzureAISettings(project_endpoint="https://test.com") # type: ignore
async with provider:
pass
mock_client_instance.close.assert_called_once()
async def test_provider_context_manager_does_not_close_external_client(mock_agents_client: MagicMock) -> None:
"""Test that context manager does not close externally provided client."""
mock_agents_client.close = AsyncMock()
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
async with provider:
pass
mock_agents_client.close.assert_not_called()
# endregion
# region create_agent Tests
async def test_create_agent_basic(
azure_ai_unit_test_env: dict[str, str],
mock_agents_client: MagicMock,
) -> None:
"""Test creating a basic agent."""
mock_agent = MagicMock(spec=AzureAgent)
mock_agent.id = "test-agent-id"
mock_agent.name = "TestAgent"
mock_agent.description = "A test agent"
mock_agent.instructions = "Be helpful"
mock_agent.model = "gpt-4"
mock_agent.temperature = 0.7
mock_agent.top_p = 0.9
mock_agent.tools = []
mock_agents_client.create_agent = AsyncMock(return_value=mock_agent)
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
agent = await provider.create_agent(
name="TestAgent",
instructions="Be helpful",
description="A test agent",
)
assert isinstance(agent, Agent)
assert agent.name == "TestAgent"
assert agent.id == "test-agent-id"
mock_agents_client.create_agent.assert_called_once()
async def test_create_agent_with_model(
azure_ai_unit_test_env: dict[str, str],
mock_agents_client: MagicMock,
) -> None:
"""Test creating an agent with explicit model."""
mock_agent = MagicMock(spec=AzureAgent)
mock_agent.id = "test-agent-id"
mock_agent.name = "TestAgent"
mock_agent.description = None
mock_agent.instructions = None
mock_agent.model = "custom-model"
mock_agent.temperature = None
mock_agent.top_p = None
mock_agent.tools = []
mock_agents_client.create_agent = AsyncMock(return_value=mock_agent)
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
await provider.create_agent(name="TestAgent", model="custom-model")
call_kwargs = mock_agents_client.create_agent.call_args.kwargs
assert call_kwargs["model"] == "custom-model"
async def test_create_agent_with_tools(
azure_ai_unit_test_env: dict[str, str],
mock_agents_client: MagicMock,
) -> None:
"""Test creating an agent with tools."""
mock_agent = MagicMock(spec=AzureAgent)
mock_agent.id = "test-agent-id"
mock_agent.name = "TestAgent"
mock_agent.description = None
mock_agent.instructions = None
mock_agent.model = "gpt-4"
mock_agent.temperature = None
mock_agent.top_p = None
mock_agent.tools = []
mock_agents_client.create_agent = AsyncMock(return_value=mock_agent)
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
@tool(approval_mode="never_require")
def get_weather(city: str) -> str:
"""Get weather for a city."""
return f"Weather in {city}"
await provider.create_agent(name="TestAgent", tools=get_weather)
call_kwargs = mock_agents_client.create_agent.call_args.kwargs
assert "tools" in call_kwargs
assert len(call_kwargs["tools"]) > 0
async def test_create_agent_with_response_format(
azure_ai_unit_test_env: dict[str, str],
mock_agents_client: MagicMock,
) -> None:
"""Test creating an agent with structured response format via default_options."""
class WeatherResponse(BaseModel):
temperature: float
description: str
mock_agent = MagicMock(spec=AzureAgent)
mock_agent.id = "test-agent-id"
mock_agent.name = "TestAgent"
mock_agent.description = None
mock_agent.instructions = None
mock_agent.model = "gpt-4"
mock_agent.temperature = None
mock_agent.top_p = None
mock_agent.tools = []
mock_agents_client.create_agent = AsyncMock(return_value=mock_agent)
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
await provider.create_agent(
name="TestAgent",
default_options={"response_format": WeatherResponse},
)
call_kwargs = mock_agents_client.create_agent.call_args.kwargs
assert "response_format" in call_kwargs
async def test_create_agent_missing_model_raises(
mock_agents_client: MagicMock,
) -> None:
"""Test that create_agent raises error when model is not specified."""
# Create provider with mocked settings that has no model
with patch("agent_framework_azure_ai._agent_provider.load_settings") as mock_load_settings:
mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None}
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
with pytest.raises(ValueError) as exc_info:
await provider.create_agent(name="TestAgent")
assert "model deployment name is required" in str(exc_info.value).lower()
# endregion
# region get_agent Tests
async def test_get_agent_by_id(
azure_ai_unit_test_env: dict[str, str],
mock_agents_client: MagicMock,
) -> None:
"""Test getting an agent by ID."""
mock_agent = MagicMock(spec=AzureAgent)
mock_agent.id = "existing-agent-id"
mock_agent.name = "ExistingAgent"
mock_agent.description = "An existing agent"
mock_agent.instructions = "Be helpful"
mock_agent.model = "gpt-4"
mock_agent.temperature = 0.7
mock_agent.top_p = 0.9
mock_agent.tools = []
mock_agents_client.get_agent = AsyncMock(return_value=mock_agent)
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
agent = await provider.get_agent("existing-agent-id")
assert isinstance(agent, Agent)
assert agent.id == "existing-agent-id"
mock_agents_client.get_agent.assert_called_once_with("existing-agent-id")
async def test_get_agent_with_function_tools(
azure_ai_unit_test_env: dict[str, str],
mock_agents_client: MagicMock,
) -> None:
"""Test getting an agent that has function tools requires tool implementations."""
mock_function_tool = MagicMock()
mock_function_tool.type = "function"
mock_function_tool.function = MagicMock()
mock_function_tool.function.name = "get_weather"
mock_agent = MagicMock(spec=AzureAgent)
mock_agent.id = "agent-with-tools"
mock_agent.name = "AgentWithTools"
mock_agent.description = None
mock_agent.instructions = None
mock_agent.model = "gpt-4"
mock_agent.temperature = None
mock_agent.top_p = None
mock_agent.tools = [mock_function_tool]
mock_agents_client.get_agent = AsyncMock(return_value=mock_agent)
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
with pytest.raises(ValueError) as exc_info:
await provider.get_agent("agent-with-tools")
assert "get_weather" in str(exc_info.value)
async def test_get_agent_with_provided_function_tools(
azure_ai_unit_test_env: dict[str, str],
mock_agents_client: MagicMock,
) -> None:
"""Test getting an agent with function tools when implementations are provided."""
mock_function_tool = MagicMock()
mock_function_tool.type = "function"
mock_function_tool.function = MagicMock()
mock_function_tool.function.name = "get_weather"
mock_agent = MagicMock(spec=AzureAgent)
mock_agent.id = "agent-with-tools"
mock_agent.name = "AgentWithTools"
mock_agent.description = None
mock_agent.instructions = None
mock_agent.model = "gpt-4"
mock_agent.temperature = None
mock_agent.top_p = None
mock_agent.tools = [mock_function_tool]
mock_agents_client.get_agent = AsyncMock(return_value=mock_agent)
@tool(approval_mode="never_require")
def get_weather(city: str) -> str:
"""Get weather for a city."""
return f"Weather in {city}"
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
agent = await provider.get_agent("agent-with-tools", tools=get_weather)
assert isinstance(agent, Agent)
assert agent.id == "agent-with-tools"
# endregion
# region as_agent Tests
def test_as_agent_wraps_without_http(
azure_ai_unit_test_env: dict[str, str],
mock_agents_client: MagicMock,
) -> None:
"""Test as_agent wraps Agent object without making HTTP calls."""
mock_agent = MagicMock(spec=AzureAgent)
mock_agent.id = "wrap-agent-id"
mock_agent.name = "WrapAgent"
mock_agent.description = "Wrapped agent"
mock_agent.instructions = "Be helpful"
mock_agent.model = "gpt-4"
mock_agent.temperature = 0.5
mock_agent.top_p = 0.8
mock_agent.tools = []
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
agent = provider.as_agent(mock_agent)
assert isinstance(agent, Agent)
assert agent.id == "wrap-agent-id"
assert agent.name == "WrapAgent"
# Ensure no HTTP calls were made
mock_agents_client.get_agent.assert_not_called()
mock_agents_client.create_agent.assert_not_called()
def test_as_agent_with_function_tools_validates(
azure_ai_unit_test_env: dict[str, str],
mock_agents_client: MagicMock,
) -> None:
"""Test as_agent validates that function tool implementations are provided."""
mock_function_tool = MagicMock()
mock_function_tool.type = "function"
mock_function_tool.function = MagicMock()
mock_function_tool.function.name = "my_function"
mock_agent = MagicMock(spec=AzureAgent)
mock_agent.id = "agent-id"
mock_agent.name = "Agent"
mock_agent.description = None
mock_agent.instructions = None
mock_agent.model = "gpt-4"
mock_agent.temperature = None
mock_agent.top_p = None
mock_agent.tools = [mock_function_tool]
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
with pytest.raises(ValueError) as exc_info:
provider.as_agent(mock_agent)
assert "my_function" in str(exc_info.value)
def test_as_agent_with_hosted_tools(
azure_ai_unit_test_env: dict[str, str],
mock_agents_client: MagicMock,
) -> None:
"""Test as_agent excludes hosted tools from local tools (they stay on the server agent)."""
mock_code_interpreter = MagicMock()
mock_code_interpreter.type = "code_interpreter"
mock_agent = MagicMock(spec=AzureAgent)
mock_agent.id = "agent-id"
mock_agent.name = "Agent"
mock_agent.description = None
mock_agent.instructions = None
mock_agent.model = "gpt-4"
mock_agent.temperature = None
mock_agent.top_p = None
mock_agent.tools = [mock_code_interpreter]
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
agent = provider.as_agent(mock_agent)
assert isinstance(agent, Agent)
# Hosted tools (code_interpreter, file_search, etc.) are already on the server agent
# and should NOT be in local tools to avoid re-sending them at run time
tools = agent.default_options.get("tools") or []
assert not any(isinstance(t, dict) and t.get("type") == "code_interpreter" for t in tools)
def test_as_agent_with_dict_function_tools_validates(
azure_ai_unit_test_env: dict[str, str],
mock_agents_client: MagicMock,
) -> None:
"""Test as_agent validates dict-format function tools require implementations."""
# Dict-based function tool (as returned by some Azure AI SDK operations)
dict_function_tool = { # type: ignore
"type": "function",
"function": {
"name": "dict_based_function",
"description": "A function defined as dict",
"parameters": {"type": "object", "properties": {}},
},
}
mock_agent = MagicMock(spec=AzureAgent)
mock_agent.id = "agent-id"
mock_agent.name = "Agent"
mock_agent.description = None
mock_agent.instructions = None
mock_agent.model = "gpt-4"
mock_agent.temperature = None
mock_agent.top_p = None
mock_agent.tools = [dict_function_tool]
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
with pytest.raises(ValueError) as exc_info:
provider.as_agent(mock_agent)
assert "dict_based_function" in str(exc_info.value)
def test_as_agent_with_dict_function_tools_provided(
azure_ai_unit_test_env: dict[str, str],
mock_agents_client: MagicMock,
) -> None:
"""Test as_agent succeeds when dict-format function tools have implementations provided."""
dict_function_tool = { # type: ignore
"type": "function",
"function": {
"name": "dict_based_function",
"description": "A function defined as dict",
"parameters": {"type": "object", "properties": {}},
},
}
mock_agent = MagicMock(spec=AzureAgent)
mock_agent.id = "agent-id"
mock_agent.name = "Agent"
mock_agent.description = None
mock_agent.instructions = None
mock_agent.model = "gpt-4"
mock_agent.temperature = None
mock_agent.top_p = None
mock_agent.tools = [dict_function_tool]
@tool
def dict_based_function() -> str:
"""A function implementation."""
return "result"
provider = AzureAIAgentsProvider(agents_client=mock_agents_client)
agent = provider.as_agent(mock_agent, tools=dict_based_function)
assert isinstance(agent, Agent)
assert agent.id == "agent-id"
# endregion
# region Tool Conversion Tests - to_azure_ai_agent_tools
def test_to_azure_ai_agent_tools_empty() -> None:
"""Test converting empty tools list."""
result = to_azure_ai_agent_tools(None)
assert result == []
result = to_azure_ai_agent_tools([])
assert result == []
def test_to_azure_ai_agent_tools_function() -> None:
"""Test converting FunctionTool to Azure tool definition."""
@tool(approval_mode="never_require")
def get_weather(city: str) -> str:
"""Get weather for a city."""
return f"Weather in {city}"
result = to_azure_ai_agent_tools([get_weather])
assert len(result) == 1
assert result[0]["type"] == "function"
assert result[0]["function"]["name"] == "get_weather"
def test_to_azure_ai_agent_tools_code_interpreter() -> None:
"""Test converting code_interpreter dict tool."""
tool = AzureAIAgentClient.get_code_interpreter_tool()
result = to_azure_ai_agent_tools([tool])
assert len(result) == 1
assert isinstance(result[0], CodeInterpreterToolDefinition)
def test_to_azure_ai_agent_tools_file_search() -> None:
"""Test converting file_search dict tool with vector stores."""
tool = AzureAIAgentClient.get_file_search_tool(vector_store_ids=["vs-123"])
run_options: dict[str, Any] = {}
result = to_azure_ai_agent_tools([tool], run_options)
assert len(result) == 1
assert "tool_resources" in run_options
def test_to_azure_ai_agent_tools_web_search_bing_grounding(monkeypatch: Any) -> None:
"""Test converting web_search dict tool for Bing Grounding."""
# Use a properly formatted connection ID as required by Azure SDK
valid_conn_id = (
"/subscriptions/test-sub/resourceGroups/test-rg/"
"providers/Microsoft.CognitiveServices/accounts/test-account/"
"projects/test-project/connections/test-connection"
)
tool = AzureAIAgentClient.get_web_search_tool(bing_connection_id=valid_conn_id)
result = to_azure_ai_agent_tools([tool])
assert len(result) > 0
def test_to_azure_ai_agent_tools_web_search_custom(monkeypatch: Any) -> None:
"""Test converting web_search dict tool for Custom Bing Search."""
tool = AzureAIAgentClient.get_web_search_tool(
bing_custom_connection_id="custom-conn-id",
bing_custom_instance_id="my-instance",
)
result = to_azure_ai_agent_tools([tool])
assert len(result) > 0
def test_to_azure_ai_agent_tools_web_search_missing_config(monkeypatch: Any) -> None:
"""Test converting web_search dict tool without bing config returns empty."""
monkeypatch.delenv("BING_CONNECTION_ID", raising=False)
monkeypatch.delenv("BING_CUSTOM_CONNECTION_ID", raising=False)
monkeypatch.delenv("BING_CUSTOM_INSTANCE_NAME", raising=False)
tool = {"type": "web_search"}
result = to_azure_ai_agent_tools([tool])
# web_search without bing connection is passed through as dict
assert len(result) == 1
def test_to_azure_ai_agent_tools_mcp() -> None:
"""Test converting MCP dict tool."""
tool = AzureAIAgentClient.get_mcp_tool(
name="my mcp server",
url="https://mcp.example.com",
)
result = to_azure_ai_agent_tools([tool])
assert len(result) > 0
def test_to_azure_ai_agent_tools_dict_passthrough() -> None:
"""Test that dict tools are passed through."""
tool = {"type": "custom_tool", "config": {"key": "value"}}
result = to_azure_ai_agent_tools([tool])
assert len(result) == 1
assert result[0] == tool
def test_to_azure_ai_agent_tools_unsupported_type() -> None:
"""Test that unsupported tool types pass through unchanged."""
class UnsupportedTool:
pass
unsupported = UnsupportedTool()
result = to_azure_ai_agent_tools([unsupported]) # type: ignore
assert len(result) == 1
assert result[0] is unsupported # Passed through unchanged
# endregion
# region Tool Conversion Tests - from_azure_ai_agent_tools
def test_from_azure_ai_agent_tools_empty() -> None:
"""Test converting empty tools list."""
result = from_azure_ai_agent_tools(None)
assert result == []
result = from_azure_ai_agent_tools([])
assert result == []
def test_from_azure_ai_agent_tools_code_interpreter() -> None:
"""Test converting CodeInterpreterToolDefinition."""
tool = CodeInterpreterToolDefinition()
result = from_azure_ai_agent_tools([tool])
assert len(result) == 1
assert result[0] == {"type": "code_interpreter"}
def test_from_azure_ai_agent_tools_code_interpreter_dict() -> None:
"""Test converting code_interpreter dict."""
tool = {"type": "code_interpreter"}
result = from_azure_ai_agent_tools([tool])
assert len(result) == 1
assert result[0] == {"type": "code_interpreter"}
def test_from_azure_ai_agent_tools_file_search_dict() -> None:
"""Test converting file_search dict with vector store IDs."""
tool = {
"type": "file_search",
"file_search": {"vector_store_ids": ["vs-123", "vs-456"]},
}
result = from_azure_ai_agent_tools([tool])
assert len(result) == 1
assert result[0]["type"] == "file_search"
assert result[0]["vector_store_ids"] == ["vs-123", "vs-456"]
def test_from_azure_ai_agent_tools_bing_grounding_dict() -> None:
"""Test converting bing_grounding dict."""
tool = {
"type": "bing_grounding",
"bing_grounding": {"connection_id": "conn-123"},
}
result = from_azure_ai_agent_tools([tool])
assert len(result) == 1
assert result[0]["type"] == "bing_grounding"
assert result[0]["connection_id"] == "conn-123"
def test_from_azure_ai_agent_tools_bing_custom_search_dict() -> None:
"""Test converting bing_custom_search dict."""
tool = {
"type": "bing_custom_search",
"bing_custom_search": {
"connection_id": "custom-conn",
"instance_name": "my-instance",
},
}
result = from_azure_ai_agent_tools([tool])
assert len(result) == 1
assert result[0]["type"] == "bing_custom_search"
assert result[0]["connection_id"] == "custom-conn"
assert result[0]["instance_name"] == "my-instance"
def test_from_azure_ai_agent_tools_mcp_dict() -> None:
"""Test that mcp dict is skipped (hosted on Azure, no local handling needed)."""
tool = {
"type": "mcp",
"mcp": {
"server_label": "my_server",
"server_url": "https://mcp.example.com",
"allowed_tools": ["tool1"],
},
}
result = from_azure_ai_agent_tools([tool])
# MCP tools are hosted on Azure agent, skipped in conversion
assert len(result) == 0
def test_from_azure_ai_agent_tools_function_dict() -> None:
"""Test converting function tool dict (returned as-is)."""
tool: dict[str, Any] = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {},
},
}
result = from_azure_ai_agent_tools([tool])
assert len(result) == 1
assert result[0] == tool
def test_from_azure_ai_agent_tools_unknown_dict() -> None:
"""Test converting unknown tool type dict."""
tool = {"type": "unknown_tool", "config": "value"}
result = from_azure_ai_agent_tools([tool])
assert len(result) == 1
assert result[0] == tool
# endregion
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,682 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import Agent, FunctionTool
from agent_framework._mcp import MCPTool
from azure.ai.projects.models import (
AgentVersionDetails,
PromptAgentDefinition,
)
from azure.ai.projects.models import (
FunctionTool as AzureFunctionTool,
)
from agent_framework_azure_ai import AzureAIProjectAgentProvider
@pytest.fixture
def mock_project_client() -> MagicMock:
"""Fixture that provides a mock AIProjectClient."""
mock_client = MagicMock()
# Mock agents property
mock_client.agents = MagicMock()
mock_client.agents.create_version = AsyncMock()
# Mock conversations property
mock_client.conversations = MagicMock()
mock_client.conversations.create = AsyncMock()
# Mock telemetry property
mock_client.telemetry = MagicMock()
mock_client.telemetry.get_application_insights_connection_string = AsyncMock()
# Mock get_openai_client method
mock_client.get_openai_client = AsyncMock()
# Mock close method
mock_client.close = AsyncMock()
return mock_client
@pytest.fixture
def mock_azure_credential() -> MagicMock:
"""Fixture that provides a mock Azure credential."""
return MagicMock()
@pytest.fixture
def azure_ai_unit_test_env(monkeypatch: pytest.MonkeyPatch) -> dict[str, str]:
"""Fixture that sets up Azure AI environment variables for unit testing."""
env_vars = {
"AZURE_AI_PROJECT_ENDPOINT": "https://test-project.cognitiveservices.azure.com/",
"AZURE_AI_MODEL_DEPLOYMENT_NAME": "test-model-deployment",
}
for key, value in env_vars.items():
monkeypatch.setenv(key, value)
return env_vars
def test_provider_init_with_project_client(mock_project_client: MagicMock) -> None:
"""Test AzureAIProjectAgentProvider initialization with existing project_client."""
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
assert provider._project_client is mock_project_client # type: ignore
assert not provider._should_close_client # type: ignore
def test_provider_init_with_credential_and_endpoint(
azure_ai_unit_test_env: dict[str, str],
mock_azure_credential: MagicMock,
) -> None:
"""Test AzureAIProjectAgentProvider initialization with credential and endpoint."""
with patch("agent_framework_azure_ai._project_provider.AIProjectClient") as mock_ai_project_client:
mock_client = MagicMock()
mock_ai_project_client.return_value = mock_client
provider = AzureAIProjectAgentProvider(
project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
credential=mock_azure_credential,
)
assert provider._project_client is mock_client # type: ignore
assert provider._should_close_client # type: ignore
# Verify AIProjectClient was called with correct parameters
mock_ai_project_client.assert_called_once()
def test_provider_init_missing_endpoint() -> None:
"""Test AzureAIProjectAgentProvider initialization when endpoint is missing."""
with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"}
with pytest.raises(ValueError, match="Azure AI project endpoint is required"):
AzureAIProjectAgentProvider(credential=MagicMock())
def test_provider_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None:
"""Test AzureAIProjectAgentProvider initialization when credential is missing."""
with pytest.raises(ValueError, match="Azure credential is required when project_client is not provided"):
AzureAIProjectAgentProvider(
project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
)
async def test_provider_create_agent(
mock_project_client: MagicMock,
azure_ai_unit_test_env: dict[str, str],
) -> None:
"""Test AzureAIProjectAgentProvider.create_agent method."""
with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
mock_load_settings.return_value = {
"project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
"model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
}
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
# Mock agent creation response
mock_agent_version = MagicMock(spec=AgentVersionDetails)
mock_agent_version.id = "agent-id"
mock_agent_version.name = "test-agent"
mock_agent_version.version = "1.0"
mock_agent_version.description = "Test Agent"
mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition)
mock_agent_version.definition.model = "gpt-4"
mock_agent_version.definition.instructions = "Test instructions"
mock_agent_version.definition.temperature = 0.7
mock_agent_version.definition.top_p = 0.9
mock_agent_version.definition.tools = []
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version)
agent = await provider.create_agent(
name="test-agent",
model="gpt-4",
instructions="Test instructions",
description="Test Agent",
)
assert isinstance(agent, Agent)
assert agent.name == "test-agent"
mock_project_client.agents.create_version.assert_called_once()
async def test_provider_create_agent_with_env_model(
mock_project_client: MagicMock,
azure_ai_unit_test_env: dict[str, str],
) -> None:
"""Test AzureAIProjectAgentProvider.create_agent uses model from env var."""
with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
mock_load_settings.return_value = {
"project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
"model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
}
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
# Mock agent creation response
mock_agent_version = MagicMock(spec=AgentVersionDetails)
mock_agent_version.id = "agent-id"
mock_agent_version.name = "test-agent"
mock_agent_version.version = "1.0"
mock_agent_version.description = None
mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition)
mock_agent_version.definition.model = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]
mock_agent_version.definition.instructions = None
mock_agent_version.definition.temperature = None
mock_agent_version.definition.top_p = None
mock_agent_version.definition.tools = []
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version)
# Call without model parameter - should use env var
agent = await provider.create_agent(name="test-agent")
assert isinstance(agent, Agent)
# Verify the model from env var was used
call_args = mock_project_client.agents.create_version.call_args
assert call_args[1]["definition"].model == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]
async def test_provider_create_agent_missing_model(mock_project_client: MagicMock) -> None:
"""Test AzureAIProjectAgentProvider.create_agent raises when model is missing."""
with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None}
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
with pytest.raises(ValueError, match="Model deployment name is required"):
await provider.create_agent(name="test-agent")
async def test_provider_create_agent_with_rai_config(
mock_project_client: MagicMock,
azure_ai_unit_test_env: dict[str, str],
) -> None:
"""Test AzureAIProjectAgentProvider.create_agent passes rai_config from default_options."""
with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
mock_load_settings.return_value = {
"project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
"model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
}
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
# Mock agent creation response
mock_agent_version = MagicMock(spec=AgentVersionDetails)
mock_agent_version.id = "agent-id"
mock_agent_version.name = "test-agent"
mock_agent_version.version = "1.0"
mock_agent_version.description = None
mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition)
mock_agent_version.definition.model = "gpt-4"
mock_agent_version.definition.instructions = None
mock_agent_version.definition.temperature = None
mock_agent_version.definition.top_p = None
mock_agent_version.definition.tools = []
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version)
# Create a mock RaiConfig-like object
mock_rai_config = MagicMock()
mock_rai_config.rai_policy_name = "policy-name"
# Call create_agent with rai_config in default_options
await provider.create_agent(
name="test-agent",
model="gpt-4",
default_options={"rai_config": mock_rai_config},
)
# Verify rai_config was passed to PromptAgentDefinition
call_args = mock_project_client.agents.create_version.call_args
definition = call_args[1]["definition"]
assert definition.rai_config is mock_rai_config
async def test_provider_create_agent_with_reasoning(
mock_project_client: MagicMock,
azure_ai_unit_test_env: dict[str, str],
) -> None:
"""Test AzureAIProjectAgentProvider.create_agent passes reasoning from default_options."""
with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
mock_load_settings.return_value = {
"project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
"model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
}
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
# Mock agent creation response
mock_agent_version = MagicMock(spec=AgentVersionDetails)
mock_agent_version.id = "agent-id"
mock_agent_version.name = "test-agent"
mock_agent_version.version = "1.0"
mock_agent_version.description = None
mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition)
mock_agent_version.definition.model = "gpt-5.2"
mock_agent_version.definition.instructions = None
mock_agent_version.definition.temperature = None
mock_agent_version.definition.top_p = None
mock_agent_version.definition.tools = []
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version)
# Create a mock Reasoning-like object
mock_reasoning = MagicMock()
mock_reasoning.effort = "medium"
mock_reasoning.summary = "concise"
# Call create_agent with reasoning in default_options
await provider.create_agent(
name="test-agent",
model="gpt-5.2",
default_options={"reasoning": mock_reasoning},
)
# Verify reasoning was passed to PromptAgentDefinition
call_args = mock_project_client.agents.create_version.call_args
definition = call_args[1]["definition"]
assert definition.reasoning is mock_reasoning
async def test_provider_get_agent_with_name(mock_project_client: MagicMock) -> None:
"""Test AzureAIProjectAgentProvider.get_agent with name parameter."""
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
# Mock agent response
mock_agent_version = MagicMock(spec=AgentVersionDetails)
mock_agent_version.id = "agent-id"
mock_agent_version.name = "test-agent"
mock_agent_version.version = "1.0"
mock_agent_version.description = "Test Agent"
mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition)
mock_agent_version.definition.model = "gpt-4"
mock_agent_version.definition.instructions = "Test instructions"
mock_agent_version.definition.temperature = None
mock_agent_version.definition.top_p = None
mock_agent_version.definition.tools = []
mock_agent_object = MagicMock()
mock_agent_object.versions.latest = mock_agent_version
mock_project_client.agents = AsyncMock()
mock_project_client.agents.get.return_value = mock_agent_object
agent = await provider.get_agent(name="test-agent")
assert isinstance(agent, Agent)
assert agent.name == "test-agent"
mock_project_client.agents.get.assert_called_with(agent_name="test-agent")
async def test_provider_get_agent_with_reference(mock_project_client: MagicMock) -> None:
"""Test AzureAIProjectAgentProvider.get_agent with reference parameter."""
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
# Mock agent response
mock_agent_version = MagicMock(spec=AgentVersionDetails)
mock_agent_version.id = "agent-id"
mock_agent_version.name = "test-agent"
mock_agent_version.version = "1.0"
mock_agent_version.description = "Test Agent"
mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition)
mock_agent_version.definition.model = "gpt-4"
mock_agent_version.definition.instructions = "Test instructions"
mock_agent_version.definition.temperature = None
mock_agent_version.definition.top_p = None
mock_agent_version.definition.tools = []
mock_project_client.agents = AsyncMock()
mock_project_client.agents.get_version.return_value = mock_agent_version
agent_reference = {"name": "test-agent", "version": "1.0"}
agent = await provider.get_agent(reference=agent_reference)
assert isinstance(agent, Agent)
assert agent.name == "test-agent"
mock_project_client.agents.get_version.assert_called_with(agent_name="test-agent", agent_version="1.0")
async def test_provider_get_agent_missing_parameters(mock_project_client: MagicMock) -> None:
"""Test AzureAIProjectAgentProvider.get_agent raises when no identifier provided."""
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
with pytest.raises(ValueError, match="Either name or reference must be provided"):
await provider.get_agent()
async def test_provider_get_agent_missing_function_tools(mock_project_client: MagicMock) -> None:
"""Test AzureAIProjectAgentProvider.get_agent raises when required tools are missing."""
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
# Mock agent with function tools
mock_agent_version = MagicMock(spec=AgentVersionDetails)
mock_agent_version.id = "agent-id"
mock_agent_version.name = "test-agent"
mock_agent_version.version = "1.0"
mock_agent_version.description = None
mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition)
mock_agent_version.definition.tools = [
AzureFunctionTool(name="test_tool", parameters=[], strict=True, description="Test tool")
]
mock_agent_object = MagicMock()
mock_agent_object.versions.latest = mock_agent_version
mock_project_client.agents = AsyncMock()
mock_project_client.agents.get.return_value = mock_agent_object
with pytest.raises(
ValueError, match="The following prompt agent definition required tools were not provided: test_tool"
):
await provider.get_agent(name="test-agent")
def test_provider_as_agent(mock_project_client: MagicMock) -> None:
"""Test AzureAIProjectAgentProvider.as_agent method."""
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
# Create mock agent version
mock_agent_version = MagicMock(spec=AgentVersionDetails)
mock_agent_version.id = "agent-id"
mock_agent_version.name = "test-agent"
mock_agent_version.version = "1.0"
mock_agent_version.description = "Test Agent"
mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition)
mock_agent_version.definition.model = "gpt-4"
mock_agent_version.definition.instructions = "Test instructions"
mock_agent_version.definition.temperature = 0.7
mock_agent_version.definition.top_p = 0.9
mock_agent_version.definition.tools = []
with patch("agent_framework_azure_ai._project_provider.AzureAIClient") as mock_azure_ai_client:
agent = provider.as_agent(mock_agent_version)
assert isinstance(agent, Agent)
assert agent.name == "test-agent"
assert agent.description == "Test Agent"
# Verify AzureAIClient was called with correct parameters
mock_azure_ai_client.assert_called_once()
call_kwargs = mock_azure_ai_client.call_args[1]
assert call_kwargs["project_client"] is mock_project_client
assert call_kwargs["agent_name"] == "test-agent"
assert call_kwargs["agent_version"] == "1.0"
assert call_kwargs["agent_description"] == "Test Agent"
assert call_kwargs["model_deployment_name"] == "gpt-4"
def test_provider_merge_tools_skips_function_tool_dicts(mock_project_client: MagicMock) -> None:
"""Test that _merge_tools skips function tool dicts but keeps other hosted tools."""
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
# Create a mock FunctionTool to provide as implementation
mock_ai_function = create_mock_ai_function("my_function", "My function description")
# Definition tools include a function tool (dict) and an MCP tool
definition_tools = [
{"type": "function", "name": "my_function", "parameters": {}}, # Should be skipped
{"type": "mcp", "server_label": "my_mcp", "server_url": "http://localhost:8080"}, # Should be converted
]
# Call _merge_tools with user-provided function implementation
merged = provider._merge_tools(definition_tools, [mock_ai_function]) # type: ignore
# Should have 2 items: the converted MCP dict and the user-provided FunctionTool
assert len(merged) == 2
# Check that the function tool dict was NOT included (it was skipped)
function_dicts = [t for t in merged if isinstance(t, dict) and t.get("type") == "function"]
assert len(function_dicts) == 0
# Check that the MCP tool was converted to dict
mcp_tools = [t for t in merged if isinstance(t, dict) and t.get("type") == "mcp"]
assert len(mcp_tools) == 1
assert mcp_tools[0]["server_label"] == "my_mcp"
# Check that the user-provided FunctionTool was included
ai_functions = [t for t in merged if isinstance(t, FunctionTool)]
assert len(ai_functions) == 1
assert ai_functions[0].name == "my_function"
async def test_provider_context_manager(mock_project_client: MagicMock) -> None:
"""Test AzureAIProjectAgentProvider async context manager."""
with patch("agent_framework_azure_ai._project_provider.AIProjectClient") as mock_ai_project_client:
mock_client = MagicMock()
mock_client.close = AsyncMock()
mock_ai_project_client.return_value = mock_client
with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
mock_load_settings.return_value = {
"project_endpoint": "https://test.com",
"model_deployment_name": "test-model",
}
async with AzureAIProjectAgentProvider(credential=MagicMock()) as provider:
assert provider._project_client is mock_client # type: ignore
# Should call close after exiting context
mock_client.close.assert_called_once()
async def test_provider_context_manager_with_provided_client(mock_project_client: MagicMock) -> None:
"""Test AzureAIProjectAgentProvider context manager doesn't close provided client."""
mock_project_client.close = AsyncMock()
async with AzureAIProjectAgentProvider(project_client=mock_project_client) as provider:
assert provider._project_client is mock_project_client # type: ignore
# Should NOT call close when client was provided
mock_project_client.close.assert_not_called()
async def test_provider_close_method(mock_project_client: MagicMock) -> None:
"""Test AzureAIProjectAgentProvider.close method."""
with patch("agent_framework_azure_ai._project_provider.AIProjectClient") as mock_ai_project_client:
mock_client = MagicMock()
mock_client.close = AsyncMock()
mock_ai_project_client.return_value = mock_client
with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings:
mock_load_settings.return_value = {
"project_endpoint": "https://test.com",
"model_deployment_name": "test-model",
}
provider = AzureAIProjectAgentProvider(credential=MagicMock())
await provider.close()
mock_client.close.assert_called_once()
def test_create_text_format_config_sets_strict_for_pydantic_models() -> None:
"""Test that create_text_format_config sets strict=True for Pydantic models."""
from pydantic import BaseModel
from agent_framework_azure_ai._shared import create_text_format_config
class TestSchema(BaseModel):
subject: str
summary: str
result = create_text_format_config(TestSchema)
# Verify strict=True is set
assert result["strict"] is True
assert result["name"] == "TestSchema"
assert "schema" in result
class MockMCPTool(MCPTool): # pyright: ignore[reportGeneralTypeIssues]
"""A mock MCPTool subclass for testing that passes isinstance checks.
Note: This intentionally does NOT call super().__init__() because MCPTool's
constructor requires MCP server connection parameters that aren't needed for
unit testing. We only need isinstance(obj, MCPTool) to return True.
"""
def __init__(self, functions: list[FunctionTool] | None = None) -> None:
self.name = "MockMCPTool"
self.description = "A mock MCP tool for testing"
self.is_connected = False
self._mock_functions = functions or []
self._connect_called = False
@property
def functions(self) -> list[FunctionTool]:
return self._mock_functions
async def connect(self, *, reset: bool = False) -> None:
self._connect_called = True
self.is_connected = True
@pytest.fixture
def mock_mcp_tool() -> MockMCPTool:
"""Fixture that provides a mock MCPTool."""
mock_functions = [
create_mock_ai_function("mcp_function_1", "First MCP function"),
create_mock_ai_function("mcp_function_2", "Second MCP function"),
]
return MockMCPTool(functions=mock_functions)
def create_mock_ai_function(name: str, description: str = "A mock function") -> FunctionTool:
"""Create a real FunctionTool for testing."""
def mock_func(arg: str) -> str:
return f"Result from {name}: {arg}"
return FunctionTool(func=mock_func, name=name, description=description, approval_mode="never_require")
async def test_provider_create_agent_with_mcp_tool(
mock_project_client: MagicMock,
azure_ai_unit_test_env: dict[str, str],
mock_mcp_tool: "MockMCPTool",
) -> None:
"""Test that create_agent connects MCP tools and passes discovered functions to Azure AI."""
# Patch normalize_tools to return tools as-is in a list (avoids callable check)
def mock_normalize_tools(tools):
if tools is None:
return []
if isinstance(tools, list):
return tools
return [tools]
with (
patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings,
patch("agent_framework_azure_ai._project_provider.to_azure_ai_tools") as mock_to_azure_tools,
patch("agent_framework_azure_ai._project_provider.normalize_tools", side_effect=mock_normalize_tools),
):
mock_load_settings.return_value = {
"project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
"model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
}
mock_to_azure_tools.return_value = [{"type": "function", "name": "mcp_function_1"}]
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
# Mock agent creation response
mock_agent_version = MagicMock(spec=AgentVersionDetails)
mock_agent_version.id = "agent-id"
mock_agent_version.name = "test-agent"
mock_agent_version.version = "1.0"
mock_agent_version.description = "Test Agent"
mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition)
mock_agent_version.definition.model = "gpt-4"
mock_agent_version.definition.instructions = "Test instructions"
mock_agent_version.definition.tools = []
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version)
# Call create_agent with MCP tool
await provider.create_agent(
name="test-agent",
model="gpt-4",
instructions="Test instructions",
tools=mock_mcp_tool,
)
# Verify MCP tool was connected
assert mock_mcp_tool._connect_called is True
assert mock_mcp_tool.is_connected is True
# Verify to_azure_ai_tools was called with the discovered MCP functions
mock_to_azure_tools.assert_called_once()
tools_passed = mock_to_azure_tools.call_args[0][0]
assert len(tools_passed) == 2
assert tools_passed[0].name == "mcp_function_1"
assert tools_passed[1].name == "mcp_function_2"
async def test_provider_create_agent_with_mcp_and_regular_tools(
mock_project_client: MagicMock,
azure_ai_unit_test_env: dict[str, str],
mock_mcp_tool: "MockMCPTool",
) -> None:
"""Test that create_agent handles both MCP tools and regular FunctionTools."""
# Create a regular FunctionTool
regular_function = create_mock_ai_function("regular_function", "A regular function")
# Patch normalize_tools to return tools as-is in a list (avoids callable check)
def mock_normalize_tools(tools):
if tools is None:
return []
if isinstance(tools, list):
return tools
return [tools]
with (
patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings,
patch("agent_framework_azure_ai._project_provider.to_azure_ai_tools") as mock_to_azure_tools,
patch("agent_framework_azure_ai._project_provider.normalize_tools", side_effect=mock_normalize_tools),
):
mock_load_settings.return_value = {
"project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"],
"model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
}
mock_to_azure_tools.return_value = []
provider = AzureAIProjectAgentProvider(project_client=mock_project_client)
# Mock agent creation response
mock_agent_version = MagicMock(spec=AgentVersionDetails)
mock_agent_version.id = "agent-id"
mock_agent_version.name = "test-agent"
mock_agent_version.version = "1.0"
mock_agent_version.description = None
mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition)
mock_agent_version.definition.model = "gpt-4"
mock_agent_version.definition.instructions = None
mock_agent_version.definition.tools = []
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version)
# Pass both MCP tool and regular function
await provider.create_agent(
name="test-agent",
model="gpt-4",
tools=[mock_mcp_tool, regular_function],
)
# Verify to_azure_ai_tools was called with:
# - The regular FunctionTool (1)
# - The 2 discovered MCP functions
mock_to_azure_tools.assert_called_once()
tools_passed = mock_to_azure_tools.call_args[0][0]
assert len(tools_passed) == 3 # 1 regular + 2 MCP functions
# Verify the regular function is in the list
tool_names = [t.name for t in tools_passed]
assert "regular_function" in tool_names
assert "mcp_function_1" in tool_names
assert "mcp_function_2" in tool_names
@@ -1,494 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from unittest.mock import MagicMock, patch
import pytest
from agent_framework import (
FunctionTool,
)
from agent_framework.exceptions import IntegrationInvalidRequestException
from azure.ai.agents.models import CodeInterpreterToolDefinition
from pydantic import BaseModel
from agent_framework_azure_ai import AzureAIAgentClient
from agent_framework_azure_ai._shared import (
_convert_response_format, # type: ignore
_convert_sdk_tool, # type: ignore
_extract_project_connection_id, # type: ignore
create_text_format_config,
from_azure_ai_agent_tools,
from_azure_ai_tools,
to_azure_ai_agent_tools,
to_azure_ai_tools,
)
from agent_framework_azure_ai._shared import (
_prepare_mcp_tool_dict_for_azure_ai as _prepare_mcp_tool_for_azure_ai, # type: ignore
)
def test_extract_project_connection_id_direct() -> None:
"""Test extracting project_connection_id from direct key."""
result = _extract_project_connection_id({"project_connection_id": "my-connection"})
assert result == "my-connection"
def test_extract_project_connection_id_from_connection_name() -> None:
"""Test extracting project_connection_id from connection.name structure."""
result = _extract_project_connection_id({"connection": {"name": "my-connection"}})
assert result == "my-connection"
def test_extract_project_connection_id_none() -> None:
"""Test returns None when no connection info."""
assert _extract_project_connection_id(None) is None
assert _extract_project_connection_id({}) is None
def test_to_azure_ai_agent_tools_empty() -> None:
"""Test converting empty/None tools list."""
assert to_azure_ai_agent_tools(None) == []
assert to_azure_ai_agent_tools([]) == []
def test_to_azure_ai_agent_tools_function_tool() -> None:
"""Test converting FunctionTool to tool definition."""
def my_func(arg: str) -> str:
"""My function."""
return arg
func_tool = FunctionTool(func=my_func, name="my_func", description="My function.") # type: ignore
result = to_azure_ai_agent_tools([func_tool]) # type: ignore
assert len(result) == 1
assert result[0]["type"] == "function"
assert result[0]["function"]["name"] == "my_func"
def test_to_azure_ai_agent_tools_code_interpreter() -> None:
"""Test converting code_interpreter dict tool."""
tool = AzureAIAgentClient.get_code_interpreter_tool()
result = to_azure_ai_agent_tools([tool])
assert len(result) == 1
assert isinstance(result[0], CodeInterpreterToolDefinition)
def test_to_azure_ai_agent_tools_web_search_missing_connection() -> None:
"""Test web search tool raises without connection info."""
# Clear any environment variables that could provide connection info
with patch.dict(
os.environ,
{"BING_CONNECTION_ID": "", "BING_CUSTOM_CONNECTION_ID": "", "BING_CUSTOM_INSTANCE_NAME": ""},
clear=False,
):
# Also need to unset the keys if they exist
env_backup = {}
for key in ["BING_CONNECTION_ID", "BING_CUSTOM_CONNECTION_ID", "BING_CUSTOM_INSTANCE_NAME"]:
env_backup[key] = os.environ.pop(key, None)
try:
# get_web_search_tool now raises ValueError when no connection info is available
with pytest.raises(ValueError, match="Azure AI Agents requires a Bing connection"):
AzureAIAgentClient.get_web_search_tool()
finally:
# Restore environment
for key, value in env_backup.items():
if value is not None:
os.environ[key] = value
def test_to_azure_ai_agent_tools_dict_passthrough() -> None:
"""Test dict tools pass through unchanged."""
tool_dict = {"type": "custom", "config": "value"}
result = to_azure_ai_agent_tools([tool_dict])
assert result[0] == tool_dict
def test_to_azure_ai_agent_tools_unsupported_type() -> None:
"""Test unsupported tool type passes through unchanged."""
class UnsupportedTool:
pass
unsupported = UnsupportedTool()
result = to_azure_ai_agent_tools([unsupported]) # type: ignore
assert len(result) == 1
assert result[0] is unsupported # Passed through unchanged
def test_from_azure_ai_agent_tools_empty() -> None:
"""Test converting empty/None tools list."""
assert from_azure_ai_agent_tools(None) == []
assert from_azure_ai_agent_tools([]) == []
def test_from_azure_ai_agent_tools_code_interpreter() -> None:
"""Test converting CodeInterpreterToolDefinition."""
tool = CodeInterpreterToolDefinition()
result = from_azure_ai_agent_tools([tool])
assert len(result) == 1
assert result[0] == {"type": "code_interpreter"}
def test_convert_sdk_tool_code_interpreter() -> None:
"""Test _convert_sdk_tool with code_interpreter type."""
tool = MagicMock()
tool.type = "code_interpreter"
result = _convert_sdk_tool(tool)
assert result == {"type": "code_interpreter"}
def test_convert_sdk_tool_function_returns_none() -> None:
"""Test _convert_sdk_tool with function type returns None."""
tool = MagicMock()
tool.type = "function"
result = _convert_sdk_tool(tool)
assert result is None
def test_convert_sdk_tool_mcp_returns_none() -> None:
"""Test _convert_sdk_tool with mcp type returns None."""
tool = MagicMock()
tool.type = "mcp"
result = _convert_sdk_tool(tool)
assert result is None
def test_convert_sdk_tool_file_search() -> None:
"""Test _convert_sdk_tool with file_search type."""
tool = MagicMock()
tool.type = "file_search"
tool.file_search = MagicMock()
tool.file_search.vector_store_ids = ["vs-1", "vs-2"]
result = _convert_sdk_tool(tool)
assert result["type"] == "file_search"
assert result["vector_store_ids"] == ["vs-1", "vs-2"]
def test_convert_sdk_tool_bing_grounding() -> None:
"""Test _convert_sdk_tool with bing_grounding type."""
tool = MagicMock()
tool.type = "bing_grounding"
tool.bing_grounding = MagicMock()
tool.bing_grounding.connection_id = "conn-123"
result = _convert_sdk_tool(tool)
assert result["type"] == "bing_grounding"
assert result["connection_id"] == "conn-123"
def test_convert_sdk_tool_bing_custom_search() -> None:
"""Test _convert_sdk_tool with bing_custom_search type."""
tool = MagicMock()
tool.type = "bing_custom_search"
tool.bing_custom_search = MagicMock()
tool.bing_custom_search.connection_id = "conn-123"
tool.bing_custom_search.instance_name = "my-instance"
result = _convert_sdk_tool(tool)
assert result["type"] == "bing_custom_search"
assert result["connection_id"] == "conn-123"
assert result["instance_name"] == "my-instance"
def test_to_azure_ai_tools_empty() -> None:
"""Test converting empty/None tools list."""
assert to_azure_ai_tools(None) == []
assert to_azure_ai_tools([]) == []
def test_to_azure_ai_tools_code_interpreter_with_file_ids() -> None:
"""Test converting code_interpreter dict tool with file inputs."""
tool = {
"type": "code_interpreter",
"file_ids": ["file-123"],
}
result = to_azure_ai_tools([tool])
assert len(result) == 1
assert result[0]["type"] == "code_interpreter"
def test_to_azure_ai_tools_function_tool() -> None:
"""Test converting FunctionTool."""
def my_func(arg: str) -> str:
"""My function."""
return arg
func_tool = FunctionTool(func=my_func, name="my_func", description="My function.") # type: ignore
result = to_azure_ai_tools([func_tool]) # type: ignore
assert len(result) == 1
assert result[0]["type"] == "function"
assert result[0]["name"] == "my_func"
def test_to_azure_ai_tools_file_search() -> None:
"""Test converting file_search dict tool."""
tool = {
"type": "file_search",
"vector_store_ids": ["vs-123"],
"max_num_results": 10,
}
result = to_azure_ai_tools([tool])
assert len(result) == 1
assert result[0]["type"] == "file_search"
assert result[0]["vector_store_ids"] == ["vs-123"]
assert result[0]["max_num_results"] == 10
def test_to_azure_ai_tools_web_search_with_location() -> None:
"""Test converting web_search dict tool with user location."""
tool = {
"type": "web_search_preview",
"user_location": {
"city": "Seattle",
"country": "US",
"region": "WA",
"timezone": "PST",
},
}
result = to_azure_ai_tools([tool])
assert len(result) == 1
assert result[0]["type"] == "web_search_preview"
def test_to_azure_ai_tools_image_generation() -> None:
"""Test converting image_generation dict tool."""
tool = {
"type": "image_generation",
"model": "gpt-image-1",
"size": "1024x1024",
"quality": "high",
}
result = to_azure_ai_tools([tool])
assert len(result) == 1
assert result[0]["type"] == "image_generation"
assert result[0]["model"] == "gpt-image-1"
def test_prepare_mcp_tool_basic() -> None:
"""Test basic MCP tool conversion."""
tool = {"type": "mcp", "server_label": "my_tool", "server_url": "http://localhost:8080"}
result = _prepare_mcp_tool_for_azure_ai(tool)
assert result["server_label"] == "my_tool"
assert "http://localhost:8080" in result["server_url"]
def test_prepare_mcp_tool_with_description() -> None:
"""Test MCP tool with description."""
tool = {
"type": "mcp",
"server_label": "my_tool",
"server_url": "http://localhost:8080",
"server_description": "My MCP server",
}
result = _prepare_mcp_tool_for_azure_ai(tool)
assert result["server_description"] == "My MCP server"
def test_prepare_mcp_tool_with_headers() -> None:
"""Test MCP tool with headers (no project_connection_id)."""
tool = {
"type": "mcp",
"server_label": "my_tool",
"server_url": "http://localhost:8080",
"headers": {"X-Api-Key": "secret"},
}
result = _prepare_mcp_tool_for_azure_ai(tool)
assert result["headers"] == {"X-Api-Key": "secret"}
def test_prepare_mcp_tool_project_connection_takes_precedence() -> None:
"""Test project_connection_id takes precedence over headers."""
tool = {
"type": "mcp",
"server_label": "my_tool",
"server_url": "http://localhost:8080",
"headers": {"X-Api-Key": "secret"},
"project_connection_id": "my-conn",
}
result = _prepare_mcp_tool_for_azure_ai(tool)
assert result["project_connection_id"] == "my-conn"
assert "headers" not in result
def test_prepare_mcp_tool_approval_mode_always() -> None:
"""Test MCP tool with always_require approval mode."""
tool = {
"type": "mcp",
"server_label": "my_tool",
"server_url": "http://localhost:8080",
"require_approval": "always",
}
result = _prepare_mcp_tool_for_azure_ai(tool)
assert result["require_approval"] == "always"
def test_prepare_mcp_tool_approval_mode_never() -> None:
"""Test MCP tool with never_require approval mode."""
tool = {
"type": "mcp",
"server_label": "my_tool",
"server_url": "http://localhost:8080",
"require_approval": "never",
}
result = _prepare_mcp_tool_for_azure_ai(tool)
assert result["require_approval"] == "never"
def test_prepare_mcp_tool_approval_mode_dict() -> None:
"""Test MCP tool with dict approval mode."""
tool = {
"type": "mcp",
"server_label": "my_tool",
"server_url": "http://localhost:8080",
"require_approval": {"always": {"tool_names": ["sensitive_tool", "dangerous_tool"]}},
}
result = _prepare_mcp_tool_for_azure_ai(tool)
# The approval mode is passed through
assert "require_approval" in result
def test_create_text_format_config_pydantic_model() -> None:
"""Test creating text format config from Pydantic model."""
class MySchema(BaseModel):
name: str
value: int
result = create_text_format_config(MySchema)
assert result["type"] == "json_schema"
assert result["name"] == "MySchema"
assert result["strict"] is True
def test_create_text_format_config_json_schema_mapping() -> None:
"""Test creating text format config from json_schema mapping."""
config = {
"type": "json_schema",
"json_schema": {
"name": "MyResponse",
"schema": {"type": "object", "properties": {"name": {"type": "string"}}},
},
}
result = create_text_format_config(config)
assert result["type"] == "json_schema"
assert result["name"] == "MyResponse"
def test_create_text_format_config_json_object() -> None:
"""Test creating text format config for json_object type."""
result = create_text_format_config({"type": "json_object"})
assert result["type"] == "json_object"
def test_create_text_format_config_text() -> None:
"""Test creating text format config for text type."""
result = create_text_format_config({"type": "text"})
assert result["type"] == "text"
def test_create_text_format_config_invalid_raises() -> None:
"""Test invalid response_format raises error."""
with pytest.raises(IntegrationInvalidRequestException):
create_text_format_config({"type": "invalid"})
def test_convert_response_format_with_format_key() -> None:
"""Test _convert_response_format with nested format key."""
config = {"format": {"type": "json_object"}}
result = _convert_response_format(config)
assert result["type"] == "json_object"
def test_convert_response_format_json_schema_missing_schema_raises() -> None:
"""Test json_schema without schema raises error."""
with pytest.raises(IntegrationInvalidRequestException, match="requires a schema"):
_convert_response_format({"type": "json_schema", "json_schema": {}})
def test_convert_response_format_raw_json_schema_with_properties() -> None:
"""Test raw JSON schema with properties is wrapped in json_schema envelope."""
result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}, "title": "MyOutput"})
assert result["type"] == "json_schema"
assert result["name"] == "MyOutput"
assert result["strict"] is True
assert result["schema"]["additionalProperties"] is False
assert "title" not in result["schema"]
def test_convert_response_format_raw_json_schema_no_title() -> None:
"""Test raw JSON schema without title defaults name to 'response'."""
result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}})
assert result["name"] == "response"
def test_convert_response_format_raw_json_schema_with_anyof() -> None:
"""Test raw JSON schema with anyOf keyword is detected."""
result = _convert_response_format({"anyOf": [{"type": "string"}, {"type": "number"}]})
assert result["type"] == "json_schema"
assert result["strict"] is True
def test_from_azure_ai_tools_mcp_approval_mode_always() -> None:
"""Test from_azure_ai_tools converts MCP require_approval='always' to dict."""
tools = [
{
"type": "mcp",
"server_label": "my_mcp",
"server_url": "http://localhost:8080",
"require_approval": "always",
}
]
result = from_azure_ai_tools(tools)
assert len(result) == 1
assert result[0]["type"] == "mcp"
assert result[0]["require_approval"] == "always"
def test_from_azure_ai_tools_mcp_approval_mode_never() -> None:
"""Test from_azure_ai_tools converts MCP require_approval='never' to dict."""
tools = [
{
"type": "mcp",
"server_label": "my_mcp",
"server_url": "http://localhost:8080",
"require_approval": "never",
}
]
result = from_azure_ai_tools(tools)
assert len(result) == 1
assert result[0]["type"] == "mcp"
assert result[0]["require_approval"] == "never"
def test_from_azure_ai_tools_mcp_approval_mode_dict_always() -> None:
"""Test from_azure_ai_tools converts MCP dict require_approval with 'always' key."""
tools = [
{
"type": "mcp",
"server_label": "my_mcp",
"server_url": "http://localhost:8080",
"require_approval": {"always": {"tool_names": ["sensitive_tool", "dangerous_tool"]}},
}
]
result = from_azure_ai_tools(tools)
assert len(result) == 1
assert result[0]["type"] == "mcp"
assert result[0]["require_approval"] == {"always": {"tool_names": ["sensitive_tool", "dangerous_tool"]}}
def test_from_azure_ai_tools_mcp_approval_mode_dict_never() -> None:
"""Test from_azure_ai_tools converts MCP dict require_approval with 'never' key."""
tools = [
{
"type": "mcp",
"server_label": "my_mcp",
"server_url": "http://localhost:8080",
"require_approval": {"never": {"tool_names": ["safe_tool"]}},
}
]
result = from_azure_ai_tools(tools)
assert len(result) == 1
assert result[0]["type"] == "mcp"
assert result[0]["require_approval"] == {"never": {"tool_names": ["safe_tool"]}}
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260319"
version = "1.0.0b260330"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc5",
"agent-framework-core>=1.0.0rc6",
"azure-cosmos>=4.3.0,<5",
]
@@ -4,7 +4,7 @@ This folder contains samples for `agent-framework-azure-cosmos`.
| File | Description |
| --- | --- |
| [`cosmos_history_provider.py`](cosmos_history_provider.py) | Demonstrates an Agent using `CosmosHistoryProvider` with `AzureOpenAIResponsesClient` (project endpoint), provider-configured container name, and `session_id` partitioning. |
| [`cosmos_history_provider.py`](cosmos_history_provider.py) | Demonstrates an Agent using `CosmosHistoryProvider` with `FoundryChatClient` (configured against an Azure AI Foundry project endpoint), provider-configured container name, and `session_id` partitioning. |
## Prerequisites
@@ -4,7 +4,7 @@
import asyncio
import os
from agent_framework.azure import AzureOpenAIResponsesClient
from agent_framework.foundry import FoundryChatClient
from azure.identity.aio import AzureCliCredential
from dotenv import load_dotenv
@@ -17,13 +17,13 @@ load_dotenv()
This sample demonstrates CosmosHistoryProvider as an agent context provider.
Key components:
- AzureOpenAIResponsesClient configured with an Azure AI project endpoint
- FoundryChatClient configured with an Azure AI project endpoint
- CosmosHistoryProvider configured for Cosmos DB-backed message history
- Provider-configured container name with session_id as partition key
Environment variables:
AZURE_AI_PROJECT_ENDPOINT
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME
FOUNDRY_PROJECT_ENDPOINT
FOUNDRY_MODEL
AZURE_COSMOS_ENDPOINT
AZURE_COSMOS_DATABASE_NAME
AZURE_COSMOS_CONTAINER_NAME
@@ -34,8 +34,8 @@ Optional:
async def main() -> None:
"""Run the Cosmos history provider sample with an Agent."""
project_endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT")
deployment_name = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME")
project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT")
deployment_name = os.getenv("FOUNDRY_MODEL")
cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT")
cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME")
cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME")
@@ -49,16 +49,16 @@ async def main() -> None:
or not cosmos_container_name
):
print(
"Please set AZURE_AI_PROJECT_ENDPOINT, AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME, "
"Please set FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL, "
"AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME."
)
return
# 1. Create an Azure credential and Responses client using project endpoint auth.
# 1. Create an Azure credential and Foundry chat client using project endpoint auth.
async with AzureCliCredential() as credential:
client = AzureOpenAIResponsesClient(
client = FoundryChatClient(
project_endpoint=project_endpoint,
deployment_name=deployment_name,
model=deployment_name,
credential=credential,
)
@@ -124,16 +124,17 @@ class AgentFunctionApp(DFAppBase):
.. code-block:: python
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from agent_framework.azure import AgentFunctionApp
from agent_framework.openai import OpenAIChatCompletionClient
# Create agents with unique names
weather_agent = AzureOpenAIChatClient(...).as_agent(
weather_agent = OpenAIChatCompletionClient(...).as_agent(
name="WeatherAgent",
instructions="You are a helpful weather agent.",
tools=[get_weather],
)
math_agent = AzureOpenAIChatClient(...).as_agent(
math_agent = OpenAIChatCompletionClient(...).as_agent(
name="MathAgent",
instructions="You are a helpful math assistant.",
tools=[calculate],
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260319"
version = "1.0.0b260330"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc5",
"agent-framework-core>=1.0.0rc6",
"agent-framework-durabletask",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
@@ -1,6 +1,6 @@
# Azure OpenAI Configuration
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=your-deployment-name
AZURE_OPENAI_DEPLOYMENT_NAME=your-deployment-name
FUNCTIONS_WORKER_RUNTIME=python
# Azure Functions Configuration
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260319"
version = "1.0.0b260330"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0rc5",
"agent-framework-core>=1.0.0rc6",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
+2 -2
View File
@@ -64,7 +64,7 @@ from fastapi import FastAPI, Request
from fastapi.responses import Response, StreamingResponse
from agent_framework import Agent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.chatkit import simple_to_agent_input, stream_agent_response
from chatkit.server import ChatKitServer
@@ -75,7 +75,7 @@ from your_store import YourStore # type: ignore[import-not-found] # Replace wi
# Define your agent with tools
agent = Agent(
client=AzureOpenAIChatClient(credential=AzureCliCredential()),
client=OpenAIChatCompletionClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant.",
tools=[], # Add your tools here
)

Some files were not shown because too many files have changed in this diff Show More