Compare commits

..
Author SHA1 Message Date
425f27f989 .NET: Fixing issue where OpenTelemetry span is never exported in .NET in-process workflow execution (#4196)
* 1. Add reproduction test for issue #4155: workflow.run Activity never stopped in streaming OffThread path

The WorkflowRunActivity_IsStopped_Streaming_OffThread test demonstrates that
the workflow.run OpenTelemetry Activity created in StreamingRunEventStream.RunLoopAsync
is started but never stopped when using the OffThread/Default streaming execution.
The background run loop keeps running after event consumption completes, so the
using Activity? declaration never disposes until explicit StopAsync() is called.

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

2. Fix workflow.run Activity never stopped in streaming OffThread execution (#4155)

The workflow.run OpenTelemetry Activity in StreamingRunEventStream.RunLoopAsync
was scoped to the method lifetime via 'using'. Since the run loop only exits on
cancellation, the Activity was never stopped/exported until explicit disposal.

Fix: Remove 'using' and explicitly dispose the Activity when the workflow reaches
Idle status (all supersteps complete). A safety-net disposal in the finally block
handles cancellation and error paths.

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

* Add root-level workflow.session activity spanning run loop lifetime\n\nImplements two-level telemetry hierarchy per PR feedback from lokitoth:\n- workflow.session: spans the entire run loop / stream lifetime\n- workflow_invoke: per input-to-halt cycle, nested within the session\n\nThis ensures the session activity stays open across multiple turns,\nwhile individual run activities are created and disposed per cycle.\n\nAlso fixes linkedSource CancellationTokenSource disposal leak in\nStreamingRunEventStream (added using declaration)."

* Address Copilot review: fix Activity/CTS disposal, rename activity, add error tag\n\n1. LockstepRunEventStream: Remove 'using' from Activity in async iterator\n   and manually dispose in finally block (fixes #4155 pattern). Also dispose\n   linkedSource CTS in finally to prevent leak.\n2. Tags.cs: Add ErrorMessage (\"error.message\") tag for runtime errors,\n   distinct from BuildErrorMessage (\"build.error.message\").\n3. ActivityNames: Rename WorkflowRun from \"workflow_invoke\" to \"workflow.run\"\n   for cross-language consistency.\n4. WorkflowTelemetryContext: Fix XML doc to say \"outer/parent span\" instead\n   of \"root-level span\".\n5. ObservabilityTests: Assert WorkflowSession absence when DisableWorkflowRun\n   is true.\n6. WorkflowRunActivityStopTests: Fix streaming test race by disposing\n   StreamingRun before asserting activities are stopped.\n7. StreamingRunEventStream/LockstepRunEventStream: Use Tags.ErrorMessage\n   instead of Tags.BuildErrorMessage for runtime error events."

* Review fixes: revert workflow_invoke rename, use 'using' for linkedSource, move SessionStarted earlier\n\n- Revert ActivityNames.WorkflowRun back to \"workflow_invoke\" (OTEL semantic convention contract)\n- Use 'using' declaration for linkedSource CTS in LockstepRunEventStream (no timing sensitivity)\n- Move SessionStarted event before WaitForInputAsync in StreamingRunEventStream to match Lockstep behavior"

* Improve naming and comments in WorkflowRunActivityStopTests"

* Prevent session Activity.Current leak in lockstep mode, add nesting test

Save and restore Activity.Current in LockstepRunEventStream.Start() so the
session activity doesn't leak into caller code via AsyncLocal. Re-establish
Activity.Current = sessionActivity before creating the run activity in
TakeEventStreamAsync to preserve parent-child nesting.

Add test verifying app activities after RunAsync are not parented under the
session, and that the workflow_invoke activity nests under the session."

* Fix stale XML doc: WorkflowRun -> WorkflowInvoke in ObservabilityTests

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 19:42:45 +00:00
7d56a5a4d6 Update Python package versions to rc2 (#4258)
- Bump core and azure-ai to 1.0.0rc2
- Bump preview packages to 1.0.0b260225
- Update dependencies to >=1.0.0rc2
- Add CHANGELOG entries for changes since rc1
- Update uv.lock

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 19:37:44 +00:00
Peter IbekweandGitHub de9d886aba .NET: Support InvokeMcpTool for declarative workflows (#4204)
* Initial implementation of InvokeMcpTool in declarative workflow

* Cleaned up sample implementation

* Updated sample comments.

* Added missing executor routing attribute

* Fix PR comments.

* Updated based on PR comments.

* Updated based on PR comments.

* Removed unnecessary using statement.
2026-02-25 19:21:36 +00:00
Rishabh ChawlaandGitHub 2e26bb9387 [Purview] Mark responses as responses and fix epoch bug for python long overflow (#4225) 2026-02-25 18:40:36 +00:00
6138487888 Python: Phase 2: Embedding clients for Ollama, Bedrock, and Azure AI Inference (#4207)
* Phase 2: Embedding clients for Ollama, Bedrock, and Azure AI Inference

Add embedding client implementations to existing provider packages:

- OllamaEmbeddingClient: Text embeddings via Ollama's embed API
- BedrockEmbeddingClient: Text embeddings via Amazon Titan on Bedrock
- AzureAIInferenceEmbeddingClient: Text and image embeddings via Azure AI
  Inference, supporting Content | str input with separate model IDs for
  text (AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID) and image
  (AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID) endpoints

Additional changes:
- Rename EmbeddingCoT -> EmbeddingT, EmbeddingOptionsCoT -> EmbeddingOptionsT
- Add otel_provider_name passthrough to all embedding clients
- Register integration pytest marker in all packages
- Add lazy-loading namespace exports for Ollama and Bedrock embeddings
- Add image embedding sample using Cohere-embed-v3-english
- Add azure-ai-inference dependency to azure-ai package

Part of #1188

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

* Fix mypy duplicate name and ruff lint issues

- Rename second 'vector' variable to 'img_vector' in image embedding loop
- Combine nested with statements in tests
- Remove unused result assignments in tests

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

* updates from feedback

* Fix CI failures in embedding usage handling

- Fix Azure AI embedding mypy issues by normalizing vectors to list[float],
  safely accumulating optional usage token fields, and filtering None entries
  before constructing GeneratedEmbeddings
- Avoid Bandit false positive by initializing usage details as an empty dict
- Update OpenAI embedding tests to assert canonical usage keys
  (input_token_count/total_token_count)

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 17:45:08 +00:00
e3a5b915a6 .NET: Add Microsoft Fabric sample #3674 (#4230)
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2026-02-25 17:34:13 +00:00
84849a24ca Update .NET package version to rc2 (#4257)
- Bump RCNumber from 1 to 2
- Update GitTag to 1.0.0-rc2
- Update preview date stamps from 260219 to 260225

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 16:53:19 +00:00
Roger BarretoandGitHub 91675bde4f .NET: Add SharePoint sample #3674 (#4227) 2026-02-25 16:40:18 +00:00
westeyandGitHub 804dbb678b Add Additional Properties ADR (#4246)
* Add Additional Properties ADR

* Address PR comments
2026-02-25 14:47:52 +00:00
4dc35e9bb0 Python: Support Agent Skills (#4210)
* Python: Support Agent Skills

Add FileAgentSkillsProvider, a context provider that discovers and exposes
Agent Skills from filesystem directories following the Agent Skills
specification (https://agentskills.io/) progressive disclosure pattern:
advertise, load, read resources.

Changes:
- FileAgentSkillsProvider - discovers SKILL.md files from configured
  directories, advertises skills via system prompt injection, and provides
  load_skill / read_skill_resource tools for on-demand access.
- Internal helpers for skill discovery, frontmatter parsing, and secure
  resource reading (path traversal / symlink guards).
- Unit tests covering discovery, loading, resource reading, and security
  scenarios.
- Sample (basic_file_skills) demonstrating usage with an expense-report skill.

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

* Python: Move skills sample to samples/02-agents/basic_skills/

Align sample directory name with .NET equivalent (Agent_Step01_BasicSkills).

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

* fix code quality checks

* address pr review comment and code quality check issue

* address pr review comments

* move the sample to the skills folder

* update readme

* reame consts and use types for them

* leverage pathlib for working with files

* refactor the test

* supply schema to functions

* update readme

* update sample name

* address pr review comments

* fix failing lint check

* address failing check

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 13:02:26 +00:00
Korolev DmitryandGitHub 2ba7ee9ce5 .NET: Implement Task support for A2A Hosting package (#3732)
* implement task support?

* some metadata + session store impl

* address PR comments x1

* API reivew

* llast changes

* More test

* remove unsued import

* fix moq override

* refactoring

* ontaskupdated

* adjust to delegate

* fix encoding

* address PR comments: rework

* init 1

* renaming

* fix tests

* fix comment

* runmode rename

* rename

* rename

* use exxperimental api, allow experimental on project level

* throw on refereceTaskIds
2026-02-25 11:20:43 +00:00
4530504a3d Python: Azure AI Search provider improvements - EmbeddingGenerator, async context manager, KB message handling (#4212)
* small updates and improvements in the azure AISearch provider

* Fix mypy errors and embedding function test

- Use separate variable for embeddings result to avoid mypy type reassignment error
- Fix test_vectorized_query_with_embedding_function: use real async function
  instead of AsyncMock which falsely matches SupportsGetEmbeddings protocol

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

* fixes from feedback

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 06:47:26 +00:00
L. Elaine DazzioandGitHub 2ad0caf069 .NET: Fix JSON arrays of objects parsed as empty records when no schema is defined (#4199)
* fix: use HasSchema check in DetermineElementType to prevent empty records

When parsing JSON arrays containing objects without a predefined schema,
`DetermineElementType()` was creating a `VariableType` with an empty
(non-null) schema via `targetType.Schema?.Select(...) ?? []`. This caused
`ParseRecord` to take the schema-based parsing path, iterating over zero
schema fields and silently discarding all JSON properties.

The fix checks `targetType.HasSchema` and falls back to
`VariableType.RecordType` (which has `Schema = null`) when no schema is
defined, ensuring `ParseRecord` takes the dynamic `ParseValues()` path
that preserves all JSON properties.

Closes #4195

* test: add regression tests for schema-less JSON array-of-objects parsing (#4195)

Add two regression tests to JsonDocumentExtensionsTests:

1. ParseRecord_ObjectWithArrayOfObjects_NoSchema_PreservesNestedProperties
   - Parses a JSON object containing an array of objects using
     VariableType.RecordType (no schema) and verifies that nested
     object properties (name, role) are preserved in each element.
   - This is the exact scenario from issue #4195 where objects in
     arrays were being returned as empty dictionaries.

2. ParseList_ArrayOfObjects_NoSchema_PreservesProperties
   - Parses a JSON array of objects directly via ParseList with
     VariableType.ListType (no schema) and verifies all properties
     are preserved.

Both tests follow the existing Arrange/Act/Assert pattern and would
have failed before the DetermineElementType() fix (empty dictionaries
instead of populated ones).
2026-02-25 01:02:43 +00:00
23fe2c16b3 Python: Fixing issue #1366 - Thread corruption when max_iterations is reached. (#4234)
* Fix thread corruption when max_iterations exhausted (#1366)

When the function invocation loop exhausts max_iterations while the model
keeps requesting tools, the failsafe code path (calling the model with
tool_choice='none' and prepending fcc_messages) was unreachable because
'if response is not None: return response' short-circuited before it.

The fix removes the premature return so the failsafe always runs after
loop exhaustion, making a final model call with tool_choice='none' to
produce a clean text answer and prepending accumulated fcc_messages from
prior iterations. This matches the existing pattern used by the error
threshold and max_function_calls paths.

Also unskips test_max_iterations_limit and test_streaming_max_iterations_limit
which were previously skipped with 'needs investigation in unified API'.

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

* Add fix report for issue #1366

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

* Fix ruff formatting in _tools.py and test_issue_1366_thread_corruption.py

Apply ruff format to fix multi-line string concatenation and function call
formatting issues flagged by the linter.

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

* Add quality review for issue #1366 fix

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

* Remove temporary investigation docs.

* Address PR review: explicit enabled check in log condition, clarify mock behavior in test

- Add explicit function_invocation_configuration['enabled'] check to the
  'Maximum iterations reached' log condition in both non-streaming and
  streaming paths, making intent clearer when function invocation is disabled.
- Add comment in test_thread_safe_after_max_iterations_with_agent explaining
  that the failsafe response (tool_choice='none') is provided automatically
  by the mock client, not from run_responses.

* Blend fix and tests into project without issue-specific callouts

- Remove issue #1366 references from _tools.py comments
- Move regression tests from standalone test_issue_1366_thread_corruption.py
  into test_function_invocation_logic.py alongside existing max_iterations tests
- Clean up test docstrings to describe behavior generically
- Delete the standalone issue-specific test file

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 00:58:34 +00:00
Evan MattsonandGitHub 40d2fac29c [BREAKING] Python: Add InvokeFunctionTool action for declarative workflows (#3716)
* add(declarative): Declarative workflow InvokeFunctionTool feature

* Cleanup

* Address PR feedback

* Remove InvokeTool kind, consolidate to InvokeFunctionTool

* Fix sample locations

* pin azure-ai-projects to 2.0.0b3 due to breaking changes
2026-02-24 22:54:35 +00:00
Tao ChenandGitHub f77f40b987 Python: Fix workflow runner concurrent processing (#4143)
* Fix workflow runner concurrent processing

* Comments 1

* Add test
2026-02-24 16:36:04 +00:00
9a7d93909d .NET: Add Foundry Agents Tool Sample - Bing Custom Search (#3701)
* .NET: Add Bing Custom Search sample #3674

* Apply format fixes

* .NET: Improve Bing Custom Search sample with dual MEAI/Native SDK options

- Add MEAI (Option 1) and Native SDK (Option 2) agent creation patterns
- Add DefaultAzureCredential with standard WARNING comment
- Add sample to solution file and FoundryAgents README index
- Improve README with connection ID/instance name guidance
- Fix missing newline at EOF in .csproj
- Suppress CS8321 for unused local function pattern

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

* Address PR review comments for Bing Custom Search sample

- Add Async suffix to CreateAgentWithMEAI and CreateAgentWithNativeSDK methods
- Clarify comment to reference ResponseTool instead of BingCustomSearchTool
- Update README Option 1 description to accurately reflect SDK usage

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-24 14:57:16 +00:00
westeyandGitHub 1086d1d183 Revert devcontainer bug workaround (#4206) 2026-02-24 12:07:44 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ec6c5ad793 Bump esbuild and vite (#4178)
Bumps [esbuild](https://github.com/evanw/esbuild) to 0.27.3 and updates ancestor dependency [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). These dependencies need to be updated together.


Updates `esbuild` from 0.21.5 to 0.27.3
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.27.3)

Updates `vite` from 5.4.21 to 7.3.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.1/packages/vite)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.27.3
  dependency-type: indirect
- dependency-name: vite
  dependency-version: 7.3.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 10:10:29 +00:00
Vincent KocandGitHub f126f91a7c Python: docs(observability): add Comet Opik setup example (#3940)
* docs(observability): add Comet Opik setup example

* Update README.md
2026-02-24 09:59:16 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c8c2219a87 Bump werkzeug from 3.1.5 to 3.1.6 in /python (#4125)
Bumps [werkzeug](https://github.com/pallets/werkzeug) from 3.1.5 to 3.1.6.
- [Release notes](https://github.com/pallets/werkzeug/releases)
- [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/werkzeug/compare/3.1.5...3.1.6)

---
updated-dependencies:
- dependency-name: werkzeug
  dependency-version: 3.1.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 09:56:36 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3da3e98264 Bump ruff from 0.15.1 to 0.15.2 in /python (#4182)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.1 to 0.15.2.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.1...0.15.2)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 09:53:18 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
acff8f38dd Bump poethepoet from 0.41.0 to 0.42.0 in /python (#4183)
Bumps [poethepoet](https://github.com/nat-n/poethepoet) from 0.41.0 to 0.42.0.
- [Release notes](https://github.com/nat-n/poethepoet/releases)
- [Commits](https://github.com/nat-n/poethepoet/compare/v0.41.0...v0.42.0)

---
updated-dependencies:
- dependency-name: poethepoet
  dependency-version: 0.42.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 09:52:14 +00:00
L. Elaine DazzioandGitHub f78fa27215 Python: Fix doubled tool_call arguments in MESSAGES_SNAPSHOT when streaming (#4200)
* fix: prevent doubled tool_call arguments in MESSAGES_SNAPSHOT

When streaming with client-side tools, some providers send a full-
arguments replay after the streaming deltas complete. The `_emit_tool_call`
function unconditionally appends every arguments delta to the internal
`flow.tool_calls_by_id` tracking dictionary via `+=`. When the replay
contains the exact same complete arguments string that was already
accumulated from prior deltas, the arguments get doubled (e.g.,
`{"todoText":"buy groceries"}{"todoText":"buy groceries"}`).

This causes `MESSAGES_SNAPSHOT` events to contain invalid doubled JSON in
`tool_calls[].function.arguments`, breaking any client or middleware that
relies on snapshots for state reconstruction.

The fix adds a guard (mirroring the existing duplicate guard in
`_emit_text`) that detects when the incoming delta exactly equals the
already-accumulated arguments string, indicating a full-arguments replay
rather than an incremental delta. In this case the append is skipped,
preventing the doubling.

The `ToolCallArgsEvent` deltas are still emitted correctly for real-time
streaming — only the internal snapshot accumulator is guarded.

Fixes #4194

* fix: move duplicate check before event emission + add test

Address Copilot review feedback:
1. Move duplicate full-arguments replay detection BEFORE emitting
   ToolCallArgsEvent, for consistency with _emit_text() which returns
   early without emitting any events on replay detection.
2. Add test_emit_tool_call_skips_duplicate_full_arguments_replay() to
   verify the duplicate detection behavior for tool call arguments,
   matching the existing test pattern for text content.
2026-02-24 09:49:24 +00:00
acc49196c1 Python: updated integration tests and guidance (#4181)
* updated integration tests and guidance

* fixed merge test

* updated integration tests

* fix: remove duplicate --dist loadfile flag from pytest-xdist config

Only one --dist mode can be active at a time; the second value silently
overrides the first. Keep --dist worksteal (dynamic load balancing) and
remove the redundant --dist loadfile from all workflow files and
pyproject.toml configs.

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

* docs: add keep-in-sync notes for merge and integration test workflows

Both python-merge-tests.yml and python-integration-tests.yml share the
same parallel job structure. Added sync reminders in workflow file
comments, the python-testing SKILL.md, and CODING_STANDARD.md.

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

* refactor: remove RUN_INTEGRATION_TESTS flag

Integration test gating now uses two mechanisms:
- `@pytest.mark.integration` for test selection via `-m` filtering
- `skip_if_*_disabled` for credential/service availability checks

The RUN_INTEGRATION_TESTS env var was redundant since the marker handles
selection and the skip decorators already check for actual credentials.

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

* fix: sync missing env vars from merge-tests to integration-tests

Add OPENAI_EMBEDDINGS_MODEL_ID and AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME
to python-integration-tests.yml to match python-merge-tests.yml.

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

* fix: remove remaining RUN_INTEGRATION_TESTS from embedding tests and docs

Missed test_openai_embedding_client.py and vector-stores README in the
earlier cleanup.

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

* set functions tests to 3.10

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-24 09:35:46 +00:00
6305e3e092 Python: feat(python): Add embedding abstractions and OpenAI implementation (Phase 1) (#4153)
* feat(python): Add embedding abstractions and OpenAI implementation (Phase 1)

This PR contains two parts:

1. **Overall migration plan** for porting vector stores and embeddings from
   Semantic Kernel to Agent Framework (docs/features/vector-stores-and-embeddings/README.md)
   covering all 10 phases from core abstractions through connectors and TextSearch.

2. **Phase 1 implementation** — core embedding abstractions and OpenAI/Azure OpenAI
   embedding clients:

   Core types (_types.py):
   - EmbeddingGenerationOptions TypedDict (total=False)
   - Embedding[EmbeddingT] generic class with model_id, dimensions, created_at
   - GeneratedEmbeddings[EmbeddingT, EmbeddingOptionsT] list container with options, usage
   - EmbeddingInputT (default str) and EmbeddingT (default list[float]) TypeVars

   Protocol + base class (_clients.py):
   - SupportsGetEmbeddings protocol — Generic[EmbeddingInputT, EmbeddingT, OptionsContraT]
   - BaseEmbeddingClient ABC — Generic[EmbeddingInputT, EmbeddingT, OptionsCoT]

   Telemetry (observability.py):
   - EmbeddingTelemetryLayer with gen_ai.operation.name = "embeddings"

   OpenAI implementation (openai/_embedding_client.py):
   - RawOpenAIEmbeddingClient, OpenAIEmbeddingClient, OpenAIEmbeddingOptions
   - Uses _ensure_client() factory pattern

   Azure OpenAI implementation (azure/_embedding_client.py):
   - AzureOpenAIEmbeddingClient following AzureOpenAIChatClient pattern
   - Supports API key, Entra ID credentials, env var configuration

   Tests:
   - 47 unit tests for types, protocol, base class, OpenAI, and Azure clients
   - 6 integration tests (gated behind RUN_INTEGRATION_TESTS + credentials)

   Samples:
   - samples/02-agents/embeddings/openai_embeddings.py
   - samples/02-agents/embeddings/azure_openai_embeddings.py

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

* fix: Add AzureOpenAIEmbeddingClient to azure __init__.pyi stub

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

* ci: Add embedding env vars to Python integration tests

Map OPENAI_EMBEDDING_MODEL_ID and AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME
from GitHub vars to the integration test environment.

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

* fix: Handle base64 encoding_format in OpenAI embedding client

When encoding_format='base64' is used, the OpenAI API returns base64-encoded
floats instead of a JSON array. Decode these automatically to list[float]
so the return type stays consistent regardless of encoding format.

Also adds a unit test for base64 decoding and fixes minor docstring/import issues.

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

* fix: Only record INPUT_TOKENS for embedding telemetry

Embeddings have no output/completion tokens. Remove OUTPUT_TOKENS recording
which was double-counting prompt_tokens via the total_tokens fallback.

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

* fix: Resolve mypy variance error and lint warning

Use contravariant/covariant TypeVars for SupportsGetEmbeddings Protocol.
Combine nested if into single statement in telemetry layer.

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

* fix: Make EmbeddingCoT invariant for mypy compatibility

GeneratedEmbeddings is invariant in its type param, so the Protocol
TypeVar cannot be covariant.

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

* fix: Address PR review - empty values guard, service_url for telemetry

- Add early return for empty values in get_embeddings to avoid unnecessary API calls
- Add service_url() method to RawOpenAIEmbeddingClient for proper telemetry endpoint reporting
- Add test for empty values behavior

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

* Python: Fix OpenAI chat client compatibility with third-party endpoints and OTel 0.4.14 (#4161)

* Fix system message content sent as list instead of string

Some OpenAI-compatible endpoints (e.g. NVIDIA NIM) reject system messages
when content is a list of content parts. This change flattens system and
developer message content to a plain string in the Chat Completions client.

Fixes https://github.com/microsoft/agent-framework/issues/1407

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

* Fix compatibility with opentelemetry-semantic-conventions-ai 0.4.14

Version 0.4.14 removed several LLM_* attributes from SpanAttributes
(LLM_SYSTEM, LLM_REQUEST_MODEL, LLM_RESPONSE_MODEL, LLM_REQUEST_MAX_TOKENS,
LLM_REQUEST_TEMPERATURE, LLM_REQUEST_TOP_P, LLM_TOKEN_TYPE).

Move these to the OtelAttr enum with their well-known gen_ai.* string values
and update all references in observability.py and tests.

Fixes https://github.com/microsoft/agent-framework/issues/4160

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

* Flatten text-only message content to string for all roles

Extend the system/developer fix to all message roles. Text-only content
lists are now post-processed into plain strings, while multimodal content
(text + images/audio) remains as a list. This fixes compatibility with
OpenAI-like endpoints that cannot deserialize list content (e.g. Foundry
Local's Neutron backend).

Partially fixes https://github.com/microsoft/agent-framework/issues/4084

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

* Fix streaming text lost when usage data in same chunk

Some providers (e.g. Gemini) include both usage data and text content
in the same streaming chunk. The early return on chunk.usage caused
text and tool call parsing to be skipped entirely. Remove the early
return and process usage alongside text/tool calls.

Fixes https://github.com/microsoft/agent-framework/issues/3434

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

* Fix mypy errors in _chat_client.py

Rename shadowed variable 'args' in system/developer branch to 'sys_args'
and rename loop variable 'content' to 'msg_content' to avoid type conflict.

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

---------

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

* reorder imports

* fix: Use OtelAttr.REQUEST_MODEL instead of removed SpanAttributes.LLM_REQUEST_MODEL

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

* docs: Add score_threshold to vector store plan

Reference SK .NET PR #13501 for score threshold filtering semantics.
Include score_threshold in SearchOptions from Phase 3.

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

* docs: Add reference to roji's SK .NET MEVD work for SQL connectors

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

* fix: Clear env vars in construction tests to avoid CI leakage

Tests for missing API key / model ID now use monkeypatch.delenv to ensure
env vars from the integration test environment don't prevent the expected
ValueError from being raised.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-24 07:40:20 +00:00
CopilotGitHubCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>eavanvalkenburgeavanvalkenburg
7b24d9160d Python: Add Foundry Memory Context Provider (#3943)
* Initial plan

* Add FoundryMemoryProvider and tests

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Add sample and documentation for FoundryMemoryProvider

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Address code review feedback for FoundryMemoryProvider

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Address PR review comments: Add DEFAULT_SOURCE_ID, use logging.getLogger, move state to session.state

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Fix Foundry memory ItemParam usage and exports

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

* Refactor provider hook state and standardize source IDs

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

* Support endpoint-based Foundry memory init

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

* updated implementation and sample

* updated code and samples

* Fix foundry memory provider tests: mock structure and field names

- Use Mock objects with memory_item.content for memory mocks
- Assert 'content' instead of 'text' on SDK message items
- Update exception types from ServiceInitializationError to ValueError
- Remove unused ServiceInitializationError import

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

* Fix mypy errors in foundry memory provider

Add type: ignore[arg-type] for scope (str | None vs str) and items
(list variance) passed to Azure SDK methods.

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

* fix import

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <github@vanvalkenburg.eu>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-24 06:05:53 +00:00
de612c47f5 Python: Add CreateConversationExecutor, fix input routing, remove unused handler layer (#4159)
* Fixed declarative deep research sample

* Small fix

* Resolved comment

* Add CreateConversationExecutor, fix input routing, remove unused handler layer

* Address Copilot feedback

* Fix System.ConversationId

---------

Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
2026-02-24 01:59:39 +00:00
bb4fe48c9a Python: Enhance Azure AI Search Citations with Document URLs in Foundry V2 (#4028)
* Python: Enhance Azure AI Search citations with document URLs in Foundry V2 (Responses API)

Override _parse_response_from_openai and _parse_chunk_from_openai in
RawAzureAIClient to extract get_urls from azure_ai_search_call_output
items and enrich url_citation annotations with document-specific URLs.

- Non-streaming: first pass collects get_urls, post-processes annotations
- Streaming: captures search output state, enriches url_citation events
  (also handles url_citation annotation type not handled by base class)
- Updated V2 sample to demonstrate citation URL extraction
- Added 14 unit tests covering extraction, enrichment, and edge cases

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

* refactor: rework search citation enrichment to override _inner_get_response

- Remove all direct openai/pydantic imports from _client.py
- Override _inner_get_response instead of _parse_response_from_openai/_parse_chunk_from_openai
- Use closure-local state for streaming instead of instance-level _streaming_search_get_urls
- Add _build_url_citation_content helper for streaming url_citation handling
- Fix mypy errors by using str(value or '') for Annotation TypedDict fields
- Fix docstring to say 'citation' instead of 'url_citation'
- Update tests to match new approach

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

* fix: handle streaming search citations from output_item.done events

The azure_ai_search_call_output item only has populated output data
(including get_urls) in the response.output_item.done event, not in
the response.output_item.added event. Also removed the search_get_urls
guard on url_citation handling so annotations are always produced even
if get_urls haven't been captured yet.

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

* addressed comments

* refactor: address PR review - eliminate type: ignore[assignment] pattern

Call super()._inner_get_response() independently in each branch instead
of once at the top with union type reassignment. Non-streaming uses
two-arg super() in the closure; streaming uses cast() for type narrowing.

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

* refactor: remove defensive patterns per PR review

- Replace all getattr() with direct attribute access
- Remove cast() for streaming branch, use type: ignore[assignment]
- Simplify _build_url_citation_content to use dict access directly
- Simplify _extract_azure_search_urls to use item.type/item.output
- Handle empty list output from streaming 'added' events
- Update tests to match actual runtime types (objects, not dicts)

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

* mypy fix

* small fixes

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-24 01:21:33 +00:00
Tao ChenandGitHub b7efaae709 Python: Automate sample validation (#4193)
* Automate sample validation: part 1

* Automate sample validation: part 2

* Create GH workflow

* comments

* Fix mypy
2026-02-24 01:08:16 +00:00
55398e21df Python: Add max_function_calls to FunctionInvocationConfiguration (#2329) (#4175)
* Add max_function_calls to FunctionInvocationConfiguration (#2329)

Add a new per-request max_function_calls setting to FunctionInvocationConfiguration
that limits the total number of individual function invocations across all iterations
within a single get_response call. This complements max_iterations (which limits LLM
roundtrips) by providing a hard cap on actual tool executions regardless of parallelism.

- Add max_function_calls field to FunctionInvocationConfiguration (default: None/unlimited)
- Track cumulative function call count in both streaming and non-streaming tool loops
- Force tool_choice='none' when the limit is reached
- Add validation in normalize_function_invocation_configuration
- Improve docstrings for FunctionInvocationConfiguration, FunctionTool, and @tool
  to clarify semantics of max_iterations vs max_function_calls vs max_invocations
- Add tests for parallel calls, single calls, unlimited mode, and config validation

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

* Add sample for controlling total tool executions

Showcases all three mechanisms for limiting tool executions:
1. max_iterations — caps LLM roundtrips
2. max_function_calls — caps total individual function invocations per request
3. max_invocations — lifetime cap on a specific tool instance
Plus a combined scenario demonstrating defense in depth.

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

* Suppress ruff E305/fmt in hosting sample to preserve XML doc tags

The XML snippet tags (# <create_agent> / # </create_agent>) are used for
docs extraction and must stay adjacent to the code they wrap. Both ruff
check (E305) and ruff format add blank lines after the function definition,
pushing the closing tag away. Suppress with ruff: noqa: E305 and fmt: off.

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

* Add per-agent tool wrapping scenario to control_total_tool_executions sample

Show that wrapping the same callable with @tool multiple times creates
independent FunctionTool instances with separate invocation counters,
enabling per-agent max_invocations budgets for shared functions.

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

* Clarify max_function_calls is a best-effort limit

The limit is checked after each batch of parallel calls completes, so the
current batch always runs to completion even if it overshoots the limit.

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

* Address PR review: fix docstring reference, clarify best-effort in sample

- Fix malformed Sphinx :attr: role in FunctionTool docstring — use plain
  backtick reference instead
- Update sample to say 'best-effort cap' instead of 'hard cap' for
  max_function_calls, noting it's checked between iterations
- Parametrize pattern is correct (fixture override, matching existing tests)

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

* clarify max_invocations limits

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-24 01:00:25 +00:00
11628c3166 Python: Fix structured_output propagation in ClaudeAgent (#4137)
* Fix structured_output propagation in ClaudeAgent

Capture structured_output from ResultMessage in _get_stream() and
propagate it to AgentResponse.value via a custom finalizer. Previously
structured_output was silently discarded, making output_format unusable.

Fixes #4095

* Address review feedback: use value parameter instead of private properties

- Extend AgentResponse.from_updates() to accept optional value parameter
- Remove structured_output yield from _get_stream()
- Update _finalize_response() to pass value via public API
- Update streaming test to use get_final_response()

* Fix mypy errors: add value parameter to from_updates overloads

Add value parameter to both @overload signatures of
AgentResponse.from_updates() so mypy recognizes the argument.

---------

Co-authored-by: Amit Mukherjee <amimukherjee@microsoft.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
2026-02-23 18:45:02 +00:00
CopilotGitHubmarkwallace-microsoftwestey-mcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
69eabcd1fc .NET: Fix case-sensitive property mismatch in CosmosChatHistoryProvider queries (#3485)
* Initial plan

* Fix case-sensitivity bug in Cosmos queries and add tests

Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>

* Fix style issues and update tests for new API

Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
2026-02-23 18:27:21 +00:00
8b69c2ea12 .NET: Add Foundry Agents Tool Sample - Web Search (#4040)
* .NET: Add Web Search sample #3674

* .NET: Fix WebSearch sample to use Responses API built-in web search

Remove incorrect Bing Grounding connection ID requirement from the
WebSearch sample. The web search tool uses the OpenAI Responses API
built-in capability and does not need a connection ID.

- Remove AZURE_FOUNDRY_BING_CONNECTION_ID env var requirement
- Use HostedWebSearchTool() without connectionId properties
- Refactor creation options into local functions (MEAI + NativeSDK)
- Switch from AzureCliCredential to DefaultAzureCredential
- Update README to reflect correct prerequisites

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

* Fix README to align DefaultAzureCredential docs with code

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

* Address review: add project to solution, README, simplify response text

- Add FoundryAgents_Step25_WebSearch to agent-framework-dotnet.slnx
- Add web search sample entry to parent FoundryAgents README.md
- Simplify text response extraction to use response.Text directly

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

* Fix merge conflict in slnx solution file

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-23 17:15:02 +00:00
CopilotGitHubcrickmancopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
66dbef3e51 Make Cosmos DB tests read COSMOSDB_ENDPOINT and COSMOSDB_KEY from environment variables (#4156)
* Initial plan

* Make Cosmos DB tests read COSMOSDB_ENDPOINT and COSMOSDB_KEY from environment variables

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Rename EmulatorEndpoint/EmulatorKey static fields to use s_ prefix convention

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>
2026-02-23 16:44:31 +00:00
892c177e93 .NET: Fix FunctionInvocationDelegatingAgent to preserve all AgentRunOptions properties (#4179)
When converting base AgentRunOptions to ChatClientAgentRunOptions, the middleware
now preserves AllowBackgroundResponses, ContinuationToken, and AdditionalProperties
in addition to ResponseFormat.

Added unit test verifying all properties are preserved during the conversion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-23 16:37:43 +00:00
Dmytro StrukandGitHub ba454552c5 Updated GitHub action for manual integration tests (#4147)
* Updated merge test permissions

* Removed repo check

* Added fetch from main for comparison

* Updated path detection logic

* Small updates

* Reverted file rename

* Created dedicated workflows for integration tests

* Small fix for Python

* Small fixes

* Small update

* Small update

* Added tests check for Python
2026-02-23 15:37:06 +00:00
westeyandGitHub e45e58108b .NET: [BREAKING] Add ChatClient decorator for calling AIContextProviders (#4097)
* Add ChatClient decorator for calling AIContextProviders

* Format new files

* Address PR comments

* Revert problematic change

* Rename Use to UseAIContextProvider
2026-02-23 15:06:21 +00:00
6e4562e354 .NET: Add Foundry Agents Tool Sample - Memory Search (#3700)
* .NET: Add Memory Search sample #3674

* Apply format fixes

* Add MemorySearch sample to solution, FoundryAgents and AgentWithMemory READMEs

- Add FoundryAgents_Step26_MemorySearch.csproj to agent-framework-dotnet.slnx
- Add Memory Search entry to FoundryAgents/README.md samples table
- Add cross-reference from AgentWithMemory/README.md to MemorySearch sample

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-23 14:54:05 +00:00
westeyandGitHub 060d8fcadd .NET: Simplify store=false scenario for responses (#4124)
* Simplify store=false scenario for responses

* Mark AsIChatClientWithStoredOutputDisabled as Experimental
2026-02-23 12:24:32 +00:00
d8b9409e96 Python: (ag-ui): Add Workflow Support, Harden Streaming Semantics, and add Dynamic Handoff Demo (#3911)
* fix Workflow.as_agent() streaming regression in ag-ui

* Address PR feedback

* workflows wip

* wip

* wip

* Workflow AG-UI demo

* Fixes for handoff workflow demo

* Fixes to workflows support in AG-UI

* Fixes

* Add headers to some demo files

* Fix comment

* Fixes for store

* Make _input_schema lazy-loaded

* fix mypy

* revert session change to handoff only for now

---------

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-02-23 11:59:56 +00:00
b1c7c7c844 Python: Fix OpenAI chat client compatibility with third-party endpoints and OTel 0.4.14 (#4161)
* Fix system message content sent as list instead of string

Some OpenAI-compatible endpoints (e.g. NVIDIA NIM) reject system messages
when content is a list of content parts. This change flattens system and
developer message content to a plain string in the Chat Completions client.

Fixes https://github.com/microsoft/agent-framework/issues/1407

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

* Fix compatibility with opentelemetry-semantic-conventions-ai 0.4.14

Version 0.4.14 removed several LLM_* attributes from SpanAttributes
(LLM_SYSTEM, LLM_REQUEST_MODEL, LLM_RESPONSE_MODEL, LLM_REQUEST_MAX_TOKENS,
LLM_REQUEST_TEMPERATURE, LLM_REQUEST_TOP_P, LLM_TOKEN_TYPE).

Move these to the OtelAttr enum with their well-known gen_ai.* string values
and update all references in observability.py and tests.

Fixes https://github.com/microsoft/agent-framework/issues/4160

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

* Flatten text-only message content to string for all roles

Extend the system/developer fix to all message roles. Text-only content
lists are now post-processed into plain strings, while multimodal content
(text + images/audio) remains as a list. This fixes compatibility with
OpenAI-like endpoints that cannot deserialize list content (e.g. Foundry
Local's Neutron backend).

Partially fixes https://github.com/microsoft/agent-framework/issues/4084

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

* Fix streaming text lost when usage data in same chunk

Some providers (e.g. Gemini) include both usage data and text content
in the same streaming chunk. The early return on chunk.usage caused
text and tool call parsing to be skipped entirely. Remove the early
return and process usage alongside text/tool calls.

Fixes https://github.com/microsoft/agent-framework/issues/3434

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

* Fix mypy errors in _chat_client.py

Rename shadowed variable 'args' in system/developer branch to 'sys_args'
and rename loop variable 'content' to 'msg_content' to avoid type conflict.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-23 10:05:36 +00:00
Dmytro StrukandGitHub 75ff4f486f Added new GitHub action for manual integration test run based on PR (#4135)
* Added new GitHub action for manual integration test run based on PR

* Addressed comments

* Added branch name as input

* Small improvements
2026-02-20 21:33:22 +00:00
7ba636d642 .NET: Support Agent Skills (#4122)
* support agent skills

* make the new agent skill provider experimental

* Fix file encoding: add UTF-8 BOM to .cs files

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

* Fix final newline and simplify new expressions

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

* Fix broken links in Agent Skills sample README

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

* Add null check for skillPaths parameter

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

* Normalize references

* normilize skill path

* address comments regarding symlink check

* address comments

* fix failing test + regex improvements

* small optimizations and improvments

* address pr review comments

* Update dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>

* address pr review comments

* address pr review comments

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2026-02-20 21:05:56 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
44aec2009f Bump flask from 3.1.2 to 3.1.3 in /python (#4126)
Bumps [flask](https://github.com/pallets/flask) from 3.1.2 to 3.1.3.
- [Release notes](https://github.com/pallets/flask/releases)
- [Changelog](https://github.com/pallets/flask/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/flask/compare/3.1.2...3.1.3)

---
updated-dependencies:
- dependency-name: flask
  dependency-version: 3.1.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 19:32:25 +00:00
CopilotGitHubwestey-mcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
06c6ec052e .NET: Fix failing vision integration tests by using local test files (#4128)
* Initial plan

* Fix failing vision integration tests by using local test files

Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>

* Fix net472 build error: replace File.ReadAllBytesAsync with compatible helper using AppContext.BaseDirectory

Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>

* Simplify ReadLocalFile: return byte[] directly instead of Task<byte[]>

Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>
2026-02-20 15:15:26 +00:00
b3ac4777ba Replace inline string literals with constants in ChatHistoryMemoryProvider (#4096)
Extract 11 private const string fields for vector store property names
(Key, Role, MessageId, AuthorName, ApplicationId, AgentId, UserId,
SessionId, Content, CreatedAt, ContentEmbedding) and replace all inline
usages across the collection definition, store dictionary, search result
access, and filter expressions.

Fixes #3801

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-20 12:00:21 +00:00
0e2fcb1c7f .NET: Add Foundry Memory Context Provider (#3522)
* Add Azure AI Foundry Memory Context Provider with unit tests

* Add FoundryMemory integration tests and sample application

* Fix ClearStoredMemoriesAsync to handle 404 gracefully and rename to EnsureStoredMemoriesDeletedAsync

* Refactor FoundryMemory: simplify architecture and add memory store creation

- Remove IFoundryMemoryOperations interface (was only for test mocking)
- Remove AIProjectClientMemoryOperations wrapper class
- Provider now directly uses AIProjectClient with internal extension methods
- Extension methods return actual response models instead of extracted values
- Remove WaitForUpdateCompletionAsync from provider (sample uses delay)
- Simplify EnsureMemoryStoreCreatedAsync to return Task instead of Task<bool>
- Add memory store creation with chat_model and embedding_model
- Add UpdateMemoriesResponse with SupersededBy and Error fields
- Simplify unit tests to focus on constructor validation and serialization
- Update sample to use simple delay for memory processing wait

* Add waiting operation for memory store updates

* Fix UTF-8 BOM encoding for FoundryMemory csproj files

* Update copilot instructions for UTF-8 BOM and fix sample API rename

* Fix UTF-8 BOM encoding for TestableAIProjectClient.cs

* Add missing response headers for TS

* Changing default embedding

* Using the SDK Models

* Program update

* Remove debugging code from sample

* Adapt FoundryMemoryProvider to new AIContextProvider API and add UTF-8 BOM instruction

- Override ProvideAIContextAsync/StoreAIContextAsync instead of removed virtual InvokingAsync/InvokedAsync
- Use ProviderSessionState<State> for session-scoped state management (matching Mem0Provider pattern)
- Replace constructor-based scope with stateInitializer delegate
- Remove Serialize method (no longer on base class)
- Add SearchInputMessageFilter, StorageInputMessageFilter, StateKey to options
- Update sample to use AIContextProviders list instead of AIContextProviderFactory
- Update unit and integration tests for new API
- Add UTF-8 BOM encoding and --tl:off instructions to dotnet/AGENTS.md

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

* Use DefaultAzureCredential in Foundry Memory sample

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

* Address PR review comments for FoundryMemoryProvider

- Move memoryStoreName from options to required constructor parameter
- Make FoundryMemoryProviderScope require non-null/whitespace scope in constructor
- Make Scope property read-only (getter only)
- Replace ConcurrentQueue with single last update ID to fix memory leak
- Only clear pending update ID after successful completion
- Add delete success logging
- Mark FoundryMemoryProvider with [Experimental] attribute
- Update unit tests for new API signatures

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

* Use Throw.IfNullOrWhitespace for scope and memoryStoreName validation

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-20 11:25:06 +00:00
360 changed files with 35255 additions and 5762 deletions
+1 -5
View File
@@ -1,10 +1,6 @@
{
"name": "C# (.NET)",
//"image": "mcr.microsoft.com/devcontainers/dotnet",
// Workaround for https://github.com/devcontainers/images/issues/1752
"build": {
"dockerfile": "dotnet.Dockerfile"
},
"image": "mcr.microsoft.com/devcontainers/dotnet",
"features": {
"ghcr.io/devcontainers/features/azure-cli:1.2.9": {},
"ghcr.io/devcontainers/features/github-cli:1": {
-5
View File
@@ -1,5 +0,0 @@
FROM mcr.microsoft.com/devcontainers/universal:latest
# Remove Yarn repository with expired GPG key to prevent apt-get update failures
# Tracking issue: https://github.com/devcontainers/images/issues/1752
RUN rm -f /etc/apt/sources.list.d/yarn.list
@@ -0,0 +1,102 @@
#
# Dedicated .NET integration tests workflow, called from the manual integration test orchestrator.
# Only runs integration test matrix entries (net10.0 and net472).
#
name: dotnet-integration-tests
on:
workflow_call:
inputs:
checkout-ref:
description: "Git ref to checkout (e.g., refs/pull/123/head)"
required: true
type: string
permissions:
contents: read
id-token: write
jobs:
dotnet-integration-tests:
strategy:
fail-fast: false
matrix:
include:
- { targetFramework: "net10.0", os: "ubuntu-latest", configuration: Release }
- { targetFramework: "net472", os: "windows-latest", configuration: Release }
runs-on: ${{ matrix.os }}
environment: integration
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
sparse-checkout: |
.
.github
dotnet
python
workflow-samples
- name: Start Azure Cosmos DB Emulator
if: runner.os == 'Windows'
shell: pwsh
run: |
Write-Host "Launching Azure Cosmos DB Emulator"
Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator"
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v5.1.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Build dotnet solutions
shell: bash
run: |
export SOLUTIONS=$(find ./dotnet/ -type f -name "*.slnx" | tr '\n' ' ')
for solution in $SOLUTIONS; do
dotnet build $solution -c ${{ matrix.configuration }} --warnaserror
done
- name: Azure CLI Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Set up Durable Task and Azure Functions Integration Test Emulators
if: matrix.os == 'ubuntu-latest'
uses: ./.github/actions/azure-functions-integration-setup
- name: Run Integration Tests
shell: bash
run: |
export INTEGRATION_TEST_PROJECTS=$(find ./dotnet -type f -name "*IntegrationTests.csproj" | tr '\n' ' ')
for project in $INTEGRATION_TEST_PROJECTS; do
target_frameworks=$(dotnet msbuild $project -getProperty:TargetFrameworks -p:Configuration=${{ matrix.configuration }} -nologo 2>/dev/null | tr -d '\r')
if [[ "$target_frameworks" == *"${{ matrix.targetFramework }}"* ]]; then
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx --filter "Category!=IntegrationDisabled"
else
echo "Skipping $project - does not support target framework ${{ matrix.targetFramework }} (supports: $target_frameworks)"
fi
done
env:
COSMOSDB_ENDPOINT: https://localhost:8081
COSMOSDB_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==
OpenAI__ApiKey: ${{ secrets.OPENAI__APIKEY }}
OpenAI__ChatModelId: ${{ vars.OPENAI__CHATMODELID }}
OpenAI__ChatReasoningModelId: ${{ vars.OPENAI__CHATREASONINGMODELID }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AzureAI__Endpoint: ${{ secrets.AZUREAI__ENDPOINT }}
AzureAI__DeploymentName: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
AzureAI__BingConnectionId: ${{ vars.AZUREAI__BINGCONECTIONID }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MEDIA_DEPLOYMENT_NAME: ${{ vars.FOUNDRY_MEDIA_DEPLOYMENT_NAME }}
FOUNDRY_MODEL_DEPLOYMENT_NAME: ${{ vars.FOUNDRY_MODEL_DEPLOYMENT_NAME }}
FOUNDRY_CONNECTION_GROUNDING_TOOL: ${{ vars.FOUNDRY_CONNECTION_GROUNDING_TOOL }}
@@ -0,0 +1,134 @@
#
# This workflow allows manually running integration tests against an open PR or a branch.
# Go to Actions → "Integration Tests (Manual)" → Run workflow → enter a PR number or branch name.
#
# It calls dedicated integration-only workflows (dotnet-integration-tests and python-integration-tests),
# passing a ref so they check out and test the correct code.
# Changed paths are detected here so only the relevant test suites run.
#
name: Integration Tests (Manual)
on:
workflow_dispatch:
inputs:
pr-number:
description: "PR number to run integration tests against (leave empty if using branch)"
required: false
type: string
default: ""
branch:
description: "Branch name to run integration tests against (leave empty if using PR number)"
required: false
type: string
default: ""
permissions:
contents: read
pull-requests: read
id-token: write
concurrency:
group: integration-tests-manual-${{ github.event.inputs.pr-number || github.event.inputs.branch }}
cancel-in-progress: true
jobs:
resolve-ref:
name: Resolve ref
runs-on: ubuntu-latest
outputs:
checkout-ref: ${{ steps.resolve.outputs.checkout-ref }}
dotnet-changes: ${{ steps.detect-changes.outputs.dotnet }}
python-changes: ${{ steps.detect-changes.outputs.python }}
steps:
- name: Resolve checkout ref
id: resolve
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.inputs.pr-number }}
BRANCH: ${{ github.event.inputs.branch }}
REPO: ${{ github.repository }}
run: |
if [ -n "$PR_NUMBER" ] && [ -n "$BRANCH" ]; then
echo "::error::Please provide either a PR number or a branch name, not both."
exit 1
fi
if [ -z "$PR_NUMBER" ] && [ -z "$BRANCH" ]; then
echo "::error::Please provide either a PR number or a branch name."
exit 1
fi
if [ -n "$PR_NUMBER" ]; then
if ! echo "$PR_NUMBER" | grep -Eq '^[0-9]+$'; then
echo "::error::Invalid PR number. Only numeric values are allowed."
exit 1
fi
PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state)
PR_STATE=$(echo "$PR_DATA" | jq -r '.state')
if [ "$PR_STATE" != "OPEN" ]; then
echo "::error::PR #$PR_NUMBER is not open (state: $PR_STATE)"
exit 1
fi
echo "checkout-ref=refs/pull/$PR_NUMBER/head" >> "$GITHUB_OUTPUT"
echo "Running integration tests for PR #$PR_NUMBER"
else
if ! echo "$BRANCH" | grep -Eq '^[a-zA-Z0-9_./-]+$'; then
echo "::error::Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes are allowed."
exit 1
fi
echo "checkout-ref=$BRANCH" >> "$GITHUB_OUTPUT"
echo "Running integration tests for branch $BRANCH"
fi
- name: Detect changed paths
id: detect-changes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.inputs.pr-number }}
BRANCH: ${{ github.event.inputs.branch }}
REPO: ${{ github.repository }}
run: |
if [ -n "$PR_NUMBER" ]; then
CHANGED_FILES=$(gh pr diff "$PR_NUMBER" --repo "$REPO" --name-only)
else
# For branches, compare against main using the GitHub API
CHANGED_FILES=$(gh api "repos/$REPO/compare/main...$BRANCH" --jq '.files[].filename')
fi
DOTNET_CHANGES=false
PYTHON_CHANGES=false
if echo "$CHANGED_FILES" | grep -q '^dotnet/'; then
DOTNET_CHANGES=true
fi
if echo "$CHANGED_FILES" | grep -q '^python/'; then
PYTHON_CHANGES=true
fi
echo "dotnet=$DOTNET_CHANGES" >> "$GITHUB_OUTPUT"
echo "python=$PYTHON_CHANGES" >> "$GITHUB_OUTPUT"
echo "Detected changes — dotnet: $DOTNET_CHANGES, python: $PYTHON_CHANGES"
dotnet-integration-tests:
name: .NET Integration Tests
needs: resolve-ref
if: needs.resolve-ref.outputs.dotnet-changes == 'true'
uses: ./.github/workflows/dotnet-integration-tests.yml
with:
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
secrets: inherit
python-integration-tests:
name: Python Integration Tests
needs: resolve-ref
if: needs.resolve-ref.outputs.python-changes == 'true'
uses: ./.github/workflows/python-integration-tests.yml
with:
checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }}
secrets: inherit
@@ -0,0 +1,273 @@
#
# Dedicated Python integration tests workflow, called from the manual integration test orchestrator.
# Runs all tests (unit + integration) split into parallel jobs by provider.
#
# NOTE: This workflow and python-merge-tests.yml share the same set of parallel
# test jobs. Keep them in sync — when adding, removing, or modifying a job here,
# apply the same change to python-merge-tests.yml.
#
name: python-integration-tests
on:
workflow_call:
inputs:
checkout-ref:
description: "Git ref to checkout (e.g., refs/pull/123/head)"
required: true
type: string
permissions:
contents: read
id-token: write
env:
UV_CACHE_DIR: /tmp/.uv-cache
UV_PYTHON: "3.13"
jobs:
# Unit tests: all non-integration tests across all packages
python-tests-unit:
name: Python Integration Tests - Unit
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Test with pytest (unit tests only)
run: >
uv run poe all-tests
-m "not integration"
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
# OpenAI integration tests
python-tests-openai:
name: Python Integration Tests - OpenAI
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
env:
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Test with pytest (OpenAI integration)
run: >
uv run pytest --import-mode=importlib
packages/core/tests/openai
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
# Azure OpenAI integration tests
python-tests-azure-openai:
name: Python Integration Tests - Azure OpenAI
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
env:
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Test with pytest (Azure OpenAI integration)
run: >
uv run pytest --import-mode=importlib
packages/core/tests/azure
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
# Misc integration tests (Anthropic, Ollama, MCP)
python-tests-misc-integration:
name: Python Integration Tests - Misc
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Test with pytest (Anthropic, Ollama, MCP integration)
run: >
uv run pytest --import-mode=importlib
packages/anthropic/tests
packages/ollama/tests
packages/core/tests/core/test_mcp.py
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
# Azure Functions + Durable Task integration tests
python-tests-functions:
name: Python Integration Tests - Functions
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
env:
UV_PYTHON: "3.10"
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
FUNCTIONS_WORKER_RUNTIME: "python"
DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
AzureWebJobsStorage: "UseDevelopmentStorage=true"
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Set up Azure Functions Integration Test Emulators
uses: ./.github/actions/azure-functions-integration-setup
id: azure-functions-setup
- name: Test with pytest (Functions + Durable Task integration)
run: >
uv run pytest --import-mode=importlib
packages/azurefunctions/tests/integration_tests
packages/durabletask/tests/integration_tests
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
# Azure AI integration tests
python-tests-azure-ai:
name: Python Integration Tests - Azure AI
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
env:
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Test with pytest
timeout-minutes: 15
run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
python-integration-tests-check:
if: always()
runs-on: ubuntu-latest
needs:
[
python-tests-unit,
python-tests-openai,
python-tests-azure-openai,
python-tests-misc-integration,
python-tests-functions,
python-tests-azure-ai
]
steps:
- name: Fail workflow if tests failed
if: contains(join(needs.*.result, ','), 'failure')
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Failed!')
- name: Fail workflow if tests cancelled
if: contains(join(needs.*.result, ','), 'cancelled')
uses: actions/github-script@v8
with:
script: core.setFailed('Integration Tests Cancelled!')
+281 -60
View File
@@ -1,4 +1,9 @@
name: Python - Merge - Tests
#
# NOTE: This workflow and python-integration-tests.yml share the same set of
# parallel test jobs. Keep them in sync — when adding, removing, or modifying a
# job here, apply the same change to python-integration-tests.yml.
#
on:
workflow_dispatch:
@@ -10,13 +15,13 @@ on:
- cron: "0 0 * * *" # Run at midnight UTC daily
permissions:
contents: write
contents: read
id-token: write
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
RUN_INTEGRATION_TESTS: "true"
UV_PYTHON: "3.13"
RUN_SAMPLES_TESTS: ${{ vars.RUN_SAMPLES_TESTS }}
jobs:
@@ -26,7 +31,13 @@ jobs:
contents: read
pull-requests: read
outputs:
pythonChanges: ${{ steps.filter.outputs.python}}
pythonChanges: ${{ steps.filter.outputs.python }}
coreChanged: ${{ steps.filter.outputs.core }}
openaiChanged: ${{ steps.filter.outputs.openai }}
azureChanged: ${{ steps.filter.outputs.azure }}
miscChanged: ${{ steps.filter.outputs.misc }}
functionsChanged: ${{ steps.filter.outputs.functions }}
azureAiChanged: ${{ steps.filter.outputs.azure-ai }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
@@ -35,6 +46,27 @@ jobs:
filters: |
python:
- 'python/**'
core:
- 'python/packages/core/agent_framework/_*.py'
- 'python/packages/core/agent_framework/_workflows/**'
- 'python/packages/core/agent_framework/exceptions.py'
- 'python/packages/core/agent_framework/observability.py'
openai:
- 'python/packages/core/agent_framework/openai/**'
- 'python/packages/core/tests/openai/**'
azure:
- 'python/packages/core/agent_framework/azure/**'
- 'python/packages/core/tests/azure/**'
misc:
- 'python/packages/anthropic/**'
- 'python/packages/ollama/**'
- 'python/packages/core/agent_framework/_mcp.py'
- 'python/packages/core/tests/core/test_mcp.py'
functions:
- 'python/packages/azurefunctions/**'
- 'python/packages/durabletask/**'
azure-ai:
- 'python/packages/azure-ai/**'
# run only if 'python' files were changed
- name: python tests
if: steps.filter.outputs.python == 'true'
@@ -43,34 +75,15 @@ jobs:
- name: not python tests
if: steps.filter.outputs.python != 'true'
run: echo "NOT python file"
python-tests-core:
name: Python Tests - Core
# Unit tests: always run all non-integration tests across all packages
python-tests-unit:
name: Python Tests - Unit
needs: paths-filter
if: github.event_name != 'pull_request' && needs.paths-filter.outputs.pythonChanges == 'true'
runs-on: ${{ matrix.os }}
environment: ${{ matrix.environment }}
strategy:
fail-fast: true
matrix:
python-version: ["3.10"]
os: [ubuntu-latest]
environment: ["integration"]
env:
UV_PYTHON: ${{ matrix.python-version }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
# For Azure Functions integration tests
FUNCTIONS_WORKER_RUNTIME: "python"
DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
AzureWebJobsStorage: "UseDevelopmentStorage=true"
if: >
github.event_name != 'pull_request' &&
needs.paths-filter.outputs.pythonChanges == 'true'
runs-on: ubuntu-latest
environment: integration
defaults:
run:
working-directory: python
@@ -80,11 +93,219 @@ jobs:
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ matrix.python-version }}
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Test with pytest (unit tests only)
run: >
uv run poe all-tests
-m "not integration"
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/**.xml
summary: true
display-options: fEX
fail-on-empty: false
title: Unit test results
# OpenAI integration tests
python-tests-openai:
name: Python Tests - OpenAI Integration
needs: paths-filter
if: >
github.event_name != 'pull_request' &&
needs.paths-filter.outputs.pythonChanges == 'true' &&
(github.event_name != 'merge_group' ||
needs.paths-filter.outputs.openaiChanged == 'true' ||
needs.paths-filter.outputs.coreChanged == 'true')
runs-on: ubuntu-latest
environment: integration
env:
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_EMBEDDINGS_MODEL_ID: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Test with pytest (OpenAI integration)
run: >
uv run pytest --import-mode=importlib
packages/core/tests/openai
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
working-directory: ./python
- name: Test OpenAI samples
timeout-minutes: 10
if: env.RUN_SAMPLES_TESTS == 'true'
run: uv run pytest tests/samples/ -m "openai"
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/**.xml
summary: true
display-options: fEX
fail-on-empty: false
title: OpenAI integration test results
# Azure OpenAI integration tests
python-tests-azure-openai:
name: Python Tests - Azure OpenAI Integration
needs: paths-filter
if: >
github.event_name != 'pull_request' &&
needs.paths-filter.outputs.pythonChanges == 'true' &&
(github.event_name != 'merge_group' ||
needs.paths-filter.outputs.azureChanged == 'true' ||
needs.paths-filter.outputs.coreChanged == 'true')
runs-on: ubuntu-latest
environment: integration
env:
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Test with pytest (Azure OpenAI integration)
run: >
uv run pytest --import-mode=importlib
packages/core/tests/azure
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
working-directory: ./python
- name: Test Azure samples
timeout-minutes: 10
if: env.RUN_SAMPLES_TESTS == 'true'
run: uv run pytest tests/samples/ -m "azure"
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/**.xml
summary: true
display-options: fEX
fail-on-empty: false
title: Azure OpenAI integration test results
# Misc integration tests (Anthropic, Ollama, MCP)
python-tests-misc-integration:
name: Python Tests - Misc Integration
needs: paths-filter
if: >
github.event_name != 'pull_request' &&
needs.paths-filter.outputs.pythonChanges == 'true' &&
(github.event_name != 'merge_group' ||
needs.paths-filter.outputs.miscChanged == 'true' ||
needs.paths-filter.outputs.coreChanged == 'true')
runs-on: ubuntu-latest
environment: integration
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Test with pytest (Anthropic, Ollama, MCP integration)
run: >
uv run pytest --import-mode=importlib
packages/anthropic/tests
packages/ollama/tests
packages/core/tests/core/test_mcp.py
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
working-directory: ./python
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@v0.7.2
with:
path: ./python/**.xml
summary: true
display-options: fEX
fail-on-empty: false
title: Misc integration test results
# Azure Functions + Durable Task integration tests
python-tests-functions:
name: Python Tests - Functions Integration
needs: paths-filter
if: >
github.event_name != 'pull_request' &&
needs.paths-filter.outputs.pythonChanges == 'true' &&
(github.event_name != 'merge_group' ||
needs.paths-filter.outputs.functionsChanged == 'true' ||
needs.paths-filter.outputs.coreChanged == 'true')
runs-on: ubuntu-latest
environment: integration
env:
UV_PYTHON: "3.10"
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
FUNCTIONS_WORKER_RUNTIME: "python"
DURABLE_TASK_SCHEDULER_CONNECTION_STRING: "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
AzureWebJobsStorage: "UseDevelopmentStorage=true"
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@v2
@@ -95,13 +316,15 @@ jobs:
- name: Set up Azure Functions Integration Test Emulators
uses: ./.github/actions/azure-functions-integration-setup
id: azure-functions-setup
- name: Test with pytest
run: uv run poe all-tests -n logical --dist loadfile --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
working-directory: ./python
- name: Test core samples
timeout-minutes: 10
if: env.RUN_SAMPLES_TESTS == 'true'
run: uv run pytest tests/samples/ -m "openai" -m "azure"
- name: Test with pytest (Functions + Durable Task integration)
run: >
uv run pytest --import-mode=importlib
packages/azurefunctions/tests/integration_tests
packages/durabletask/tests/integration_tests
-m integration
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
working-directory: ./python
- name: Surface failing tests
if: always()
@@ -111,22 +334,20 @@ jobs:
summary: true
display-options: fEX
fail-on-empty: false
title: Test results
title: Functions integration test results
python-tests-azure-ai:
name: Python Tests - Azure AI
needs: paths-filter
if: github.event_name != 'pull_request' && needs.paths-filter.outputs.pythonChanges == 'true'
runs-on: ${{ matrix.os }}
environment: ${{ matrix.environment }}
strategy:
fail-fast: true
matrix:
python-version: ["3.10"]
os: [ubuntu-latest]
environment: ["integration"]
if: >
github.event_name != 'pull_request' &&
needs.paths-filter.outputs.pythonChanges == 'true' &&
(github.event_name != 'merge_group' ||
needs.paths-filter.outputs.azureAiChanged == 'true' ||
needs.paths-filter.outputs.coreChanged == 'true')
runs-on: ubuntu-latest
environment: integration
env:
UV_PYTHON: ${{ matrix.python-version }}
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREAI__DEPLOYMENTNAME }}
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
@@ -139,11 +360,8 @@ jobs:
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ matrix.python-version }}
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
- name: Azure CLI Login
if: github.event_name != 'pull_request'
uses: azure/login@v2
@@ -153,7 +371,7 @@ jobs:
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Test with pytest
timeout-minutes: 15
run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist loadfile --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
working-directory: ./python
- name: Test Azure AI samples
timeout-minutes: 10
@@ -177,11 +395,14 @@ jobs:
runs-on: ubuntu-latest
needs:
[
python-tests-core,
python-tests-azure-ai
python-tests-unit,
python-tests-openai,
python-tests-azure-openai,
python-tests-misc-integration,
python-tests-functions,
python-tests-azure-ai,
]
steps:
- name: Fail workflow if tests failed
id: check_tests_failed
if: contains(join(needs.*.result, ','), 'failure')
@@ -0,0 +1,304 @@
name: Python - Sample Validation
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *" # Run at midnight UTC daily
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
jobs:
validate-01-get-started:
name: Validate 01-get-started
runs-on: ubuntu-latest
permissions:
contents: read
env:
# Azure AI configuration for get-started samples
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: "3.12"
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir 01-get-started --save-report --report-name 01-get-started
- name: Upload validation report
uses: actions/upload-artifact@v4
if: always()
with:
name: validation-report-01-get-started
path: python/samples/_sample_validation/reports/
validate-02-agents:
name: Validate 02-agents
runs-on: ubuntu-latest
permissions:
contents: read
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }}
# OpenAI configuration
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI_CHAT_MODEL_ID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI_RESPONSES_MODEL_ID }}
# Observability
ENABLE_INSTRUMENTATION: "true"
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: "3.12"
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir 02-agents --save-report --report-name 02-agents
- name: Upload validation report
uses: actions/upload-artifact@v4
if: always()
with:
name: validation-report-02-agents
path: python/samples/_sample_validation/reports/
validate-03-workflows:
name: Validate 03-workflows
runs-on: ubuntu-latest
permissions:
contents: read
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }}
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: "3.12"
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir 03-workflows --save-report --report-name 03-workflows
- name: Upload validation report
uses: actions/upload-artifact@v4
if: always()
with:
name: validation-report-03-workflows
path: python/samples/_sample_validation/reports/
validate-04-hosting:
name: Validate 04-hosting
runs-on: ubuntu-latest
permissions:
contents: read
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }}
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: "3.12"
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir 04-hosting --save-report --report-name 04-hosting
- name: Upload validation report
uses: actions/upload-artifact@v4
if: always()
with:
name: validation-report-04-hosting
path: python/samples/_sample_validation/reports/
validate-05-end-to-end:
name: Validate 05-end-to-end
runs-on: ubuntu-latest
permissions:
contents: read
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }}
# 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 }}
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: "3.12"
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir 05-end-to-end --save-report --report-name 05-end-to-end
- name: Upload validation report
uses: actions/upload-artifact@v4
if: always()
with:
name: validation-report-05-end-to-end
path: python/samples/_sample_validation/reports/
validate-autogen-migration:
name: Validate autogen-migration
runs-on: ubuntu-latest
permissions:
contents: read
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: "3.12"
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir autogen-migration --save-report --report-name autogen-migration
- name: Upload validation report
uses: actions/upload-artifact@v4
if: always()
with:
name: validation-report-autogen-migration
path: python/samples/_sample_validation/reports/
validate-semantic-kernel-migration:
name: Validate semantic-kernel-migration
runs-on: ubuntu-latest
permissions:
contents: read
env:
# Azure AI configuration
AZURE_AI_PROJECT_ENDPOINT: ${{ secrets.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ secrets.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
# Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME }}
# OpenAI configuration
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI_CHAT_MODEL_ID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI_RESPONSES_MODEL_ID }}
# Copilot Studio
COPILOTSTUDIOAGENT__ENVIRONMENTID: ${{ secrets.COPILOTSTUDIOAGENT__ENVIRONMENTID }}
COPILOTSTUDIOAGENT__SCHEMANAME: ${{ secrets.COPILOTSTUDIOAGENT__SCHEMANAME }}
COPILOTSTUDIOAGENT__TENANTID: ${{ secrets.COPILOTSTUDIOAGENT__TENANTID }}
COPILOTSTUDIOAGENT__AGENTAPPID: ${{ secrets.COPILOTSTUDIOAGENT__AGENTAPPID }}
# GitHub Copilot configuration
GITHUB_COPILOT_MODEL: ${{ vars.GITHUB_COPILOT_MODEL }}
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: "3.12"
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run sample validation
run: |
cd samples && uv run python -m _sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration
- name: Upload validation report
uses: actions/upload-artifact@v4
if: always()
with:
name: validation-report-semantic-kernel-migration
path: python/samples/_sample_validation/reports/
@@ -1114,6 +1114,7 @@ Defaults introduced by this change:
- `RedisContextProvider.DEFAULT_SOURCE_ID = "redis"`
- `RedisHistoryProvider.DEFAULT_SOURCE_ID = "redis_memory"`
- `AzureAISearchContextProvider.DEFAULT_SOURCE_ID = "azure_ai_search"`
- `FoundryMemoryProvider.DEFAULT_SOURCE_ID = "foundry_memory"`
## Comparison to .NET Implementation
@@ -0,0 +1,211 @@
---
status: accepted
contact: westey-m
date: 2026-02-24
deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub, lokitoth, alliscode, taochenosu, moonbox3
consulted:
informed:
---
# AdditionalProperties for AIAgent and AgentSession
## Context and Problem Statement
The `AIAgent` base class currently exposes `Id`, `Name`, and `Description` as its core metadata properties, and `AgentSession` exposes only a `StateBag` property.
Neither type has a mechanism for attaching arbitrary metadata, such as protocol-specific descriptors (e.g., A2A agent cards), hosting attributes, session-level tags, or custom user-defined metadata for discovery and routing.
Other types in the framework already carry `AdditionalProperties` — notably `AgentRunOptions`, `AgentResponse`, and `AgentResponseUpdate` — all using `AdditionalPropertiesDictionary` from `Microsoft.Extensions.AI`.
Adding a similar property to `AIAgent` and `AgentSession` would give both types a consistent, extensible metadata surface.
Related: [Work Item #2133](https://github.com/microsoft/agent-framework/issues/2133)
## Decision Drivers
- **Consistency**: Other core types (`AgentRunOptions`, `AgentResponse`, `AgentResponseUpdate`) already expose `AdditionalProperties`. `AIAgent` and `AgentSession` are the major abstractions that lack this.
- **Extensibility**: Hosting libraries, protocol adapters (A2A, AG-UI), and discovery mechanisms need a place to attach agent-level and session-level metadata without subclassing.
- **Simplicity**: The solution should be easy to understand and use; avoid over-engineering.
- **Minimal breaking change**: The addition should not require changes to existing agent implementations.
- **Clear semantics**: Users should understand what `AdditionalProperties` on an agent or session means and how it differs from `AdditionalProperties` on `AgentRunOptions`.
## Considered Options
### Surface Area
- **Option A**: Public get-only property, auto-initialized (`AdditionalPropertiesDictionary AdditionalProperties { get; } = new()`) on both `AIAgent` and `AgentSession`
- **Option B**: Public get/set nullable property (`AdditionalPropertiesDictionary? AdditionalProperties { get; set; }`) on both `AIAgent` and `AgentSession`
- **Option C**: Constructor-injected dictionary with public get-only accessor on both `AIAgent` and `AgentSession`
- **Option D**: External container/wrapper object — metadata lives outside `AIAgent` and `AgentSession`; no changes to the base classes
### Semantics
- **Option 1**: Metadata only — describes the agent or session; not propagated when calling `IChatClient`
- **Option 2**: Passed down the stack — merged into `ChatOptions.AdditionalProperties` during `ChatClientAgent` runs
## Decision Outcome
The chosen option is **Option D + Option 1**: an external container/wrapper object, used purely as metadata.
### Consequences
- Good, because `AIAgent` and `AgentSession` remain unchanged, avoiding any increase to the core framework surface area while still enabling extensible metadata.
- Good, because an external wrapper (owned by hosting/protocol libraries or user code, not the `AIAgent` / `AgentSession` base classes) can internally use `AdditionalPropertiesDictionary` to stay consistent with existing patterns on `AgentRunOptions`, `AgentResponse`, and `AgentResponseUpdate`.
- Good, because metadata-only semantics keep a clean separation from per-run extensibility (`AgentRunOptions.AdditionalProperties`) and avoid unexpected side effects during agent execution.
- Good, because no additional allocation occurs on `AIAgent` or `AgentSession` when no metadata is needed; external wrappers can be created only when metadata is required.
- Bad, because callers and libraries must manage and pass around both the agent/session instance and its associated metadata wrapper, keeping them correctly associated.
- Bad, because different hosting or protocol layers may define their own wrapper types, which can fragment the ecosystem unless conventions are agreed upon.
## Pros and Cons of the Options
### Option A — Public get-only property, auto-initialized
The property is always non-null and ready to use. Users add metadata after construction.
```csharp
public abstract partial class AIAgent
{
public AdditionalPropertiesDictionary AdditionalProperties { get; } = new();
}
public abstract partial class AgentSession
{
public AdditionalPropertiesDictionary AdditionalProperties { get; } = new();
}
// Usage
agent.AdditionalProperties["protocol"] = "A2A";
agent.AdditionalProperties.Add<MyAgentCardInfo>(cardInfo);
session.AdditionalProperties["tenant"] = tenantId;
```
- Good, because users never encounter `null` — no defensive null checks needed.
- Good, because the dictionary reference cannot be replaced, preventing accidental data loss.
- Good, because it is the simplest API surface to use.
- Neutral, because it always allocates, even when no metadata is needed. The allocation cost is negligible.
- Bad, because it cannot be set at construction time as a single object (users must populate it post-construction).
### Option B — Public get/set nullable property
Matches the existing pattern on `AgentRunOptions`, `AgentResponse`, and `AgentResponseUpdate`.
```csharp
public abstract partial class AIAgent
{
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
}
public abstract partial class AgentSession
{
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
}
// Usage
agent.AdditionalProperties ??= new();
agent.AdditionalProperties["protocol"] = "A2A";
session.AdditionalProperties ??= new();
session.AdditionalProperties["tenant"] = tenantId;
```
- Good, because it is consistent with the existing `AdditionalProperties` pattern on `AgentRunOptions` and `AgentResponse`.
- Good, because it avoids allocation when no metadata is needed.
- Bad, because every consumer must null-check before reading or writing.
- Bad, because the entire dictionary can be replaced, risking accidental loss of metadata set by other components (e.g., a hosting library sets metadata, then user code replaces the dictionary).
### Option C — Constructor-injected with public get
The dictionary is provided at construction time and exposed as get-only.
```csharp
public abstract partial class AIAgent
{
public AdditionalPropertiesDictionary AdditionalProperties { get; }
protected AIAgent(AdditionalPropertiesDictionary? additionalProperties = null)
{
this.AdditionalProperties = additionalProperties ?? new();
}
}
public abstract partial class AgentSession
{
public AdditionalPropertiesDictionary AdditionalProperties { get; }
protected AgentSession(AdditionalPropertiesDictionary? additionalProperties = null)
{
this.AdditionalProperties = additionalProperties ?? new();
}
}
```
- Good, because an agent's metadata can be established before any code runs against it.
- Bad, because `AdditionalPropertiesDictionary` has no read-only variant, so the constructor-injection pattern gives a false sense of immutability — callers can still mutate the dictionary contents after construction.
- Bad, because it requires adding a constructor parameter to the abstract base classes, which is a source-breaking change for all existing `AIAgent` and `AgentSession` subclasses (even with a default value, it changes the constructor signature that derived classes chain to).
- Bad, because it is more complex with little practical benefit over Option A, since post-construction mutation is equally possible.
### Option D — External container/wrapper object
Rather than adding `AdditionalProperties` to `AIAgent` or `AgentSession`, users wrap the agent or session in a container object that carries both the instance and any associated metadata. No changes to the base classes are required.
```csharp
public class AgentWithMetadata
{
public required AIAgent Agent { get; init; }
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
}
public class SessionWithMetadata
{
public required AgentSession Session { get; init; }
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
}
// Usage
var wrapper = new AgentWithMetadata
{
Agent = myAgent,
AdditionalProperties = new() { ["protocol"] = "A2A" }
};
```
- Good, because it requires no changes to `AIAgent` or `AgentSession`, avoiding any risk of breaking existing implementations.
- Good, because metadata is clearly external to the agent and session, eliminating any ambiguity about whether it might be passed down the execution stack.
- Good, because the container pattern gives the user full control over the metadata lifecycle and serialization.
- Bad, because it is not discoverable — users must know about the container convention; there is no built-in API surface guiding them.
### Option 1 — Metadata only
`AdditionalProperties` on `AIAgent` and `AgentSession` is descriptive metadata. It is **not** automatically propagated when the agent calls downstream services such as `IChatClient`.
- Good, because it keeps a clean separation of concerns: agent/session-level metadata vs. per-run options.
- Good, because it avoids unintended side effects — metadata added for discovery or hosting won't leak into LLM requests.
- Good, because per-run extensibility is already served by `AgentRunOptions.AdditionalProperties` (see [ADR 0014](0014-feature-collections.md)), so there is no gap.
- Neutral, because users who want to pass agent metadata to the chat client can still do so manually via `AgentRunOptions`.
### Option 2 — Passed down the stack
`AdditionalProperties` on `AIAgent` and `AgentSession` are automatically merged into `ChatOptions.AdditionalProperties` (or similar) when `ChatClientAgent` invokes the underlying `IChatClient`.
- Good, because it provides an automatic way to send agent-level configuration to the LLM provider.
- Bad, because it conflates metadata (describing the agent) with operational parameters (controlling LLM behavior), leading to potential confusion.
- Bad, because it risks leaking unrelated metadata into LLM calls (e.g., hosting tags, discovery URLs).
- Bad, because it would be `ChatClientAgent`-specific behavior on a base-class property, creating inconsistency for non-`ChatClientAgent` implementations.
- Bad, because it duplicates the purpose of `AgentRunOptions.AdditionalProperties`, which already serves as the per-run extensibility point for passing data down the stack.
## Serialization Considerations
`AIAgent` instances are not typically serialized, so `AdditionalProperties` on `AIAgent` does not raise serialization concerns.
`AgentSession` instances, however, are routinely serialized and deserialized — for example, to persist conversation state across application restarts. Adding `AdditionalProperties` to `AgentSession` introduces a serialization challenge: `AdditionalPropertiesDictionary` is a `Dictionary<string, object?>`, and `object?` values do not carry enough type information for the JSON deserializer to reconstruct the original CLR types.
### Default behavior — JsonElement round-tripping
By default, when an `AgentSession` with `AdditionalProperties` is serialized and later deserialized, any complex objects stored as values in the dictionary will be deserialized as `JsonElement` rather than their original types. This is the same behavior exhibited by `ChatMessage.AdditionalProperties` and other `AdditionalPropertiesDictionary` usages in `Microsoft.Extensions.AI`, and is the approach we will follow.
### Custom serialization via JsonSerializerOptions
`AIAgent.SerializeSessionAsync` and `AIAgent.DeserializeSessionAsync` already accept an optional `JsonSerializerOptions` parameter. Users who need strongly-typed round-tripping of `AdditionalProperties` values can supply custom options with appropriate converters or type info resolvers. This is non-trivial to implement but provides full control over deserialization behavior when needed.
## More Information
- [ADR 0014 — Feature Collections](0014-feature-collections.md) established that `AdditionalProperties` on `AgentRunOptions` serves as the per-run extensibility mechanism. The proposed agent-level and session-level properties serve a complementary, distinct purpose: static metadata describing the agent or session itself.
- `AdditionalPropertiesDictionary` is defined in `Microsoft.Extensions.AI` and is already a dependency of `Microsoft.Agents.AI.Abstractions`. No new package references are needed.
- Type-safe access is available via the existing `AdditionalPropertiesExtensions` helper methods (`Add<T>`, `TryGetValue<T>`, `Contains<T>`, `Remove<T>`), which use `typeof(T).FullName` as the dictionary key.
@@ -0,0 +1,390 @@
# Vector Stores and Embeddings
## Overview
This feature ports the vector store abstractions, embedding generator abstractions, and their implementations from Semantic Kernel into Agent Framework. The ported code follows AF's coding standards, feels native to AF, and is structured to allow data models/schemas to be reusable across both frameworks. The embedding abstraction combines the best of SK's `EmbeddingGeneratorBase` and MEAI's `IEmbeddingGenerator<TInput, TEmbedding>`.
| Capability | Description |
| --- | --- |
| Embedding generation | Generic embedding client abstraction supporting text, image, and audio inputs |
| Vector store collections | CRUD operations on vector store collections (upsert, get, delete) |
| Vector search | Unified search interface with `search_type` parameter (`"vector"`, `"keyword_hybrid"`) |
| Data model decorator | `@vectorstoremodel` decorator for defining vector store data models (supports Pydantic, dataclasses, plain classes, dicts) |
| Agent tools | `create_search_tool`, `create_upsert_tool`, `create_get_tool`, `create_delete_tool` for agent-usable vector store operations |
| In-memory store | Zero-dependency vector store for testing and development |
| 13+ connectors | Azure AI Search, Qdrant, Redis, PostgreSQL, MongoDB, Cosmos DB, Pinecone, Chroma, Weaviate, Oracle, SQL Server, FAISS |
## Key Design Decisions
### Embedding Abstractions (combining SK + MEAI)
- **Both Protocol and Base class** (matching AF's `SupportsChatGetResponse` + `BaseChatClient` pattern):
- `SupportsGetEmbeddings` — Protocol for duck-typing
- `BaseEmbeddingClient` — ABC base class for implementations (similar to `BaseChatClient`)
- **Generic input type** (`EmbeddingInputT`, default `str`) from MEAI — allows image/audio embeddings in the future
- **Generic output type** (`EmbeddingT`, default `list[float]`) from MEAI — supports `list[float]`, `list[int]`, `bytes`, etc.
- **Generic order**: `[EmbeddingInputT, EmbeddingT, EmbeddingOptionsT]` — options last, matching MEAI's `IEmbeddingGenerator<TInput, TEmbedding>` with options appended
- **TypeVar naming convention**: Use `SuffixT` per AF standard (e.g., `EmbeddingInputT`, `EmbeddingT`, `ModelT`, `KeyT`)
- `EmbeddingGenerationOptions` TypedDict (inspired by MEAI, matching AF's `ChatOptions` pattern) — `total=False`, includes `dimensions`, `model_id`. No `additional_properties` since each implementation extends with its own fields.
- Protocol and base class are generic over input, output, and options: `SupportsGetEmbeddings[EmbeddingInputT, EmbeddingT, OptionsContraT]`, `BaseEmbeddingClient[EmbeddingInputT, EmbeddingT, OptionsCoT]`
- **`Embedding[EmbeddingT]` type** in `_types.py` — a lightweight generic class (not Pydantic) with `vector: EmbeddingT`, `model_id: str | None`, `dimensions: int | None` (explicit or computed from vector), `created_at: datetime | None`, `additional_properties: dict[str, Any]`
- **`GeneratedEmbeddings[EmbeddingT, EmbeddingOptionsT]` type** — a list-like container of `Embedding[EmbeddingT]` objects with `options: EmbeddingOptionsT | None` (stores the options used to generate), `usage: dict[str, Any] | None`, `additional_properties: dict[str, Any]`
- **No numpy dependency** — return `list[float]` by default; users cast as needed
### Vector Store Abstractions
- **Port core abstractions without Pydantic for internal classes** — use plain classes
- **Both Protocol and Base class** for vector store operations (matching AF pattern):
- `SupportsVectorUpsert` / `SupportsVectorSearch` — Protocols for duck-typing (follows `Supports<Capability>` naming convention)
- `BaseVectorCollection` / `BaseVectorSearch` — ABC base classes for implementations
- `BaseVectorStore` — ABC base class for store operations (factory for collections, no protocol needed)
- **TypeVar naming convention**: `ModelT`, `KeyT`, `FilterT` (suffix T, per AF standard)
- **Support Pydantic for user-facing data models** — the `@vectorstoremodel` decorator and `VectorStoreCollectionDefinition` should work with Pydantic models, dataclasses, plain classes, and dicts
- **Remove SK-specific dependencies** — no `KernelBaseModel`, `KernelFunction`, `KernelParameterMetadata`, `kernel_function`, `PromptExecutionSettings`
- **Embedding types in `_types.py`**, embedding protocol/base class in `_clients.py`
- **All vector store specific types, enums, protocols, base classes** in `_vectors.py`
- **Error handling** uses AF's exception hierarchy (e.g., `IntegrationException` variants)
### Package Structure
- **Embedding types** (`Embedding`, `GeneratedEmbeddings`, `EmbeddingGenerationOptions`) in `agent_framework/_types.py`
- **Embedding protocol + base class** (`SupportsGetEmbeddings`, `BaseEmbeddingClient`) in `agent_framework/_clients.py`
- **All vector store specific code** in a new `agent_framework/_vectors.py` module — this includes:
- Enums: `FieldTypes`, `IndexKind`, `DistanceFunction`
- `VectorStoreField`, `VectorStoreCollectionDefinition`
- `SearchOptions`, `SearchResponse`, `RecordFilterOptions`
- `@vectorstoremodel` decorator
- Serialization/deserialization protocols
- `VectorStoreRecordHandler`, `BaseVectorCollection`, `BaseVectorStore`, `BaseVectorSearch`
- `SupportsVectorUpsert`, `SupportsVectorSearch` protocols
- **OpenAI embeddings** in `agent_framework/openai/` (built into core, like OpenAI chat)
- **Azure OpenAI embeddings** in `agent_framework/azure/` (built into core, follows `AzureOpenAIChatClient` pattern)
- **Each vector store connector** in its own AF package under `packages/`
- **In-memory store** in core (no external deps)
- **TextSearch and its implementations** (Brave, Google) — last phase, separate work
## Naming: SK → AF
### Names that change
| SK Name | AF Name | Rationale |
|---------|---------|-----------|
| `VectorStoreCollection` | `BaseVectorCollection` | Drop redundant `Store`, add `Base` prefix per AF pattern |
| `VectorStore` | `BaseVectorStore` | Add `Base` prefix per AF pattern |
| `VectorSearch` | `BaseVectorSearch` | Add `Base` prefix per AF pattern |
| `VectorSearchOptions` | `SearchOptions` | Shorter — context is already vector search |
| `VectorSearchResult` | `SearchResponse` | Align with `ChatResponse`/`AgentResponse` |
| `GetFilteredRecordOptions` | `RecordFilterOptions` | Shorter, more natural |
| `EmbeddingGeneratorBase` | `BaseEmbeddingClient` | Matches AF `BaseChatClient` pattern |
| `VectorStoreCollectionProtocol` | `SupportsVectorUpsert` | AF `Supports*` naming convention |
| `VectorSearchProtocol` | `SupportsVectorSearch` | AF `Supports*` naming convention |
| `__kernel_vectorstoremodel__` | `__vectorstoremodel__` | Drop SK `kernel` prefix |
| `__kernel_vectorstoremodel_definition__` | `__vectorstoremodel_definition__` | Drop SK `kernel` prefix |
| `search()` + `hybrid_search()` | `search(search_type=...)` | Single method with `Literal` parameter |
| `SearchType` enum | `Literal["vector", "keyword_hybrid"]` | No enum, just a literal |
| `KernelSearchResults` | `SearchResults` | Drop SK `Kernel` prefix (plural — container of `SearchResponse` items) |
### Names that stay the same
| Name | Location |
|------|----------|
| `@vectorstoremodel` | `_vectors.py` |
| `VectorStoreField` | `_vectors.py` |
| `VectorStoreCollectionDefinition` | `_vectors.py` |
| `VectorStoreRecordHandler` | `_vectors.py` |
| `FieldTypes` | `_vectors.py` |
| `IndexKind` | `_vectors.py` |
| `DistanceFunction` | `_vectors.py` |
| `DISTANCE_FUNCTION_DIRECTION_HELPER` | `_vectors.py` |
| `Embedding` | `_types.py` |
| `GeneratedEmbeddings` | `_types.py` |
| `EmbeddingGenerationOptions` | `_types.py` |
| `SupportsGetEmbeddings` | `_clients.py` |
### New AF-only names (no SK equivalent)
| Name | Location | Purpose |
|------|----------|---------|
| `BaseEmbeddingClient` | `_clients.py` | ABC base for embedding implementations |
| `EmbeddingInputT` | `_types.py` | TypeVar for generic embedding input (default `str`) |
| `EmbeddingTelemetryLayer` | `observability.py` | MRO-based OTel tracing for embeddings |
| `SupportsVectorUpsert` | `_vectors.py` | Protocol for collection CRUD |
| `SupportsVectorSearch` | `_vectors.py` | Protocol for vector search |
| `create_search_tool` | `_vectors.py` | Creates AF `FunctionTool` from vector search |
## Source Files Reference (SK → AF mapping)
### SK Source Files
| SK File | Lines | Content |
|---------|-------|---------|
| `data/vector.py` | 2369 | All vector store abstractions, enums, decorator, search |
| `data/_shared.py` | 184 | SearchOptions, KernelSearchResults, shared search types |
| `data/text_search.py` | 349 | TextSearch base, TextSearchResult |
| `connectors/ai/embedding_generator_base.py` | 50 | EmbeddingGeneratorBase ABC |
| `connectors/in_memory.py` | 520 | InMemoryCollection, InMemoryStore |
| `connectors/azure_ai_search.py` | 793 | Azure AI Search collection + store |
| `connectors/azure_cosmos_db.py` | 1104 | Cosmos DB (Mongo + NoSQL) |
| `connectors/redis.py` | 845 | Redis (Hashset + JSON) |
| `connectors/qdrant.py` | 653 | Qdrant collection + store |
| `connectors/postgres.py` | 987 | PostgreSQL collection + store |
| `connectors/mongodb.py` | 633 | MongoDB Atlas collection + store |
| `connectors/pinecone.py` | 691 | Pinecone collection + store |
| `connectors/chroma.py` | 484 | Chroma collection + store |
| `connectors/faiss.py` | 278 | FAISS (extends InMemory) |
| `connectors/weaviate.py` | 804 | Weaviate collection + store |
| `connectors/oracle.py` | 1267 | Oracle collection + store |
| `connectors/sql_server.py` | 1132 | SQL Server collection + store |
| `connectors/ai/open_ai/services/open_ai_text_embedding.py` | 91 | OpenAI embedding impl |
| `connectors/ai/open_ai/services/open_ai_text_embedding_base.py` | 78 | OpenAI embedding base |
| `connectors/brave.py` | ~200 | Brave TextSearch impl |
| `connectors/google_search.py` | ~200 | Google TextSearch impl |
---
## Implementation Phases
### Phase 1: Core Embedding Abstractions & OpenAI Implementation âś… DONE
**Goal:** Establish the embedding generator abstraction and ship one working implementation.
**Mergeable:** Yes — adds new types/protocols, no breaking changes.
**Status:** Merged via PR #4153. Closes sub-issue #4163.
#### 1.1 — Embedding types in `_types.py`
- `EmbeddingInputT` TypeVar (default `str`) — generic input type for embedding generation
- `EmbeddingT` TypeVar (default `list[float]`) — generic output embedding vector type
- `Embedding[EmbeddingT]` generic class: `vector: EmbeddingT`, `model_id: str | None`, `dimensions: int | None` (explicit param or computed from vector length), `created_at: datetime | None`, `additional_properties: dict[str, Any]`
- `GeneratedEmbeddings[EmbeddingT, EmbeddingOptionsT]` generic class: list-like container of `Embedding[EmbeddingT]` objects with `options: EmbeddingOptionsT | None` (the options used to generate), `usage: dict[str, Any] | None`, `additional_properties: dict[str, Any]`
- `EmbeddingGenerationOptions` TypedDict (`total=False`): `dimensions: int`, `model_id: str` — follows the same pattern as `ChatOptions`. No `additional_properties` needed since it's a TypedDict and each implementation can extend with its own fields.
#### 1.2 — Embedding generator protocol + base class in `_clients.py`
- `SupportsGetEmbeddings(Protocol[EmbeddingInputT, EmbeddingT, OptionsContraT])`: generic over input, output, and options (all with defaults), `get_embeddings(values: Sequence[EmbeddingInputT], *, options: OptionsContraT | None = None) -> Awaitable[GeneratedEmbeddings[EmbeddingT]]`
- `BaseEmbeddingClient(ABC, Generic[EmbeddingInputT, EmbeddingT, OptionsCoT])`: ABC base class mirroring `BaseChatClient` pattern
- `__init__` with `additional_properties`, etc.
- Abstract `get_embeddings(...)` for subclasses to implement directly (no `_inner_*` indirection — simpler than chat, no middleware needed)
- `EmbeddingTelemetryLayer` in `observability.py` — MRO-based telemetry (no closure), `gen_ai.operation.name = "embeddings"`
#### 1.3 — OpenAI embedding generator in `agent_framework/openai/` and `agent_framework/azure/`
- `RawOpenAIEmbeddingClient` — implements `get_embeddings` via `_ensure_client()` factory
- `OpenAIEmbeddingClient(OpenAIConfigMixin, EmbeddingTelemetryLayer[str, list[float], OptionsT], RawOpenAIEmbeddingClient[OptionsT])` — full client with config + telemetry layers
- `OpenAIEmbeddingOptions(EmbeddingGenerationOptions)` — extends with `encoding_format`, `user`
- `AzureOpenAIEmbeddingClient` in `agent_framework/azure/` — follows `AzureOpenAIChatClient` pattern with `AzureOpenAIConfigMixin`, `load_settings`, Entra ID credential support
- `AzureOpenAISettings` extended with `embedding_deployment_name` (env var: `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME`)
#### 1.4 — Tests and samples
- Unit tests for types, protocol, base class, OpenAI client, Azure OpenAI client
- Integration tests for OpenAI and Azure OpenAI (gated behind credentials check, `@pytest.mark.flaky`)
- Samples in `samples/02-agents/embeddings/` — `openai_embeddings.py`, `azure_openai_embeddings.py`
---
### Phase 2: Embedding Generators for Existing Providers
**Goal:** Add embedding generators to all existing AF provider packages that have chat clients.
**Mergeable:** Yes — each is independent, added to existing provider packages.
#### 2.1 — Azure AI Inference embedding (in `packages/azure-ai/`)
#### 2.2 — Ollama embedding (in `packages/ollama/`)
#### 2.3 — Anthropic embedding (in `packages/anthropic/`)
#### 2.4 — Bedrock embedding (in `packages/bedrock/`)
---
### Phase 3: Core Vector Store Abstractions
**Goal:** Establish all vector store types, enums, the decorator, collection definition, and base classes.
**Mergeable:** Yes — adds new abstractions, no breaking changes.
#### 3.1 — Vector store enums and field types in `_vectors.py`
- `FieldTypes` enum: `KEY`, `VECTOR`, `DATA`
- `IndexKind` enum: `HNSW`, `FLAT`, `IVF_FLAT`, `DISK_ANN`, `QUANTIZED_FLAT`, `DYNAMIC`, `DEFAULT`
- `DistanceFunction` enum: `COSINE_SIMILARITY`, `COSINE_DISTANCE`, `DOT_PROD`, `EUCLIDEAN_DISTANCE`, `EUCLIDEAN_SQUARED_DISTANCE`, `MANHATTAN`, `HAMMING`, `DEFAULT`
- No `SearchType` enum — use `Literal["vector", "keyword_hybrid"]` instead, per AF convention of avoiding unnecessary imports
- `VectorStoreField` plain class (not Pydantic)
- `VectorStoreCollectionDefinition` class (not Pydantic internally, but supports Pydantic models as input)
- `SearchOptions` plain class — includes `score_threshold: float | None` for filtering results by score (see note below)
- `SearchResponse` generic class
- `RecordFilterOptions` plain class
- `DISTANCE_FUNCTION_DIRECTION_HELPER` dict
#### 3.2 — `@vectorstoremodel` decorator
- Port from SK, works with dataclasses, Pydantic models, plain classes, and dicts
- Sets `__vectorstoremodel__` and `__vectorstoremodel_definition__` on the class
- Remove SK-specific `kernel` prefix (`__kernel_vectorstoremodel__` → `__vectorstoremodel__`)
#### 3.3 — Serialization/deserialization protocols
- `SerializeMethodProtocol`, `ToDictFunctionProtocol`, `FromDictFunctionProtocol`, etc.
- Port the record handler logic but without Pydantic base class — use plain class or ABC
#### 3.4 — Vector store base classes in `_vectors.py`
- `VectorStoreRecordHandler` — internal base class that handles serialization/deserialization between user data models and store-specific formats, plus embedding generation for vector fields. Both `BaseVectorCollection` and `BaseVectorSearch` extend this.
- `BaseVectorCollection(VectorStoreRecordHandler)` — base for collections
- Uses `SupportsGetEmbeddings` instead of `EmbeddingGeneratorBase`
- Not a Pydantic model — use `__init__` with explicit params
- `upsert`, `get`, `delete`, `ensure_collection_exists`, `collection_exists`, `ensure_collection_deleted`
- Async context manager support
- `BaseVectorStore` — base for stores
- `get_collection`, `list_collection_names`, `collection_exists`, `ensure_collection_deleted`
- Async context manager support
#### 3.5 — Vector search base class
- `BaseVectorSearch(VectorStoreRecordHandler)` — base for vector search
- Single `search(search_type=...)` method with `search_type: Literal["vector", "keyword_hybrid"]` parameter — no enum, just a literal
- `_inner_search` abstract method for implementations
- Filter building with lambda parser (AST-based)
- Vector generation from values using embedding generator
#### 3.6 — Protocols for type checking
- `SupportsVectorUpsert` — Protocol for upsert/get/delete operations
- `SupportsVectorSearch` — Protocol for vector search (single `search()` with `search_type` parameter)
- No separate `SupportsVectorHybridSearch` — search type is a parameter, not a separate capability
- No protocol for `VectorStore` — it's a factory for collections, not a capability to duck-type against
#### 3.7 — Exception types
- Add vector store exceptions under `IntegrationException` or create new branch
- `VectorStoreException`, `VectorStoreOperationException`, `VectorSearchException`, `VectorStoreModelException`, etc.
#### 3.8 — `create_search_tool` on `BaseVectorSearch`
- Method on `BaseVectorSearch` that creates an AF `FunctionTool` from the vector search
- Wraps the single `search()` method, passing `search_type` parameter
- Accepts: `name`, `description`, `search_type`, `top`, `skip`, `filter`, `string_mapper`
- The tool takes a query string, vectorizes it, searches, and returns results as strings
- Can also be a standalone factory function in `_vectors.py`
#### 3.9 — Tests for all vector store abstractions
- Unit tests for enums, field types, collection definition
- Unit tests for decorator
- Unit tests for serialization/deserialization
- Unit tests for record handler
---
### Phase 4: In-Memory Vector Store
**Goal:** Provide a zero-dependency vector store for testing and development.
**Mergeable:** Yes — first usable vector store.
#### 4.1 — Port `InMemoryCollection` and `InMemoryStore` into core
- Place in `agent_framework/_vectors.py` (alongside the abstractions)
- Supports vector search (cosine similarity, etc.)
- No external dependencies
#### 4.2 — Port FAISS extension (optional, can be separate package)
- Extends InMemory with FAISS indexing
#### 4.3 — Tests and sample code
---
### Phase 5: Vector Store Connectors — Tier 1 (High Priority)
**Goal:** Ship the most commonly used vector store connectors.
**Mergeable:** Yes — each connector is independent.
Each connector follows the AF package structure:
- New package under `packages/`
- Own `pyproject.toml`, `tests/`, lazy loading in core
#### 5.1 — Azure AI Search (`packages/azure-ai-search/`)
- May extend existing package or be new
- `AzureAISearchCollection`, `AzureAISearchStore`
#### 5.2 — Qdrant (`packages/qdrant/`)
- New package
- `QdrantCollection`, `QdrantStore`
#### 5.3 — Redis (`packages/redis/`)
- May extend existing redis package
- `RedisCollection` (JSON + Hashset variants), `RedisStore`
#### 5.4 — PostgreSQL/pgvector (`packages/postgres/`)
- New package
- `PostgresCollection`, `PostgresStore`
---
### Phase 6: Vector Store Connectors — Tier 2
**Goal:** Ship remaining vector store connectors.
**Mergeable:** Yes — each connector is independent.
#### 6.1 — MongoDB Atlas (`packages/mongodb/`)
#### 6.2 — Azure Cosmos DB (`packages/azure-cosmos-db/`)
- Cosmos Mongo + Cosmos NoSQL
#### 6.3 — Pinecone (`packages/pinecone/`)
#### 6.4 — Chroma (`packages/chroma/`)
#### 6.5 — Weaviate (`packages/weaviate/`)
---
### Phase 7: Vector Store Connectors — Tier 3
**Goal:** Ship niche or less common connectors.
**Mergeable:** Yes — each connector is independent.
#### 7.1 — Oracle (`packages/oracle/`)
#### 7.2 — SQL Server (`packages/sql-server/`)
#### 7.3 — FAISS (`packages/faiss/` or in core extending InMemory)
> **Note:** When implementing any SQL-based connector (PostgreSQL, SQL Server, SQLite, Cosmos DB), review the .NET MEVD changes made by @roji (Shay Rojansky) in SK for design patterns, query building, filter translation, and feature parity: https://github.com/microsoft/semantic-kernel/pulls?q=is%3Apr+author%3Aroji+is%3Aclosed
---
### Phase 8: Vector Store CRUD Tools
**Goal:** Provide a full set of agent-usable tools for CRUD operations on vector store collections.
**Mergeable:** Yes — adds tools without changing existing APIs.
#### 8.1 — `create_upsert_tool` — tool for upserting records into a collection
#### 8.2 — `create_get_tool` — tool for retrieving records by key
- Key-based lookup only (by primary key), not a search tool
- Documentation must clearly distinguish this from `create_search_tool`: get_tool retrieves specific records by their known key, while search_tool performs similarity/filtered search across the collection
- Consider if this overlaps with filtered search and document when to use which
#### 8.3 — `create_delete_tool` — tool for deleting records by key
#### 8.4 — Tests and samples for CRUD tools
---
### Phase 9: Additional Embedding Implementations (New Providers)
**Goal:** Provide embedding generators for providers that don't yet have AF packages.
**Mergeable:** Yes — each is independent, new packages.
#### 9.1 — HuggingFace/ONNX embedding (new package or lab)
#### 9.2 — Mistral AI embedding (new package)
#### 9.3 — Google AI / Vertex AI embedding (new package)
#### 9.4 — Nvidia embedding (new package)
---
### Phase 10: TextSearch Abstractions & Implementations (Separate Work)
**Goal:** Port text search (non-vector) abstractions and implementations.
**Mergeable:** Yes — independent of vector stores.
#### 10.1 — TextSearch base class and types
- `SearchOptions`, `SearchResponse`, `TextSearchResult`
- `TextSearch` base class with `search()` method
- `create_search_function()` for kernel integration (may need AF equivalent)
#### 10.2 — Brave Search implementation
#### 10.3 — Google Search implementation
#### 10.4 — Vector store text search bridge (connecting VectorSearch to TextSearch interface)
---
## Key Considerations
1. **No Pydantic for internal classes**: All AF internal classes should use plain classes. Pydantic is only used for user-facing input validation (e.g., vector store data models).
2. **Protocol + Base class**: Follow AF's pattern of both a `Protocol` for duck-typing and a `Base` ABC for implementation, matching how `SupportsChatGetResponse` + `BaseChatClient` works.
3. **Exception hierarchy**: Use AF's `IntegrationException` branch for vector store operations, since vector stores are external dependencies.
4. **`from __future__ import annotations`**: Required in all files per AF coding standard.
5. **No `**kwargs` escape hatches in public APIs**: For user-facing interfaces, use explicit named parameters per AF coding standard. Internal implementation details (e.g., cooperative multiple inheritance / MRO patterns) may use `**kwargs` where necessary, as long as they are not exposed in public signatures.
6. **Lazy loading**: Connector packages use `__getattr__` lazy loading in core provider folders.
7. **Reusable data models**: The `@vectorstoremodel` decorator and `VectorStoreCollectionDefinition` should be agnostic enough to work with both SK and AF. The core types (`FieldTypes`, `IndexKind`, `DistanceFunction`, `VectorStoreField`) should be identical or easily mapped.
8. **`create_search_tool`**: The AF-native equivalent of SK's `create_search_function`. Instead of creating a `KernelFunction`, this creates an AF `FunctionTool` (via the `@tool` decorator pattern) from a vector search. This allows agents to use vector search as a tool during conversations. Design:
- `create_search_tool(name, description, search_type, ...)` → returns a `FunctionTool` that wraps `VectorSearch.search(search_type=...)`
- The tool accepts a query string, performs embedding + vector search, and returns results as strings
- Supports configurable string mappers, filter functions, top/skip defaults
- Lives in `_vectors.py` as a method on `BaseVectorSearch` and/or as a standalone factory function
9. **CRUD tools**: A full set of create/read/update/delete tools for vector store collections, allowing agents to manage data in vector stores. Design:
- `create_upsert_tool(...)` → tool for upserting records
- `create_get_tool(...)` → tool for retrieving records by key
- `create_delete_tool(...)` → tool for deleting records
- These are separate from search and are placed in a later phase
10. **Score threshold filtering**: `SearchOptions` includes `score_threshold: float | None` to filter search results by relevance score (ref: [SK .NET PR #13501](https://github.com/microsoft/semantic-kernel/pull/13501)). The semantics depend on the distance function: for similarity functions (cosine similarity, dot product), results *below* the threshold are filtered out; for distance functions (cosine distance, euclidean), results *above* the threshold are filtered out. Use `DISTANCE_FUNCTION_DIRECTION_HELPER` to determine direction. Connectors should implement this natively where the database supports it, falling back to client-side post-filtering otherwise.
+1
View File
@@ -29,6 +29,7 @@ using types like `IChatClient`, `FunctionInvokingChatClient`, `AITool`, `AIFunct
## Key Conventions
- **Encoding**: All new files must be saved with UTF-8 encoding with BOM (Byte Order Mark). This is required for `dotnet format` to work correctly.
- **Copyright header**: `// Copyright (c) Microsoft. All rights reserved.` at top of all `.cs` files
- **XML docs**: Required for all public methods and classes
- **Async**: Use `Async` suffix for methods returning `Task`/`ValueTask`
+3 -3
View File
@@ -111,9 +111,9 @@
<!-- Identity -->
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.78.0" />
<!-- Workflows -->
<PackageVersion Include="Microsoft.Agents.ObjectModel" Version="2026.2.3.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel.Json" Version="2026.2.3.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel.PowerFx" Version="2026.2.3.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel" Version="2026.2.4.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel.Json" Version="2026.2.4.1" />
<PackageVersion Include="Microsoft.Agents.ObjectModel.PowerFx" Version="2026.2.4.1" />
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.8.1" />
<!-- Durable Task -->
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
+17 -1
View File
@@ -96,6 +96,10 @@
<Project Path="samples/GettingStarted/Agents/Agent_Step19_Declarative/Agent_Step19_Declarative.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Agent_Step20_AdditionalAIContext.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/AgentSkills/">
<File Path="samples/GettingStarted/AgentSkills/README.md" />
<Project Path="samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/DeclarativeAgents/">
<Project Path="samples/GettingStarted/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
</Folder>
@@ -138,6 +142,7 @@
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj" />
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/AgentWithMemory_Step03_CustomMemory.csproj" />
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/AgentWithOpenAI/">
<File Path="samples/GettingStarted/AgentWithOpenAI/README.md" />
@@ -176,8 +181,13 @@
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step13_Plugins/FoundryAgents_Step13_Plugins.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/FoundryAgents_Step14_CodeInterpreter.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step15_ComputerUse/FoundryAgents_Step15_ComputerUse.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step19_OpenAPITools/FoundryAgents_Step19_OpenAPITools.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step18_FileSearch/FoundryAgents_Step18_FileSearch.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step19_OpenAPITools/FoundryAgents_Step19_OpenAPITools.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step21_BingCustomSearch/FoundryAgents_Step21_BingCustomSearch.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step22_SharePoint/FoundryAgents_Step22_SharePoint.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step23_MicrosoftFabric/FoundryAgents_Step23_MicrosoftFabric.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step25_WebSearch/FoundryAgents_Step25_WebSearch.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step26_MemorySearch/FoundryAgents_Step26_MemorySearch.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Evaluations_Step01_RedTeaming/FoundryAgents_Evaluations_Step01_RedTeaming.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Evaluations_Step02_SelfReflection/FoundryAgents_Evaluations_Step02_SelfReflection.csproj" />
</Folder>
@@ -218,6 +228,7 @@
<Project Path="samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/Declarative/Examples/">
<File Path="../workflow-samples/CustomerSupport.yaml" />
@@ -424,10 +435,12 @@
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
@@ -445,6 +458,7 @@
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj" />
@@ -467,10 +481,12 @@
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
+4 -4
View File
@@ -2,11 +2,11 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<RCNumber>1</RCNumber>
<RCNumber>2</RCNumber>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260219.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260219.1</PackageVersion>
<GitTag>1.0.0-rc1</GitTag>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260225.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260225.1</PackageVersion>
<GitTag>1.0.0-rc2</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
+1
View File
@@ -7,6 +7,7 @@
<IsAotCompatible>false</IsAotCompatible>
<TargetFrameworks>net10.0;net472</TargetFrameworks>
<UserSecretsId>5ee045b0-aea3-4f08-8d31-32d1a6f8fed0</UserSecretsId>
<NoWarn>$(NoWarn);MAAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -5,6 +5,7 @@
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
@@ -21,3 +22,16 @@ AIAgent agent = new AzureOpenAIClient(
// Invoke the agent and output the text result.
Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate."));
// Create a responses based agent with "store"=false.
// This means that chat history is managed locally by Agent Framework
// instead of being stored in the service (default).
AIAgent agentStoreFalse = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetResponsesClient(deploymentName)
.AsIChatClientWithStoredOutputDisabled()
.AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker");
// Invoke the agent and output the text result.
Console.WriteLine(await agentStoreFalse.RunAsync("Tell me a joke about a pirate."));
@@ -0,0 +1,28 @@
<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>
<!-- Copy skills directory to output -->
<ItemGroup>
<None Include="skills\**\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use Agent Skills with a ChatClientAgent.
// Agent Skills are modular packages of instructions and resources that extend an agent's capabilities.
// Skills follow the progressive disclosure pattern: advertise -> load -> read resources.
//
// This sample includes the expense-report skill:
// - Policy-based expense filing with references and assets
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";
// --- Skills Provider ---
// Discovers skills from the 'skills' directory and makes them available to the agent
var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills"));
// --- Agent Setup ---
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetResponsesClient(deploymentName)
.AsAIAgent(new ChatClientAgentOptions
{
Name = "SkillsAgent",
ChatOptions = new()
{
Instructions = "You are a helpful assistant.",
},
AIContextProviders = [skillsProvider],
});
// --- Example 1: Expense policy question (loads FAQ resource) ---
Console.WriteLine("Example 1: Checking expense policy FAQ");
Console.WriteLine("---------------------------------------");
AgentResponse response1 = await agent.RunAsync("Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered.");
Console.WriteLine($"Agent: {response1.Text}\n");
// --- Example 2: Filing an expense report (multi-turn with template asset) ---
Console.WriteLine("Example 2: Filing an expense report");
Console.WriteLine("---------------------------------------");
AgentSession session = await agent.CreateSessionAsync();
AgentResponse response2 = await agent.RunAsync("I had 3 client dinners and a $1,200 flight last week. Return a draft expense report and ask about any missing details.",
session);
Console.WriteLine($"Agent: {response2.Text}\n");
@@ -0,0 +1,63 @@
# Agent Skills Sample
This sample demonstrates how to use **Agent Skills** with a `ChatClientAgent` in the Microsoft Agent Framework.
## What are Agent Skills?
Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern:
1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill)
2. **Load**: Full instructions are loaded on-demand via `load_skill` tool
3. **Resources**: References and other files loaded via `read_skill_resource` tool
## Skills Included
### expense-report
Policy-based expense filing with spending limits, receipt requirements, and approval workflows.
- `references/POLICY_FAQ.md` — Detailed expense policy Q&A
- `assets/expense-report-template.md` — Submission template
## Project Structure
```
Agent_Step01_BasicSkills/
├── Program.cs
├── Agent_Step01_BasicSkills.csproj
└── skills/
└── expense-report/
├── SKILL.md
├── references/
│ └── POLICY_FAQ.md
└── assets/
└── expense-report-template.md
```
## Running the Sample
### Prerequisites
- .NET 10.0 SDK
- Azure OpenAI endpoint with a deployed model
### Setup
1. Set environment variables:
```bash
export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
```
2. Run the sample:
```bash
dotnet run
```
### Examples
The sample runs two examples:
1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource
2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset
## Learn More
- [Agent Skills Specification](https://agentskills.io/)
- [Microsoft Agent Framework Documentation](../../../../../docs/)
@@ -0,0 +1,40 @@
---
name: expense-report
description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories.
metadata:
author: contoso-finance
version: "2.1"
---
# Expense Report
## Categories and Limits
| Category | Limit | Receipt | Approval |
|---|---|---|---|
| Meals — solo | $50/day | >$25 | No |
| Meals — team/client | $75/person | Always | Manager if >$200 total |
| Lodging | $250/night | Always | Manager if >3 nights |
| Ground transport | $100/day | >$15 | No |
| Airfare | Economy | Always | Manager; VP if >$1,500 |
| Conference/training | $2,000/event | Always | Manager + L&D |
| Office supplies | $100 | Yes | No |
| Software/subscriptions | $50/month | Yes | Manager if >$200/year |
## Filing Process
1. Collect receipts — must show vendor, date, amount, payment method.
2. Categorize per table above.
3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md).
4. For client/team meals: list attendee names and business purpose.
5. Submit — auto-approved if <$500; manager if $500–$2,000; VP if >$2,000.
6. Reimbursement: 10 business days via direct deposit.
## Policy Rules
- Submit within 30 days of transaction.
- Alcohol is never reimbursable.
- Foreign currency: convert to USD at transaction-date rate; note original currency and amount.
- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes.
- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter.
- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state.
@@ -0,0 +1,5 @@
# Expense Report Template
| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached |
|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------|
| | | | | | | | | | Yes or No |
@@ -0,0 +1,55 @@
# Expense Policy — Frequently Asked Questions
## Meals
**Q: Can I expense coffee or snacks during the workday?**
A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal.
**Q: What if a team dinner exceeds the per-person limit?**
A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP.
**Q: Do I need to list every attendee?**
A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list.
## Travel
**Q: Can I book a premium economy or business class flight?**
A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation.
**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?**
A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people.
**Q: Are tips reimbursable?**
A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification.
## Lodging
**Q: What if the $250/night limit isn't enough for the city I'm visiting?**
A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking.
**Q: Can I stay with friends/family instead and get a per-diem?**
A: No. Contoso reimburses actual lodging costs only, not per-diems.
## Subscriptions and Software
**Q: Can I expense a personal productivity tool?**
A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing.
**Q: What about annual subscriptions?**
A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report.
## Receipts and Documentation
**Q: My receipt is faded/damaged. What do I do?**
A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter.
**Q: Do I need a receipt for parking meters or tolls?**
A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required.
## Approval and Reimbursement
**Q: My manager is on leave. Who approves my report?**
A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system.
**Q: Can I submit expenses from a previous quarter?**
A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval.
@@ -0,0 +1,7 @@
# AgentSkills Samples
Samples demonstrating Agent Skills capabilities.
| Sample | Description |
|--------|-------------|
| [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources |
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.FoundryMemory\Microsoft.Agents.AI.FoundryMemory.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use the FoundryMemoryProvider to persist and recall memories for an agent.
// The sample stores conversation messages in an Azure AI Foundry memory store and retrieves relevant
// memories for subsequent invocations, even across new sessions.
//
// Note: Memory extraction in Azure AI Foundry is asynchronous and takes time. This sample demonstrates
// a simple polling approach to wait for memory updates to complete before querying.
using System.Text.Json;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.FoundryMemory;
string foundryEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
string memoryStoreName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_MEMORY_STORE_NAME") ?? "memory-store-sample";
string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_MODEL") ?? "gpt-4.1-mini";
string embeddingModelName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_EMBEDDING_MODEL") ?? "text-embedding-ada-002";
// Create an AIProjectClient for Foundry with Azure Identity authentication.
DefaultAzureCredential credential = new();
AIProjectClient projectClient = new(new Uri(foundryEndpoint), credential);
// Get the ChatClient from the AIProjectClient's OpenAI property using the deployment name.
// The stateInitializer can be used to customize the Foundry Memory scope per session and it will be called each time a session
// is encountered by the FoundryMemoryProvider that does not already have state stored on the session.
// If each session should have its own scope, you can create a new id per session via the stateInitializer, e.g.:
// new FoundryMemoryProvider(projectClient, memoryStoreName, stateInitializer: _ => new(new FoundryMemoryProviderScope(Guid.NewGuid().ToString())), ...)
// In our case we are storing memories scoped by user so that memories are retained across sessions.
FoundryMemoryProvider memoryProvider = new(
projectClient,
memoryStoreName,
stateInitializer: _ => new(new FoundryMemoryProviderScope("sample-user-123")));
AIAgent agent = await projectClient.CreateAIAgentAsync(deploymentName,
options: new ChatClientAgentOptions()
{
Name = "TravelAssistantWithFoundryMemory",
ChatOptions = new() { Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details." },
AIContextProviders = [memoryProvider]
});
AgentSession session = await agent.CreateSessionAsync();
Console.WriteLine("\n>> Setting up Foundry Memory Store\n");
// Ensure the memory store exists (creates it with the specified models if needed).
await memoryProvider.EnsureMemoryStoreCreatedAsync(deploymentName, embeddingModelName, "Sample memory store for travel assistant");
// Clear any existing memories for this scope to demonstrate fresh behavior.
await memoryProvider.EnsureStoredMemoriesDeletedAsync(session);
Console.WriteLine(await agent.RunAsync("Hi there! My name is Taylor and I'm planning a hiking trip to Patagonia in November.", session));
Console.WriteLine(await agent.RunAsync("I'm travelling with my sister and we love finding scenic viewpoints.", session));
// Memory extraction in Azure AI Foundry is asynchronous and takes time to process.
// WhenUpdatesCompletedAsync polls all pending updates and waits for them to complete.
Console.WriteLine("\nWaiting for Foundry Memory to process updates...");
await memoryProvider.WhenUpdatesCompletedAsync();
Console.WriteLine("Updates completed.\n");
Console.WriteLine(await agent.RunAsync("What do you already know about my upcoming trip?", session));
Console.WriteLine("\n>> Serialize and deserialize the session to demonstrate persisted state\n");
JsonElement serializedSession = await agent.SerializeSessionAsync(session);
AgentSession restoredSession = await agent.DeserializeSessionAsync(serializedSession);
Console.WriteLine(await agent.RunAsync("Can you recap the personal details you remember?", restoredSession));
Console.WriteLine("\n>> Start a new session that shares the same Foundry Memory scope\n");
Console.WriteLine("\nWaiting for Foundry Memory to process updates...");
await memoryProvider.WhenUpdatesCompletedAsync();
AgentSession newSession = await agent.CreateSessionAsync();
Console.WriteLine(await agent.RunAsync("Summarize what you already know about me.", newSession));
@@ -0,0 +1,57 @@
# Agent with Memory Using Azure AI Foundry
This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories across sessions.
## Features Demonstrated
- Creating a `FoundryMemoryProvider` with Azure Identity authentication
- Automatic memory store creation if it doesn't exist
- Multi-turn conversations with automatic memory extraction
- Memory retrieval to inform agent responses
- Session serialization and deserialization
- Memory persistence across completely new sessions
## Prerequisites
1. Azure subscription with Azure AI Foundry project
2. Azure OpenAI resource with a chat model deployment (e.g., gpt-4o-mini) and an embedding model deployment (e.g., text-embedding-ada-002)
3. .NET 10.0 SDK
4. Azure CLI logged in (`az login`)
## Environment Variables
```bash
# Azure AI Foundry project endpoint and memory store name
export FOUNDRY_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project"
export FOUNDRY_PROJECT_MEMORY_STORE_NAME="my_memory_store"
# Model deployment names (models deployed in your Foundry project)
export FOUNDRY_PROJECT_MODEL="gpt-4o-mini"
export FOUNDRY_PROJECT_EMBEDDING_MODEL="text-embedding-ada-002"
```
## Run the Sample
```bash
dotnet run
```
## Expected Output
The agent will:
1. Create the memory store if it doesn't exist (using the specified chat and embedding models)
2. Learn your name (Taylor), travel destination (Patagonia), timing (November), companions (sister), and interests (scenic viewpoints)
3. Wait for Foundry Memory to index the memories
4. Recall those details when asked about the trip
5. Demonstrate memory persistence across session serialization/deserialization
6. Show that a brand new session can still access the same memories
## Key Differences from Mem0
| Aspect | Mem0 | Azure AI Foundry Memory |
|--------|------|------------------------|
| Authentication | API Key | Azure Identity (DefaultAzureCredential) |
| Scope | ApplicationId, UserId, AgentId, ThreadId | Single `Scope` string |
| Memory Types | Single memory store | User Profile + Chat Summary |
| Hosting | Mem0 cloud or self-hosted | Azure AI Foundry managed service |
| Store Creation | N/A (automatic) | Explicit via `EnsureMemoryStoreCreatedAsync` |
@@ -7,3 +7,6 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|[Chat History memory](./AgentWithMemory_Step01_ChatHistoryMemory/)|This sample demonstrates how to enable an agent to remember messages from previous conversations.|
|[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.|
|[Custom Memory Implementation](./AgentWithMemory_Step03_CustomMemory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
|[Memory with Azure AI Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories.|
> **See also**: [Memory Search with Foundry Agents](../FoundryAgents/FoundryAgents_Step26_MemorySearch/) - demonstrates using the built-in Memory Search tool with Azure Foundry Agents.
@@ -105,12 +105,27 @@ Console.WriteLine("\n\n=== Example 5: MessageAIContextProvider middleware ===");
var contextProviderAgent = originalAgent
.AsBuilder()
.Use([new DateTimeContextProvider()])
.UseAIContextProviders(new DateTimeContextProvider())
.Build();
var contextResponse = await contextProviderAgent.RunAsync("Is it almost time for lunch?");
Console.WriteLine($"Context-enriched response: {contextResponse}");
// AIContextProvider at the chat client level. Unlike the agent-level MessageAIContextProvider,
// this operates within the IChatClient pipeline and can also enrich tools and instructions.
// It must be used within the context of a running AIAgent (uses AIAgent.CurrentRunContext).
// In this case we are attaching an AIContextProvider that only adds messages.
Console.WriteLine("\n\n=== Example 6: AIContextProvider on chat client pipeline ===");
var chatClientProviderAgent = azureOpenAIClient.AsIChatClient()
.AsBuilder()
.UseAIContextProviders(new DateTimeContextProvider())
.BuildAIAgent(
instructions: "You are an AI assistant that helps people find information.");
var chatClientContextResponse = await chatClientProviderAgent.RunAsync("Is it almost time for lunch?");
Console.WriteLine($"Chat client context-enriched response: {chatClientContextResponse}");
// Function invocation middleware that logs before and after function calls.
async ValueTask<object?> FunctionCallMiddleware(AIAgent agent, FunctionInvocationContext context, Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken cancellationToken)
{
@@ -278,7 +293,7 @@ async Task<ChatResponse> PerRequestChatClientMiddleware(IEnumerable<ChatMessage>
/// <summary>
/// A <see cref="MessageAIContextProvider"/> that injects the current date and time into the agent's context.
/// This is a simple example of how to use a MessageAIContextProvider to enrich agent messages
/// via the <see cref="AIAgentBuilder.Use(MessageAIContextProvider[])"/> extension method.
/// via the <see cref="AIAgentBuilder.UseAIContextProviders(MessageAIContextProvider[])"/> extension method.
/// </summary>
internal sealed class DateTimeContextProvider : MessageAIContextProvider
{
@@ -15,6 +15,7 @@ This sample demonstrates how to add middleware to intercept:
6. Per‑request function pipeline with approval
7. Combining agent‑level and per‑request middleware
8. MessageAIContextProvider middleware via `AIAgentBuilder.Use(...)` for injecting additional context messages
9. AIContextProvider middleware via `ChatClientBuilder.Use(...)` for enriching messages, tools, and instructions at the chat client level
## Function Invocation Middleware
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);CA1812;CS8321</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.AI.Projects" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use Bing Custom Search Tool with AI Agents.
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI.Responses;
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
string connectionId = Environment.GetEnvironmentVariable("BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID is not set.");
string instanceName = Environment.GetEnvironmentVariable("BING_CUSTOM_SEARCH_INSTANCE_NAME") ?? throw new InvalidOperationException("BING_CUSTOM_SEARCH_INSTANCE_NAME is not set.");
const string AgentInstructions = """
You are a helpful agent that can use Bing Custom Search tools to assist users.
Use the available Bing Custom Search tools to answer questions and perform tasks.
""";
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Bing Custom Search tool parameters shared by both options
BingCustomSearchToolParameters bingCustomSearchToolParameters = new([
new BingCustomSearchConfiguration(connectionId, instanceName)
]);
AIAgent agent = await CreateAgentWithMEAIAsync();
// AIAgent agent = await CreateAgentWithNativeSDKAsync();
Console.WriteLine($"Created agent: {agent.Name}");
// Run the agent with a search query
AgentResponse response = await agent.RunAsync("Search for the latest news about Microsoft AI");
Console.WriteLine("\n=== Agent Response ===");
foreach (var message in response.Messages)
{
Console.WriteLine(message.Text);
}
// Cleanup by deleting the agent
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
Console.WriteLine($"\nDeleted agent: {agent.Name}");
// --- Agent Creation Options ---
// Option 1 - Using AsAITool wrapping for the ResponseTool returned by AgentTool.CreateBingCustomSearchTool (MEAI + AgentFramework)
async Task<AIAgent> CreateAgentWithMEAIAsync()
{
return await aiProjectClient.CreateAIAgentAsync(
model: deploymentName,
name: "BingCustomSearchAgent-MEAI",
instructions: AgentInstructions,
tools: [((ResponseTool)AgentTool.CreateBingCustomSearchTool(bingCustomSearchToolParameters)).AsAITool()]);
}
// Option 2 - Using PromptAgentDefinition with AgentTool.CreateBingCustomSearchTool (Native SDK)
async Task<AIAgent> CreateAgentWithNativeSDKAsync()
{
return await aiProjectClient.CreateAIAgentAsync(
name: "BingCustomSearchAgent-NATIVE",
creationOptions: new AgentVersionCreationOptions(
new PromptAgentDefinition(model: deploymentName)
{
Instructions = AgentInstructions,
Tools = {
(ResponseTool)AgentTool.CreateBingCustomSearchTool(bingCustomSearchToolParameters),
}
})
);
}
@@ -0,0 +1,63 @@
# Using Bing Custom Search with AI Agents
This sample demonstrates how to use the Bing Custom Search tool with AI agents to perform customized web searches.
## What this sample demonstrates
- Creating agents with Bing Custom Search capabilities
- Configuring custom search instances via connection ID and instance name
- Two agent creation approaches: MEAI abstraction (Option 1) and Native SDK (Option 2)
- Running search queries through the agent
- Managing agent lifecycle (creation and deletion)
## Agent creation options
This sample provides two approaches for creating agents with Bing Custom Search:
- **Option 1 - MEAI + AgentFramework**: Uses the Agent Framework `ResponseTool` wrapped with `AsAITool()` to call the `CreateAIAgentAsync` overload that accepts `tools:[]`, while still relying on the same underlying Azure AI Projects SDK types as Option 2.
- **Option 2 - Native SDK**: Uses `PromptAgentDefinition` with `AgentVersionCreationOptions` to create the agent directly with the Azure AI Projects SDK types.
Both options produce the same result. Toggle between them by commenting/uncommenting the corresponding `CreateAgentWith*Async` call in `Program.cs`.
## Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 10 SDK or later
- Azure Foundry service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
- A Bing Custom Search resource configured in Azure and connected to your Foundry project
**Note**: This demo uses Azure Default credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource.
Set the following environment variables:
```powershell
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
$env:BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID="/subscriptions/<sub-id>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<account>/projects/<project>/connections/<connection-name>"
$env:BING_CUSTOM_SEARCH_INSTANCE_NAME="your-configuration-name"
```
### Finding the connection ID and instance name
- **Connection ID**: The full ARM resource path including the `/projects/<name>/connections/<connection-name>` segment. Find the connection name in your Foundry project under **Management center** → **Connected resources**.
- **Instance Name**: The **configuration name** from the Bing Custom Search resource (Azure portal → your Bing Custom Search resource → **Configurations**). This is _not_ the Azure resource name.
## Run the sample
Navigate to the FoundryAgents sample directory and run:
```powershell
cd dotnet/samples/GettingStarted/FoundryAgents
dotnet run --project .\FoundryAgents_Step21_BingCustomSearch
```
## Expected behavior
The sample will:
1. Create an agent with Bing Custom Search tool capabilities
2. Run the agent with a search query about Microsoft AI
3. Display the search results returned by the agent
4. Clean up resources by deleting the agent
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);CA1812;CS8321</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.AI.Projects" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use SharePoint Grounding Tool with AI Agents.
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI.Responses;
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
string sharepointConnectionId = Environment.GetEnvironmentVariable("SHAREPOINT_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("SHAREPOINT_PROJECT_CONNECTION_ID is not set.");
const string AgentInstructions = """
You are a helpful agent that can use SharePoint tools to assist users.
Use the available SharePoint tools to answer questions and perform tasks.
""";
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Create SharePoint tool options with project connection
var sharepointOptions = new SharePointGroundingToolOptions();
sharepointOptions.ProjectConnections.Add(new ToolProjectConnection(sharepointConnectionId));
AIAgent agent = await CreateAgentWithMEAIAsync();
// AIAgent agent = await CreateAgentWithNativeSDKAsync();
Console.WriteLine($"Created agent: {agent.Name}");
AgentResponse response = await agent.RunAsync("List the documents available in SharePoint");
// Display the response
Console.WriteLine("\n=== Agent Response ===");
Console.WriteLine(response);
// Display grounding annotations if any
foreach (var message in response.Messages)
{
foreach (var content in message.Contents)
{
if (content.Annotations is not null)
{
foreach (var annotation in content.Annotations)
{
Console.WriteLine($"Annotation: {annotation}");
}
}
}
}
// Cleanup by agent name removes the agent version created.
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
Console.WriteLine($"\nDeleted agent: {agent.Name}");
// --- Agent Creation Options ---
// Option 1 - Using AgentTool.CreateSharepointTool + AsAITool() (MEAI + AgentFramework)
async Task<AIAgent> CreateAgentWithMEAIAsync()
{
return await aiProjectClient.CreateAIAgentAsync(
model: deploymentName,
name: "SharePointAgent-MEAI",
instructions: AgentInstructions,
tools: [((ResponseTool)AgentTool.CreateSharepointTool(sharepointOptions)).AsAITool()]);
}
// Option 2 - Using PromptAgentDefinition SDK native type
async Task<AIAgent> CreateAgentWithNativeSDKAsync()
{
return await aiProjectClient.CreateAIAgentAsync(
name: "SharePointAgent-NATIVE",
creationOptions: new AgentVersionCreationOptions(
new PromptAgentDefinition(model: deploymentName)
{
Instructions = AgentInstructions,
Tools = { AgentTool.CreateSharepointTool(sharepointOptions) }
})
);
}
@@ -0,0 +1,50 @@
# Using SharePoint Grounding with AI Agents
This sample demonstrates how to use the SharePoint grounding tool with AI agents. The SharePoint grounding tool enables agents to search and retrieve information from SharePoint sites.
## What this sample demonstrates
- Creating agents with SharePoint grounding capabilities
- Using AgentTool.CreateSharepointTool (MEAI abstraction)
- Using native SDK SharePoint tools (PromptAgentDefinition)
- Managing agent lifecycle (creation and deletion)
## Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 10 SDK or later
- Azure Foundry service endpoint and deployment configured
- Azure authentication configured for `DefaultAzureCredential` (for example, Azure CLI logged in with `az login`, environment variables, managed identity, or IDE sign-in)
- A SharePoint project connection configured in Azure Foundry
**Note**: This demo uses `DefaultAzureCredential` for authentication. This credential will try multiple authentication mechanisms in order (such as environment variables, managed identity, Azure CLI login, and IDE sign-in) and use the first one that works. A common option for local development is to sign in with the Azure CLI using `az login` and ensure you have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively) and the [DefaultAzureCredential documentation](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential).
Set the following environment variables:
```powershell
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
$env:SHAREPOINT_PROJECT_CONNECTION_ID="your-sharepoint-connection-id" # Required: SharePoint project connection ID
```
## Run the sample
Navigate to the FoundryAgents sample directory and run:
```powershell
cd dotnet/samples/GettingStarted/FoundryAgents
dotnet run --project .\FoundryAgents_Step22_SharePoint
```
## Expected behavior
The sample will:
1. Create two agents with SharePoint grounding capabilities:
- Option 1: Using AgentTool.CreateSharepointTool (MEAI abstraction)
- Option 2: Using native SDK SharePoint tools
2. Run the agent with a query: "List the documents available in SharePoint"
3. The agent will use SharePoint grounding to search and retrieve relevant documents
4. Display the response and any grounding annotations
5. Clean up resources by deleting both agents
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);CA1812;CS8321</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.AI.Projects" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use Microsoft Fabric Tool with AI Agents.
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI.Responses;
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
string fabricConnectionId = Environment.GetEnvironmentVariable("FABRIC_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("FABRIC_PROJECT_CONNECTION_ID is not set.");
const string AgentInstructions = "You are a helpful assistant with access to Microsoft Fabric data. Answer questions based on data available through your Fabric connection.";
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Configure Microsoft Fabric tool options with project connection
var fabricToolOptions = new FabricDataAgentToolOptions();
fabricToolOptions.ProjectConnections.Add(new ToolProjectConnection(fabricConnectionId));
AIAgent agent = await CreateAgentWithMEAIAsync();
// AIAgent agent = await CreateAgentWithNativeSDKAsync();
Console.WriteLine($"Created agent: {agent.Name}");
// Run the agent with a sample query
AgentResponse response = await agent.RunAsync("What data is available in the connected Fabric workspace?");
Console.WriteLine("\n=== Agent Response ===");
foreach (var message in response.Messages)
{
Console.WriteLine(message.Text);
}
// Cleanup by deleting the agent
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
Console.WriteLine($"\nDeleted agent: {agent.Name}");
// --- Agent Creation Options ---
// Option 1 - Using AsAITool wrapping for the ResponseTool returned by AgentTool.CreateMicrosoftFabricTool (MEAI + AgentFramework)
async Task<AIAgent> CreateAgentWithMEAIAsync()
{
return await aiProjectClient.CreateAIAgentAsync(
model: deploymentName,
name: "FabricAgent-MEAI",
instructions: AgentInstructions,
tools: [((ResponseTool)AgentTool.CreateMicrosoftFabricTool(fabricToolOptions)).AsAITool()]);
}
// Option 2 - Using PromptAgentDefinition with AgentTool.CreateMicrosoftFabricTool (Native SDK)
async Task<AIAgent> CreateAgentWithNativeSDKAsync()
{
return await aiProjectClient.CreateAIAgentAsync(
name: "FabricAgent-NATIVE",
creationOptions: new AgentVersionCreationOptions(
new PromptAgentDefinition(model: deploymentName)
{
Instructions = AgentInstructions,
Tools =
{
AgentTool.CreateMicrosoftFabricTool(fabricToolOptions),
}
})
);
}
@@ -0,0 +1,57 @@
# Using Microsoft Fabric Tool with AI Agents
This sample demonstrates how to use the Microsoft Fabric tool with AI Agents, allowing agents to query and interact with data in Microsoft Fabric workspaces.
## What this sample demonstrates
- Creating agents with Microsoft Fabric data access capabilities
- Using FabricDataAgentToolOptions to configure Fabric connections
- Two agent creation approaches: MEAI abstraction (Option 1) and Native SDK (Option 2)
- Managing agent lifecycle (creation and deletion)
## Agent creation options
This sample provides two approaches for creating agents with Microsoft Fabric:
- **Option 1 - MEAI + AgentFramework**: Uses the Agent Framework `ResponseTool` wrapped with `AsAITool()` to call the `CreateAIAgentAsync` overload that accepts `tools:[]`, while still relying on the same underlying Azure AI Projects SDK types as Option 2.
- **Option 2 - Native SDK**: Uses `PromptAgentDefinition` with `AgentVersionCreationOptions` to create the agent directly with the Azure AI Projects SDK types.
Both options produce the same result. Toggle between them by commenting/uncommenting the corresponding `CreateAgentWith*Async` call in `Program.cs`.
## Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 10 SDK or later
- Azure Foundry service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
- A Microsoft Fabric workspace with a configured project connection in Azure Foundry
**Note**: This demo uses Azure Default credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource.
Set the following environment variables:
```powershell
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
$env:FABRIC_PROJECT_CONNECTION_ID="your-fabric-connection-id" # The Fabric project connection ID from Azure Foundry
```
## Run the sample
Navigate to the FoundryAgents sample directory and run:
```powershell
cd dotnet/samples/GettingStarted/FoundryAgents
dotnet run --project .\FoundryAgents_Step23_MicrosoftFabric
```
## Expected behavior
The sample will:
1. Create an agent with Microsoft Fabric tool capabilities
2. Configure the agent with a Fabric project connection
3. Run the agent with a query about available Fabric data
4. Display the agent's response
5. Clean up resources by deleting the agent
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);CA1812;CS8321</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.AI.Projects" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use the Responses API Web Search Tool with AI Agents.
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
const string AgentInstructions = "You are a helpful assistant that can search the web to find current information and answer questions accurately.";
const string AgentName = "WebSearchAgent";
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Option 1 - Using HostedWebSearchTool (MEAI + AgentFramework)
AIAgent agent = await CreateAgentWithMEAIAsync();
// Option 2 - Using PromptAgentDefinition with the Responses API native type
// AIAgent agent = await CreateAgentWithNativeSDKAsync();
AgentResponse response = await agent.RunAsync("What's the weather today in Seattle?");
// Get the text response
Console.WriteLine($"Response: {response.Text}");
// Getting any annotations/citations generated by the web search tool
foreach (AIAnnotation annotation in response.Messages.SelectMany(m => m.Contents).SelectMany(c => c.Annotations ?? []))
{
Console.WriteLine($"Annotation: {annotation}");
if (annotation.RawRepresentation is UriCitationMessageAnnotation urlCitation)
{
Console.WriteLine($$"""
Title: {{urlCitation.Title}}
URL: {{urlCitation.Uri}}
""");
}
}
// Cleanup by agent name removes the agent version created.
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
// Creates the agent using the HostedWebSearchTool MEAI abstraction that maps to the built-in Responses API web search tool.
async Task<AIAgent> CreateAgentWithMEAIAsync()
=> await aiProjectClient.CreateAIAgentAsync(
name: AgentName,
model: deploymentName,
instructions: AgentInstructions,
tools: [new HostedWebSearchTool()]);
// Creates the agent using the PromptAgentDefinition with the Responses API native ResponseTool.CreateWebSearchTool().
async Task<AIAgent> CreateAgentWithNativeSDKAsync()
=> await aiProjectClient.CreateAIAgentAsync(
AgentName,
new AgentVersionCreationOptions(
new PromptAgentDefinition(model: deploymentName)
{
Instructions = AgentInstructions,
Tools = { ResponseTool.CreateWebSearchTool() }
}));
@@ -0,0 +1,52 @@
# Using Web Search with AI Agents
This sample demonstrates how to use the Responses API web search tool with AI agents. The web search tool allows agents to search the web for current information to answer questions accurately.
## What this sample demonstrates
- Creating agents with web search capabilities
- Using HostedWebSearchTool (MEAI abstraction)
- Using native SDK web search tools (ResponseTool.CreateWebSearchTool)
- Extracting text responses and URL citations from agent responses
- Managing agent lifecycle (creation and deletion)
## Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 10 SDK or later
- Azure Foundry service endpoint and deployment configured
- Azure authentication configured for `DefaultAzureCredential` (for example, Azure CLI logged in with `az login`, environment variables, managed identity, or IDE sign-in)
**Note**: This sample authenticates using `DefaultAzureCredential` from the Azure Identity library, which will try several credential sources (including Azure CLI, environment variables, managed identity, and IDE sign-in). Ensure at least one supported credential source is available. For more information, see the [Azure Identity documentation](https://learn.microsoft.com/dotnet/api/overview/azure/identity-readme).
**Note**: The web search tool uses the built-in web search capability from the OpenAI Responses API.
Set the following environment variables:
```powershell
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
```
## Run the sample
Navigate to the FoundryAgents sample directory and run:
```powershell
cd dotnet/samples/GettingStarted/FoundryAgents
dotnet run --project .\FoundryAgents_Step25_WebSearch
```
## Expected behavior
The sample will:
1. Create an agent with web search capabilities using HostedWebSearchTool (MEAI abstraction)
- Alternative: Using native SDK web search tools (commented out in code)
- Alternative: Retrieving an existing agent by name (commented out in code)
2. Run the agent with a query: "What's the weather today in Seattle?"
3. The agent will use the web search tool to find current information
4. Display the text response from the agent
5. Display any URL citations from web search results
6. Clean up resources by deleting the agent
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);CA1812</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,124 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use the Memory Search Tool with AI Agents.
// The Memory Search Tool enables agents to recall information from previous conversations,
// supporting user profile persistence and chat summaries across sessions.
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using OpenAI.Responses;
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// Memory store configuration
// NOTE: Memory stores must be created beforehand via Azure Portal or Python SDK.
// The .NET SDK currently only supports using existing memory stores with agents.
string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_MEMORY_STORE_NAME") ?? throw new InvalidOperationException("AZURE_FOUNDRY_MEMORY_STORE_NAME is not set.");
const string AgentInstructions = """
You are a helpful assistant that remembers past conversations.
Use the memory search tool to recall relevant information from previous interactions.
When a user shares personal details or preferences, remember them for future conversations.
""";
const string AgentNameMEAI = "MemorySearchAgent-MEAI";
const string AgentNameNative = "MemorySearchAgent-NATIVE";
// Scope identifies the user or context for memory isolation.
// Using a unique user identifier ensures memories are private to that user.
string userScope = $"user_{Environment.MachineName}";
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
// Create the Memory Search tool configuration
MemorySearchTool memorySearchTool = new(memoryStoreName, userScope)
{
// Optional: Configure how quickly new memories are indexed (in seconds)
UpdateDelay = 1,
// Optional: Configure search behavior
SearchOptions = new MemorySearchToolOptions
{
// Additional search options can be configured here if needed
}
};
// Create agent using Option 1 (MEAI) or Option 2 (Native SDK)
AIAgent agent = await CreateAgentWithMEAI();
// AIAgent agent = await CreateAgentWithNativeSDK();
Console.WriteLine("Agent created with Memory Search tool. Starting conversation...\n");
// Conversation 1: Share some personal information
Console.WriteLine("User: My name is Alice and I love programming in C#.");
AgentResponse response1 = await agent.RunAsync("My name is Alice and I love programming in C#.");
Console.WriteLine($"Agent: {response1.Messages.LastOrDefault()?.Text}\n");
// Allow time for memory to be indexed
await Task.Delay(2000);
// Conversation 2: Test if the agent remembers
Console.WriteLine("User: What's my name and what programming language do I prefer?");
AgentResponse response2 = await agent.RunAsync("What's my name and what programming language do I prefer?");
Console.WriteLine($"Agent: {response2.Messages.LastOrDefault()?.Text}\n");
// Inspect memory search results if available in raw response items
// Note: Memory search tool call results appear as AgentResponseItem types
foreach (var message in response2.Messages)
{
if (message.RawRepresentation is AgentResponseItem agentResponseItem &&
agentResponseItem is MemorySearchToolCallResponseItem memorySearchResult)
{
Console.WriteLine($"Memory Search Status: {memorySearchResult.Status}");
Console.WriteLine($"Memory Search Results Count: {memorySearchResult.Results.Count}");
foreach (var result in memorySearchResult.Results)
{
var memoryItem = result.MemoryItem;
Console.WriteLine($" - Memory ID: {memoryItem.MemoryId}");
Console.WriteLine($" Scope: {memoryItem.Scope}");
Console.WriteLine($" Content: {memoryItem.Content}");
Console.WriteLine($" Updated: {memoryItem.UpdatedAt}");
}
}
}
// Cleanup: Delete the agent (memory store persists and should be cleaned up separately if needed)
Console.WriteLine("\nCleaning up agent...");
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
Console.WriteLine("Agent deleted successfully.");
// NOTE: Memory stores are long-lived resources and are NOT deleted with the agent.
// To delete a memory store, use the Azure Portal or Python SDK:
// await project_client.memory_stores.delete(memory_store.name)
// --- Agent Creation Options ---
#pragma warning disable CS8321 // Local function is declared but never used
// Option 1 - Using MemorySearchTool wrapped as MEAI AITool
async Task<AIAgent> CreateAgentWithMEAI()
{
return await aiProjectClient.CreateAIAgentAsync(
model: deploymentName,
name: AgentNameMEAI,
instructions: AgentInstructions,
tools: [((ResponseTool)memorySearchTool).AsAITool()]);
}
// Option 2 - Using PromptAgentDefinition with MemorySearchTool (Native SDK)
async Task<AIAgent> CreateAgentWithNativeSDK()
{
return await aiProjectClient.CreateAIAgentAsync(
name: AgentNameNative,
creationOptions: new AgentVersionCreationOptions(
new PromptAgentDefinition(model: deploymentName)
{
Instructions = AgentInstructions,
Tools = { memorySearchTool }
})
);
}
@@ -0,0 +1,92 @@
# Using Memory Search with AI Agents
This sample demonstrates how to use the Memory Search tool with AI agents. The Memory Search tool enables agents to recall information from previous conversations, supporting user profile persistence and chat summaries across sessions.
## What this sample demonstrates
- Creating an agent with Memory Search tool capabilities
- Configuring memory scope for user isolation
- Having conversations where the agent remembers past information
- Inspecting memory search results from agent responses
- Managing agent lifecycle (creation and deletion)
## Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 10 SDK or later
- Azure Foundry service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
- **A pre-created Memory Store** (see below)
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
### Creating a Memory Store
Memory stores must be created before running this sample. The .NET SDK currently only supports **using** existing memory stores with agents. To create a memory store, use one of these methods:
**Option 1: Azure Portal**
1. Navigate to your Azure AI Foundry project
2. Go to the Memory section
3. Create a new memory store with your desired settings
**Option 2: Python SDK**
```python
from azure.ai.projects import AIProjectClient
from azure.ai.projects.models import MemoryStoreDefaultDefinition, MemoryStoreDefaultOptions
from azure.identity import DefaultAzureCredential
project_client = AIProjectClient(
endpoint="https://your-endpoint.openai.azure.com/",
credential=DefaultAzureCredential()
)
memory_store = await project_client.memory_stores.create(
name="my-memory-store",
description="Memory store for Agent Framework conversations",
definition=MemoryStoreDefaultDefinition(
chat_model=os.environ["AZURE_AI_CHAT_MODEL_DEPLOYMENT_NAME"],
embedding_model=os.environ["AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME"],
options=MemoryStoreDefaultOptions(
user_profile_enabled=True,
chat_summary_enabled=True
)
)
)
```
## Environment Variables
Set the following environment variables:
```powershell
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
$env:AZURE_AI_MEMORY_STORE_NAME="your-memory-store-name" # Required - name of pre-created memory store
```
## Run the sample
Navigate to the FoundryAgents sample directory and run:
```powershell
cd dotnet/samples/GettingStarted/FoundryAgents
dotnet run --project .\FoundryAgents_Step26_MemorySearch
```
## Expected behavior
The sample will:
1. Create an agent with Memory Search tool configured
2. Send a message with personal information ("My name is Alice and I love programming in C#")
3. Wait for memory indexing
4. Ask the agent to recall the previously shared information
5. Display memory search results if available in the response
6. Clean up by deleting the agent (note: memory store persists)
## Important notes
- **Memory Store Lifecycle**: Memory stores are long-lived resources and are NOT deleted when the agent is deleted. Clean them up separately via Azure Portal or Python SDK.
- **Scope**: The `scope` parameter isolates memories per user/context. Use unique identifiers for different users.
- **Update Delay**: The `UpdateDelay` parameter controls how quickly new memories are indexed.
@@ -58,6 +58,11 @@ Before you begin, ensure you have the following prerequisites:
|[Using plugins](./FoundryAgents_Step13_Plugins/)|This sample demonstrates how to use plugins with a Foundry agent|
|[Code interpreter](./FoundryAgents_Step14_CodeInterpreter/)|This sample demonstrates how to use the code interpreter tool with a Foundry agent|
|[Computer use](./FoundryAgents_Step15_ComputerUse/)|This sample demonstrates how to use computer use capabilities with a Foundry agent|
|[Bing Custom Search](./FoundryAgents_Step21_BingCustomSearch/)|This sample demonstrates how to use Bing Custom Search tool with a Foundry agent|
|[SharePoint grounding](./FoundryAgents_Step22_SharePoint/)|This sample demonstrates how to use the SharePoint grounding tool with a Foundry agent|
|[Microsoft Fabric](./FoundryAgents_Step23_MicrosoftFabric/)|This sample demonstrates how to use Microsoft Fabric tool with a Foundry agent|
|[Web search](./FoundryAgents_Step25_WebSearch/)|This sample demonstrates how to use the Responses API web search tool with a Foundry agent|
|[Memory search](./FoundryAgents_Step26_MemorySearch/)|This sample demonstrates how to use memory search tool with a Foundry agent|
|[File search](./FoundryAgents_Step18_FileSearch/)|This sample demonstrates how to use the file search tool with a Foundry agent|
|[Local MCP](./FoundryAgents_Step27_LocalMCP/)|This sample demonstrates how to use a local MCP client with a Foundry agent|
+1
View File
@@ -18,3 +18,4 @@ of the agent framework.
|[Agent With Anthropic](./AgentWithAnthropic/README.md)|Getting started with agents using Anthropic Claude|
|[Workflow](./Workflows/README.md)|Getting started with Workflow|
|[Model Context Protocol](./ModelContextProtocol/README.md)|Getting started with Model Context Protocol|
|[Agent Skills](./AgentSkills/README.md)|Getting started with Agent Skills|
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.AzureAI\Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Mcp\Microsoft.Agents.AI.Workflows.Declarative.Mcp.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="InvokeMcpTool.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,63 @@
#
# This workflow demonstrates invoking MCP tools directly from a declarative workflow.
# Uses the Foundry MCP server to search AI model details.
#
# The workflow:
# 1. Accepts a model search term as input
# 2. Invokes the Foundry MCP tool
# 3. Invokes the Microsoft Learn MCP tool
# 4. Uses an agent to summarize the results
#
# Example input:
# gpt-4.1
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_mcp_tool
actions:
# Set the search query from user input or use default
- kind: SetVariable
id: set_search_query
variable: Local.SearchQuery
value: =System.LastMessage.Text
# Invoke MCP search tool on Foundry MCP server
- kind: InvokeMcpTool
id: invoke_foundry_search
serverUrl: https://mcp.ai.azure.com
serverLabel: azure_mcp_server
toolName: model_details_get
conversationId: =System.ConversationId
arguments:
modelName: =Local.SearchQuery
output:
autoSend: true
result: Local.FoundrySearchResult
# Invoke MCP search tool on Microsoft Learn server
- kind: InvokeMcpTool
id: invoke_docs_search
serverUrl: https://learn.microsoft.com/api/mcp
serverLabel: microsoft_docs
toolName: microsoft_docs_search
conversationId: =System.ConversationId
arguments:
query: =Local.SearchQuery
output:
autoSend: true
result: Local.DocsSearchResult
# Use the search agent to provide a helpful response based on results
- kind: InvokeAzureAgent
id: summarize_results
agent:
name: McpSearchAgent
conversationId: =System.ConversationId
input:
messages: =UserMessage("Based on the search results for '" & Local.SearchQuery & "', please provide a helpful summary.")
output:
autoSend: true
result: Local.Summary
@@ -0,0 +1,141 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates using the InvokeMcpTool action to call MCP (Model Context Protocol)
// server tools directly from a declarative workflow. MCP servers expose tools that can be
// invoked to perform specific tasks, like searching documentation or executing operations.
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using Azure.Core;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
using Microsoft.Extensions.Configuration;
using Shared.Foundry;
using Shared.Workflows;
namespace Demo.Workflows.Declarative.InvokeMcpTool;
/// <summary>
/// Demonstrates a workflow that uses InvokeMcpTool to call MCP server tools
/// directly from the workflow.
/// </summary>
/// <remarks>
/// <para>
/// The InvokeMcpTool action allows workflows to invoke tools on MCP (Model Context Protocol)
/// servers. This enables:
/// </para>
/// <list type="bullet">
/// <item>Searching external data sources like documentation</item>
/// <item>Executing operations on remote servers</item>
/// <item>Integrating with MCP-compatible services</item>
/// </list>
/// <para>
/// This sample uses the Microsoft Learn MCP server to search Azure documentation and the Azure foundry MCP server to get AI model details.
/// When you run the sample, provide an AI model (e.g. gpt-4.1-mini) as input,
/// The workflow will use the MCP tools to find relevant information about the model from Microsoft Learn and foundry, then an agent will summarize the results.
/// </para>
/// <para>
/// See the README.md file in the parent folder (../README.md) for detailed
/// information about the configuration required to run this sample.
/// </para>
/// </remarks>
internal sealed class Program
{
public static async Task Main(string[] args)
{
// Initialize configuration
IConfiguration configuration = Application.InitializeConfig();
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
// Ensure sample agent exists in Foundry
await CreateAgentAsync(foundryEndpoint, configuration);
// Get input from command line or console
string workflowInput = Application.GetInput(args);
// Create the MCP tool handler for invoking MCP server tools.
// The HttpClient callback allows configuring authentication per MCP server.
// Different MCP servers may require different authentication configurations.
// For Production scenarios, consider implementing a more robust HttpClient management strategy to reuse HttpClient instances and manage their lifetimes appropriately.
List<HttpClient> createdHttpClients = [];
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
DefaultAzureCredential credential = new();
DefaultMcpToolHandler mcpToolHandler = new(
httpClientProvider: async (serverUrl, cancellationToken) =>
{
if (serverUrl.StartsWith("https://mcp.ai.azure.com", StringComparison.OrdinalIgnoreCase))
{
// Acquire token for the Azure MCP server
AccessToken token = await credential.GetTokenAsync(
new TokenRequestContext(["https://mcp.ai.azure.com/.default"]),
cancellationToken);
// Create HttpClient with Authorization header
HttpClient httpClient = new();
httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.Token);
createdHttpClients.Add(httpClient);
return httpClient;
}
if (serverUrl.StartsWith("https://learn.microsoft.com", StringComparison.OrdinalIgnoreCase))
{
// Microsoft Learn MCP server does not require authentication
HttpClient httpClient = new();
createdHttpClients.Add(httpClient);
return httpClient;
}
// Return null for unknown servers to use the default HttpClient without auth.
return null;
});
try
{
// Create the workflow factory with MCP tool provider
WorkflowFactory workflowFactory = new("InvokeMcpTool.yaml", foundryEndpoint)
{
McpToolHandler = mcpToolHandler
};
// Execute the workflow
WorkflowRunner runner = new() { UseJsonCheckpoints = true };
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
}
finally
{
// Clean up connections and dispose created HttpClients
await mcpToolHandler.DisposeAsync();
foreach (HttpClient httpClient in createdHttpClients)
{
httpClient.Dispose();
}
}
}
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration)
{
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential());
await aiProjectClient.CreateAgentAsync(
agentName: "McpSearchAgent",
agentDefinition: DefineSearchAgent(configuration),
agentDescription: "Provides information based on search results");
}
private static PromptAgentDefinition DefineSearchAgent(IConfiguration configuration)
{
return new PromptAgentDefinition(configuration.GetValue(Application.Settings.FoundryModelMini))
{
Instructions =
"""
You are a helpful assistant that answers questions based on search results.
Use the information provided in the conversation history to answer questions.
If the information is already available in the conversation, use it directly.
Be concise and helpful in your responses.
"""
};
}
}
@@ -435,7 +435,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
var partitionKey = BuildPartitionKey(state);
// Efficient count query
var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId AND c.Type = @type")
var query = new QueryDefinition("SELECT VALUE COUNT(1) FROM c WHERE c.conversationId = @conversationId AND c.type = @type")
.WithParameter("@conversationId", state.ConversationId)
.WithParameter("@type", "ChatMessage");
@@ -469,7 +469,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable
var partitionKey = BuildPartitionKey(state);
// Batch delete for efficiency
var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE c.conversationId = @conversationId AND c.Type = @type")
var query = new QueryDefinition("SELECT VALUE c.id FROM c WHERE c.conversationId = @conversationId AND c.type = @type")
.WithParameter("@conversationId", state.ConversationId)
.WithParameter("@type", "ChatMessage");
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ClientModel;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects;
namespace Microsoft.Agents.AI.FoundryMemory;
/// <summary>
/// Internal extension methods for <see cref="AIProjectClient"/> to provide MemoryStores helper operations.
/// </summary>
internal static class AIProjectClientExtensions
{
/// <summary>
/// Creates a memory store if it doesn't already exist.
/// </summary>
internal static async Task<bool> CreateMemoryStoreIfNotExistsAsync(
this AIProjectClient client,
string memoryStoreName,
string? description,
string chatModel,
string embeddingModel,
CancellationToken cancellationToken)
{
try
{
await client.MemoryStores.GetMemoryStoreAsync(memoryStoreName, cancellationToken).ConfigureAwait(false);
return false; // Store already exists
}
catch (ClientResultException ex) when (ex.Status == 404)
{
// Store doesn't exist, create it
}
MemoryStoreDefaultDefinition definition = new(chatModel, embeddingModel);
await client.MemoryStores.CreateMemoryStoreAsync(memoryStoreName, definition, description, cancellationToken: cancellationToken).ConfigureAwait(false);
return true;
}
}
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.FoundryMemory;
/// <summary>
/// Provides JSON serialization utilities for the Foundry Memory provider.
/// </summary>
internal static class FoundryMemoryJsonUtilities
{
/// <summary>
/// Gets the default JSON serializer options for Foundry Memory operations.
/// </summary>
public static JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
TypeInfoResolver = FoundryMemoryJsonContext.Default
};
}
/// <summary>
/// Source-generated JSON serialization context for Foundry Memory types.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.General,
UseStringEnumConverter = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
WriteIndented = false)]
[JsonSerializable(typeof(FoundryMemoryProviderScope))]
[JsonSerializable(typeof(FoundryMemoryProvider.State))]
internal partial class FoundryMemoryJsonContext : JsonSerializerContext;
@@ -0,0 +1,440 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.FoundryMemory;
/// <summary>
/// Provides an Azure AI Foundry Memory backed <see cref="AIContextProvider"/> that persists conversation messages as memories
/// and retrieves related memories to augment the agent invocation context.
/// </summary>
/// <remarks>
/// The provider stores user, assistant and system messages as Foundry memories and retrieves relevant memories
/// for new invocations using the memory search endpoint. Retrieved memories are injected as user messages
/// to the model, prefixed by a configurable context prompt.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FoundryMemoryProvider : AIContextProvider
{
private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:";
private readonly ProviderSessionState<State> _sessionState;
private readonly string _contextPrompt;
private readonly string _memoryStoreName;
private readonly int _maxMemories;
private readonly int _updateDelay;
private readonly bool _enableSensitiveTelemetryData;
private readonly AIProjectClient _client;
private readonly ILogger<FoundryMemoryProvider>? _logger;
private string? _lastPendingUpdateId;
/// <summary>
/// Initializes a new instance of the <see cref="FoundryMemoryProvider"/> class.
/// </summary>
/// <param name="client">The Azure AI Project client configured for your Foundry project.</param>
/// <param name="memoryStoreName">The name of the memory store in Azure AI Foundry.</param>
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation, providing the scope for memory storage and retrieval.</param>
/// <param name="options">Provider options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="stateInitializer"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="memoryStoreName"/> is null or whitespace.</exception>
public FoundryMemoryProvider(
AIProjectClient client,
string memoryStoreName,
Func<AgentSession?, State> stateInitializer,
FoundryMemoryProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
: base(options?.SearchInputMessageFilter, options?.StorageInputMessageFilter)
{
Throw.IfNull(client);
Throw.IfNullOrWhitespace(memoryStoreName);
this._sessionState = new ProviderSessionState<State>(
ValidateStateInitializer(Throw.IfNull(stateInitializer)),
options?.StateKey ?? this.GetType().Name,
FoundryMemoryJsonUtilities.DefaultOptions);
FoundryMemoryProviderOptions effectiveOptions = options ?? new FoundryMemoryProviderOptions();
this._logger = loggerFactory?.CreateLogger<FoundryMemoryProvider>();
this._client = client;
this._contextPrompt = effectiveOptions.ContextPrompt ?? DefaultContextPrompt;
this._memoryStoreName = memoryStoreName;
this._maxMemories = effectiveOptions.MaxMemories;
this._updateDelay = effectiveOptions.UpdateDelay;
this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData;
}
/// <inheritdoc />
public override string StateKey => this._sessionState.StateKey;
private static Func<AgentSession?, State> ValidateStateInitializer(Func<AgentSession?, State> stateInitializer) =>
session =>
{
State state = stateInitializer(session);
if (state is null)
{
throw new InvalidOperationException("State initializer must return a non-null state.");
}
return state;
};
/// <inheritdoc />
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
Throw.IfNull(context);
State state = this._sessionState.GetOrInitializeState(context.Session);
FoundryMemoryProviderScope scope = state.Scope;
List<ResponseItem> messageItems = (context.AIContext.Messages ?? [])
.Where(m => !string.IsNullOrWhiteSpace(m.Text))
.Select(m => (ResponseItem)ToResponseItem(m.Role, m.Text!))
.ToList();
if (messageItems.Count == 0)
{
return new AIContext();
}
try
{
MemorySearchOptions searchOptions = new(scope.Scope)
{
ResultOptions = new MemorySearchResultOptions { MaxMemories = this._maxMemories }
};
foreach (ResponseItem item in messageItems)
{
searchOptions.Items.Add(item);
}
ClientResult<MemoryStoreSearchResponse> result = await this._client.MemoryStores.SearchMemoriesAsync(
this._memoryStoreName,
searchOptions,
cancellationToken).ConfigureAwait(false);
MemoryStoreSearchResponse response = result.Value;
List<string> memories = response.Memories
.Select(m => m.MemoryItem?.Content ?? string.Empty)
.Where(c => !string.IsNullOrWhiteSpace(c))
.ToList();
string? outputMessageText = memories.Count == 0
? null
: $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}";
if (this._logger?.IsEnabled(LogLevel.Information) is true)
{
this._logger.LogInformation(
"FoundryMemoryProvider: Retrieved {Count} memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
memories.Count,
this._memoryStoreName,
this.SanitizeLogData(scope.Scope));
if (outputMessageText is not null && this._logger.IsEnabled(LogLevel.Trace))
{
this._logger.LogTrace(
"FoundryMemoryProvider: Search Results\nOutput:{MessageText}\nMemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
this.SanitizeLogData(outputMessageText),
this._memoryStoreName,
this.SanitizeLogData(scope.Scope));
}
}
return new AIContext
{
Messages = [new ChatMessage(ChatRole.User, outputMessageText)]
};
}
catch (ArgumentException)
{
throw;
}
catch (Exception ex)
{
if (this._logger?.IsEnabled(LogLevel.Error) is true)
{
this._logger.LogError(
ex,
"FoundryMemoryProvider: Failed to search for memories due to error. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
this._memoryStoreName,
this.SanitizeLogData(scope.Scope));
}
return new AIContext();
}
}
/// <inheritdoc />
protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
State state = this._sessionState.GetOrInitializeState(context.Session);
FoundryMemoryProviderScope scope = state.Scope;
try
{
List<ResponseItem> messageItems = context.RequestMessages
.Concat(context.ResponseMessages ?? [])
.Where(m => IsAllowedRole(m.Role) && !string.IsNullOrWhiteSpace(m.Text))
.Select(m => (ResponseItem)ToResponseItem(m.Role, m.Text!))
.ToList();
if (messageItems.Count == 0)
{
return;
}
MemoryUpdateOptions updateOptions = new(scope.Scope)
{
UpdateDelay = this._updateDelay
};
foreach (ResponseItem item in messageItems)
{
updateOptions.Items.Add(item);
}
ClientResult<MemoryUpdateResult> result = await this._client.MemoryStores.UpdateMemoriesAsync(
this._memoryStoreName,
updateOptions,
cancellationToken).ConfigureAwait(false);
MemoryUpdateResult response = result.Value;
if (response.UpdateId is not null)
{
Interlocked.Exchange(ref this._lastPendingUpdateId, response.UpdateId);
}
if (this._logger?.IsEnabled(LogLevel.Information) is true)
{
this._logger.LogInformation(
"FoundryMemoryProvider: Sent {Count} messages to update memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}', UpdateId: '{UpdateId}'.",
messageItems.Count,
this._memoryStoreName,
this.SanitizeLogData(scope.Scope),
response.UpdateId);
}
}
catch (Exception ex)
{
if (this._logger?.IsEnabled(LogLevel.Error) is true)
{
this._logger.LogError(
ex,
"FoundryMemoryProvider: Failed to send messages to update memories due to error. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
this._memoryStoreName,
this.SanitizeLogData(scope.Scope));
}
}
}
/// <summary>
/// Ensures all stored memories for the configured scope are deleted.
/// This method handles cases where the scope doesn't exist (no memories stored yet).
/// </summary>
/// <param name="session">The session containing the scope state to clear memories for.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task EnsureStoredMemoriesDeletedAsync(AgentSession session, CancellationToken cancellationToken = default)
{
Throw.IfNull(session);
State state = this._sessionState.GetOrInitializeState(session);
FoundryMemoryProviderScope scope = state.Scope;
try
{
await this._client.MemoryStores.DeleteScopeAsync(this._memoryStoreName, scope.Scope, cancellationToken).ConfigureAwait(false);
if (this._logger?.IsEnabled(LogLevel.Information) is true)
{
this._logger.LogInformation(
"FoundryMemoryProvider: Deleted stored memories for scope. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
this._memoryStoreName,
this.SanitizeLogData(scope.Scope));
}
}
catch (ClientResultException ex) when (ex.Status == 404)
{
// Scope doesn't exist (no memories stored yet), nothing to delete
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
{
this._logger.LogDebug(
"FoundryMemoryProvider: No memories to delete for scope. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.",
this._memoryStoreName,
this.SanitizeLogData(scope.Scope));
}
}
}
/// <summary>
/// Ensures the memory store exists, creating it if necessary.
/// </summary>
/// <param name="chatModel">The deployment name of the chat model for memory processing.</param>
/// <param name="embeddingModel">The deployment name of the embedding model for memory search.</param>
/// <param name="description">Optional description for the memory store.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task EnsureMemoryStoreCreatedAsync(
string chatModel,
string embeddingModel,
string? description = null,
CancellationToken cancellationToken = default)
{
bool created = await this._client.CreateMemoryStoreIfNotExistsAsync(
this._memoryStoreName,
description,
chatModel,
embeddingModel,
cancellationToken).ConfigureAwait(false);
if (created)
{
if (this._logger?.IsEnabled(LogLevel.Information) is true)
{
this._logger.LogInformation(
"FoundryMemoryProvider: Created memory store '{MemoryStoreName}'.",
this._memoryStoreName);
}
}
else
{
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
{
this._logger.LogDebug(
"FoundryMemoryProvider: Memory store '{MemoryStoreName}' already exists.",
this._memoryStoreName);
}
}
}
/// <summary>
/// Waits for all pending memory update operations to complete.
/// </summary>
/// <remarks>
/// Memory extraction in Azure AI Foundry is asynchronous. This method polls the latest pending update
/// and returns when it has completed, failed, or been superseded. Since updates are processed in order,
/// completion of the latest update implies all prior updates have also been processed.
/// </remarks>
/// <param name="pollingInterval">The interval between status checks. Defaults to 5 seconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="InvalidOperationException">Thrown if the update operation failed.</exception>
public async Task WhenUpdatesCompletedAsync(
TimeSpan? pollingInterval = null,
CancellationToken cancellationToken = default)
{
string? updateId = Volatile.Read(ref this._lastPendingUpdateId);
if (updateId is null)
{
return;
}
TimeSpan interval = pollingInterval ?? TimeSpan.FromSeconds(5);
await this.WaitForUpdateAsync(updateId, interval, cancellationToken).ConfigureAwait(false);
// Only clear the pending update ID after successful completion
Interlocked.CompareExchange(ref this._lastPendingUpdateId, null, updateId);
}
private async Task WaitForUpdateAsync(string updateId, TimeSpan interval, CancellationToken cancellationToken)
{
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
ClientResult<MemoryUpdateResult> result = await this._client.MemoryStores.GetUpdateResultAsync(
this._memoryStoreName,
updateId,
cancellationToken).ConfigureAwait(false);
MemoryUpdateResult response = result.Value;
MemoryStoreUpdateStatus status = response.Status;
if (this._logger?.IsEnabled(LogLevel.Debug) is true)
{
this._logger.LogDebug(
"FoundryMemoryProvider: Update status for '{UpdateId}': {Status}",
updateId,
status);
}
if (status == MemoryStoreUpdateStatus.Completed || status == MemoryStoreUpdateStatus.Superseded)
{
return;
}
if (status == MemoryStoreUpdateStatus.Failed)
{
throw new InvalidOperationException($"Memory update operation '{updateId}' failed: {response.ErrorDetails}");
}
if (status == MemoryStoreUpdateStatus.Queued || status == MemoryStoreUpdateStatus.InProgress)
{
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
}
else
{
throw new InvalidOperationException($"Unknown update status '{status}' for update '{updateId}'.");
}
}
}
private static MessageResponseItem ToResponseItem(ChatRole role, string text)
{
if (role == ChatRole.Assistant)
{
return ResponseItem.CreateAssistantMessageItem(text);
}
if (role == ChatRole.System)
{
return ResponseItem.CreateSystemMessageItem(text);
}
return ResponseItem.CreateUserMessageItem(text);
}
private static bool IsAllowedRole(ChatRole role) =>
role == ChatRole.User || role == ChatRole.Assistant || role == ChatRole.System;
private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : "<redacted>";
/// <summary>
/// Represents the state of a <see cref="FoundryMemoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
/// </summary>
public sealed class State
{
/// <summary>
/// Initializes a new instance of the <see cref="State"/> class with the specified scope.
/// </summary>
/// <param name="scope">The scope to use for memory storage and retrieval.</param>
[JsonConstructor]
public State(FoundryMemoryProviderScope scope)
{
this.Scope = Throw.IfNull(scope);
}
/// <summary>
/// Gets the scope used for memory storage and retrieval.
/// </summary>
public FoundryMemoryProviderScope Scope { get; }
}
}
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.FoundryMemory;
/// <summary>
/// Options for configuring the <see cref="FoundryMemoryProvider"/>.
/// </summary>
public sealed class FoundryMemoryProviderOptions
{
/// <summary>
/// When providing memories to the model, this string is prefixed to the retrieved memories to supply context.
/// </summary>
/// <value>Defaults to "## Memories\nConsider the following memories when answering user questions:".</value>
public string? ContextPrompt { get; set; }
/// <summary>
/// Gets or sets the maximum number of memories to retrieve during search.
/// </summary>
/// <value>Defaults to 5.</value>
public int MaxMemories { get; set; } = 5;
/// <summary>
/// Gets or sets the delay in seconds before memory updates are processed.
/// </summary>
/// <remarks>
/// Setting to 0 triggers updates immediately without waiting for inactivity.
/// Higher values allow the service to batch multiple updates together.
/// </remarks>
/// <value>Defaults to 0 (immediate).</value>
public int UpdateDelay { get; set; }
/// <summary>
/// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs.
/// </summary>
/// <value>Defaults to <see langword="false"/>.</value>
public bool EnableSensitiveTelemetryData { get; set; }
/// <summary>
/// Gets or sets the key used to store the provider state in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
/// <value>Defaults to the provider's type name.</value>
public string? StateKey { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to request messages when building the search text to use when
/// searching for relevant memories during <see cref="AIContextProvider.InvokingAsync"/>.
/// </summary>
/// <value>
/// When <see langword="null"/>, the provider defaults to including only
/// <see cref="AgentRequestMessageSourceType.External"/> messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? SearchInputMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to request messages when determining which messages to
/// extract memories from during <see cref="AIContextProvider.InvokedAsync"/>.
/// </summary>
/// <value>
/// When <see langword="null"/>, the provider defaults to including only
/// <see cref="AgentRequestMessageSourceType.External"/> messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputMessageFilter { get; set; }
}
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.FoundryMemory;
/// <summary>
/// Allows scoping of memories for the <see cref="FoundryMemoryProvider"/>.
/// </summary>
/// <remarks>
/// Azure AI Foundry memories are scoped by a single string identifier that you control.
/// Common patterns include using a user ID, team ID, or other unique identifier
/// to partition memories across different contexts.
/// </remarks>
public sealed class FoundryMemoryProviderScope
{
/// <summary>
/// Initializes a new instance of the <see cref="FoundryMemoryProviderScope"/> class with the specified scope identifier.
/// </summary>
/// <param name="scope">The scope identifier used to partition memories. Must not be null or whitespace.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="scope"/> is null or whitespace.</exception>
public FoundryMemoryProviderScope(string scope)
{
Throw.IfNullOrWhitespace(scope);
this.Scope = scope;
}
/// <summary>
/// Gets the scope identifier used to partition memories.
/// </summary>
/// <remarks>
/// This value controls how memory is partitioned in the memory store.
/// Each unique scope maintains its own isolated collection of memory items.
/// For example, use a user ID to ensure each user has their own individual memory.
/// </remarks>
public string Scope { get; }
}
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- Disable packing until we are ready to release this as a nuget -->
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="OpenAI" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework - Azure AI Foundry Memory integration</Title>
<Description>Provides Azure AI Foundry Memory integration for Microsoft Agent Framework.</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.FoundryMemory.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project>
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using A2A;
using A2A.AspNetCore;
using Microsoft.Agents.AI;
@@ -10,12 +11,14 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.AspNetCore.Builder;
/// <summary>
/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
{
/// <summary>
@@ -33,6 +36,20 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
=> endpoints.MapA2A(agentBuilder, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2A(agentBuilder.Name, path, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -43,6 +60,21 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path)
=> endpoints.MapA2A(agentName, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -109,6 +141,37 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard)
=> endpoints.MapA2A(agentName, path, agentCard, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2A(agentBuilder.Name, path, agentCard, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, agentCard, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -144,10 +207,28 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, agentCard, configureTaskManager);
return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, agentRunMode);
}
/// <summary>
@@ -160,6 +241,17 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
=> endpoints.MapA2A(agent, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode agentRunMode)
=> endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -169,13 +261,25 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore);
var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode);
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
configureTaskManager(taskManager);
@@ -198,6 +302,23 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard)
=> endpoints.MapA2A(agent, path, agentCard, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode agentRunMode)
=> endpoints.MapA2A(agent, path, agentCard, _ => { }, agentRunMode);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -213,13 +334,31 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory);
var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: agentRunMode);
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
configureTaskManager(taskManager);
@@ -8,6 +8,12 @@
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A.AspNetCore" />
</ItemGroup>
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Provides JSON serialization options for A2A Hosting APIs to support AOT and trimming.
/// </summary>
public static class A2AHostingJsonUtilities
{
/// <summary>
/// Gets the default <see cref="JsonSerializerOptions"/> instance used for A2A Hosting serialization.
/// </summary>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
private static JsonSerializerOptions CreateDefaultOptions()
{
JsonSerializerOptions options = new(global::A2A.A2AJsonUtilities.DefaultOptions);
// Chain in the resolvers from both AgentAbstractionsJsonUtilities and the A2A SDK context.
// AgentAbstractionsJsonUtilities is first to ensure M.E.AI types (e.g. ResponseContinuationToken)
// are handled via its resolver, followed by the A2A SDK resolver for protocol types.
options.TypeInfoResolverChain.Clear();
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.TypeInfoResolverChain.Add(global::A2A.A2AJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.MakeReadOnly();
return options;
}
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using A2A;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Provides context for a custom A2A run mode decision.
/// </summary>
public sealed class A2ARunDecisionContext
{
internal A2ARunDecisionContext(MessageSendParams messageSendParams)
{
this.MessageSendParams = messageSendParams;
}
/// <summary>
/// Gets the parameters of the incoming A2A message that triggered this run.
/// </summary>
public MessageSendParams MessageSendParams { get; }
}
@@ -1,19 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.Converters;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Provides extension methods for attaching A2A (Agent2Agent) messaging capabilities to an <see cref="AIAgent"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class AIAgentExtensions
{
// Metadata key used to store continuation tokens for long-running background operations
// in the AgentTask.Metadata dictionary, persisted by the task store.
private const string ContinuationTokenMetadataKey = "__a2a__continuationToken";
/// <summary>
/// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified <see cref="AIAgent"/>.
/// </summary>
@@ -21,49 +31,45 @@ public static class AIAgentExtensions
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
/// <param name="agentSessionStore">The store to store session contents and metadata.</param>
/// <param name="runMode">Controls the response behavior of the agent run.</param>
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
/// <returns>The configured <see cref="TaskManager"/>.</returns>
public static ITaskManager MapA2A(
this AIAgent agent,
ITaskManager? taskManager = null,
ILoggerFactory? loggerFactory = null,
AgentSessionStore? agentSessionStore = null)
AgentSessionStore? agentSessionStore = null,
AgentRunMode? runMode = null,
JsonSerializerOptions? jsonSerializerOptions = null)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(agent.Name);
runMode ??= AgentRunMode.DisallowBackground;
var hostAgent = new AIHostAgent(
innerAgent: agent,
sessionStore: agentSessionStore ?? new NoopAgentSessionStore());
taskManager ??= new TaskManager();
taskManager.OnMessageReceived += OnMessageReceivedAsync;
// Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent.
JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions;
// OnMessageReceived handles both message-only and task-based flows.
// The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set,
// so we consolidate all initial message handling here and return either
// an AgentMessage or AgentTask depending on the agent response.
// When the agent returns a ContinuationToken (long-running operation), a task is
// created for stateful tracking. Otherwise a lightweight AgentMessage is returned.
// See https://github.com/a2aproject/a2a-dotnet/issues/275
taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, runMode, taskManager, continuationTokenJsonOptions, ct);
// Task flow for subsequent updates and cancellations
taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct);
taskManager.OnTaskCancelled += OnTaskCancelledAsync;
return taskManager;
async Task<A2AResponse> OnMessageReceivedAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken)
{
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
var options = messageSendParams.Metadata is not { Count: > 0 }
? null
: new AgentRunOptions { AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
var response = await hostAgent.RunAsync(
messageSendParams.ToChatMessages(),
session: session,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
var parts = response.Messages.ToParts();
return new AgentMessage
{
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = MessageRole.Agent,
Parts = parts,
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
}
}
/// <summary>
@@ -74,15 +80,19 @@ public static class AIAgentExtensions
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</param>
/// <param name="agentSessionStore">The store to store session contents and metadata.</param>
/// <param name="runMode">Controls the response behavior of the agent run.</param>
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
/// <returns>The configured <see cref="TaskManager"/>.</returns>
public static ITaskManager MapA2A(
this AIAgent agent,
AgentCard agentCard,
ITaskManager? taskManager = null,
ILoggerFactory? loggerFactory = null,
AgentSessionStore? agentSessionStore = null)
AgentSessionStore? agentSessionStore = null,
AgentRunMode? runMode = null,
JsonSerializerOptions? jsonSerializerOptions = null)
{
taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore);
taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, runMode, jsonSerializerOptions);
taskManager.OnAgentCardQuery += (context, query) =>
{
@@ -97,4 +107,203 @@ public static class AIAgentExtensions
};
return taskManager;
}
private static async Task<A2AResponse> OnMessageReceivedAsync(
MessageSendParams messageSendParams,
AIHostAgent hostAgent,
AgentRunMode runMode,
ITaskManager taskManager,
JsonSerializerOptions continuationTokenJsonOptions,
CancellationToken cancellationToken)
{
// AIAgent does not support resuming from arbitrary prior tasks.
// Throw explicitly so the client gets a clear error rather than a response
// that silently ignores the referenced task context.
// Follow-ups on the *same* task are handled via OnTaskUpdated instead.
if (messageSendParams.Message.ReferenceTaskIds is { Count: > 0 })
{
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context. Use OnTaskUpdated for follow-ups on the same task.");
}
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
// Decide whether to run in background based on user preferences and agent capabilities
var decisionContext = new A2ARunDecisionContext(messageSendParams);
var allowBackgroundResponses = await runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
var options = messageSendParams.Metadata is not { Count: > 0 }
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
var response = await hostAgent.RunAsync(
messageSendParams.ToChatMessages(),
session: session,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
if (response.ContinuationToken is null)
{
return CreateMessageFromResponse(contextId, response);
}
var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false);
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
return agentTask;
}
private static async Task OnTaskUpdatedAsync(
AgentTask agentTask,
AIHostAgent hostAgent,
ITaskManager taskManager,
JsonSerializerOptions continuationTokenJsonOptions,
CancellationToken cancellationToken)
{
var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
try
{
// Discard any stale continuation token — the incoming user message supersedes
// any previous background operation. AF agents don't support updating existing
// background responses (long-running operations); we start a fresh run from the
// existing session using the full chat history (which includes the new message).
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false);
var response = await hostAgent.RunAsync(
ExtractChatMessagesFromTaskHistory(agentTask),
session: session,
options: new AgentRunOptions { AllowBackgroundResponses = true },
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
if (response.ContinuationToken is not null)
{
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
}
else
{
await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception)
{
await taskManager.UpdateStatusAsync(
agentTask.Id,
TaskState.Failed,
final: true,
cancellationToken: cancellationToken).ConfigureAwait(false);
throw;
}
}
private static Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken)
{
// Remove the continuation token from metadata if present.
// The task has already been marked as cancelled by the TaskManager.
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
return Task.CompletedTask;
}
private static AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) =>
new()
{
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = MessageRole.Agent,
Parts = response.Messages.ToParts(),
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
// Task outputs should be returned as artifacts rather than messages:
// https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts
private static Artifact CreateArtifactFromResponse(AgentResponse response) =>
new()
{
ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
Parts = response.Messages.ToParts(),
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
private static async Task<AgentTask> InitializeTaskAsync(
string contextId,
AgentMessage originalMessage,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false);
// Add the original user message to the task history.
// The A2A SDK does this internally when it creates tasks via OnTaskCreated.
agentTask.History ??= [];
agentTask.History.Add(originalMessage);
// Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false);
return agentTask;
}
private static void StoreContinuationToken(
AgentTask agentTask,
ResponseContinuationToken token,
JsonSerializerOptions continuationTokenJsonOptions)
{
// Serialize the continuation token into the task's metadata so it survives
// across requests and is cleaned up with the task itself.
agentTask.Metadata ??= [];
agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement(
token,
continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
}
private static async Task TransitionToWorkingAsync(
string taskId,
string contextId,
AgentResponse response,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
// Include any intermediate progress messages from the response as a status message.
AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null;
await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static async Task CompleteWithArtifactAsync(
string taskId,
AgentResponse response,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
var artifact = CreateArtifactFromResponse(response);
await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false);
await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask agentTask)
{
if (agentTask.History is not { Count: > 0 })
{
return [];
}
var chatMessages = new List<ChatMessage>(agentTask.History.Count);
foreach (var message in agentTask.History)
{
chatMessages.Add(message.ToChatMessage());
}
return chatMessages;
}
}
@@ -0,0 +1,105 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Specifies how the A2A hosting layer determines whether to run <see cref="AIAgent"/> in background or not.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public sealed class AgentRunMode : IEquatable<AgentRunMode>
{
private const string MessageValue = "message";
private const string TaskValue = "task";
private const string DynamicValue = "dynamic";
private readonly string _value;
private readonly Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? _runInBackground;
private AgentRunMode(string value, Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? runInBackground = null)
{
this._value = value;
this._runInBackground = runInBackground;
}
/// <summary>
/// Dissallows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>false</c>.
/// In the A2A protocol terminology will make responses be returned as <c>AgentMessage</c>.
/// </summary>
public static AgentRunMode DisallowBackground => new(MessageValue);
/// <summary>
/// Allows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>true</c>.
/// In the A2A protocol terminology will make responses be returned as <c>AgentTask</c> if the agent supports background responses, and as <c>AgentMessage</c> otherwise.
/// </summary>
public static AgentRunMode AllowBackgroundIfSupported => new(TaskValue);
/// <summary>
/// The agent run mode is decided by the supplied <paramref name="runInBackground"/> delegate.
/// The delegate receives an <see cref="A2ARunDecisionContext"/> with the incoming
/// message and returns a boolean specifying whether to run the agent in background mode.
/// <see langword="true"/> indicates that the agent should run in background mode and return an
/// <c>AgentTask</c> if the agent supports background mode; otherwise, it returns an <c>AgentMessage</c>
/// if the mode is not supported. <see langword="false"/> indicates that the agent should run in
/// non-background mode and return an <c>AgentMessage</c>.
/// </summary>
/// <param name="runInBackground">
/// An async delegate that decides whether the response should be wrapped in an <c>AgentTask</c>.
/// </param>
public static AgentRunMode AllowBackgroundWhen(Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>> runInBackground)
{
ArgumentNullException.ThrowIfNull(runInBackground);
return new(DynamicValue, runInBackground);
}
/// <summary>
/// Determines whether the agent response should be returned as an <c>AgentTask</c>.
/// </summary>
internal ValueTask<bool> ShouldRunInBackgroundAsync(A2ARunDecisionContext context, CancellationToken cancellationToken)
{
if (string.Equals(this._value, MessageValue, StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult(false);
}
if (string.Equals(this._value, TaskValue, StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult(true);
}
// Dynamic: delegate to custom callback.
if (this._runInBackground is not null)
{
return this._runInBackground(context, cancellationToken);
}
// No delegate provided — fall back to "message" behavior.
return ValueTask.FromResult(true);
}
/// <inheritdoc/>
public bool Equals(AgentRunMode? other) =>
other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode);
/// <inheritdoc/>
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this._value);
/// <inheritdoc/>
public override string ToString() => this._value;
/// <summary>Determines whether two <see cref="AgentRunMode"/> instances are equal.</summary>
public static bool operator ==(AgentRunMode? left, AgentRunMode? right) =>
left?.Equals(right) ?? right is null;
/// <summary>Determines whether two <see cref="AgentRunMode"/> instances are not equal.</summary>
public static bool operator !=(AgentRunMode? left, AgentRunMode? right) =>
!(left == right);
}
@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Text.Json;
using A2A;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
@@ -37,7 +36,7 @@ internal static class AdditionalPropertiesDictionaryExtensions
continue;
}
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
}
return metadata;
@@ -10,6 +10,8 @@
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -92,4 +92,23 @@ public static class OpenAIResponseClientExtensions
return new ChatClientAgent(chatClient, options, loggerFactory, services);
}
/// <summary>
/// Gets an <see cref="IChatClient"/> for use with this <see cref="ResponsesClient"/> that does not store responses for later retrieval.
/// </summary>
/// <remarks>
/// This corresponds to setting the "store" property in the JSON representation to false.
/// </remarks>
/// <param name="responseClient">The client.</param>
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="ResponsesClient"/> that does not store responses for later retrieval.</returns>
/// <exception cref="ArgumentNullException"><paramref name="responseClient"/> is <see langword="null"/>.</exception>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static IChatClient AsIChatClientWithStoredOutputDisabled(this ResponsesClient responseClient)
{
return Throw.IfNull(responseClient)
.AsIChatClient()
.AsBuilder()
.ConfigureOptions(x => x.RawRepresentationFactory = _ => new CreateResponseOptions() { StoredOutputEnabled = false })
.Build();
}
}
@@ -98,7 +98,7 @@ internal sealed class PurviewWrapper : IDisposable
try
{
(bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, options?.ConversationId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
(bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, options?.ConversationId, Activity.DownloadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
if (shouldBlockResponse)
{
if (this._logger.IsEnabled(LogLevel.Information))
@@ -186,7 +186,7 @@ internal sealed class PurviewWrapper : IDisposable
sessionIdResponse = sessionId;
}
}
(bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, sessionIdResponse, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
(bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, sessionIdResponse, Activity.DownloadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
if (shouldBlockResponse)
{
@@ -0,0 +1,252 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
/// <summary>
/// Default implementation of <see cref="IMcpToolHandler"/> using the MCP C# SDK.
/// </summary>
/// <remarks>
/// This provider supports per-server authentication via the <c>httpClientProvider</c> callback.
/// The callback allows different MCP servers to use different authentication configurations by returning
/// a pre-configured <see cref="HttpClient"/> for each server.
/// </remarks>
public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
{
private readonly Func<string, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
private readonly Dictionary<string, McpClient> _clients = [];
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
private readonly SemaphoreSlim _clientLock = new(1, 1);
/// <summary>
/// Initializes a new instance of the <see cref="DefaultMcpToolHandler"/> class.
/// </summary>
/// <param name="httpClientProvider">
/// An optional callback that provides an <see cref="HttpClient"/> for each MCP server.
/// The callback receives (serverUrl, cancellationToken) and should return an HttpClient
/// configured with any required authentication. Return <see langword="null"/> to use a default HttpClient with no auth.
/// </param>
public DefaultMcpToolHandler(Func<string, CancellationToken, Task<HttpClient?>>? httpClientProvider = null)
{
this._httpClientProvider = httpClientProvider;
}
/// <inheritdoc/>
public async Task<McpServerToolResultContent> InvokeToolAsync(
string serverUrl,
string? serverLabel,
string toolName,
IDictionary<string, object?>? arguments,
IDictionary<string, string>? headers,
string? connectionName,
CancellationToken cancellationToken = default)
{
// TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
// Convert IDictionary to IReadOnlyDictionary for CallToolAsync
IReadOnlyDictionary<string, object?>? readOnlyArguments = arguments is null
? null
: arguments as IReadOnlyDictionary<string, object?> ?? new Dictionary<string, object?>(arguments);
CallToolResult result = await client.CallToolAsync(
toolName,
readOnlyArguments,
cancellationToken: cancellationToken).ConfigureAwait(false);
// Map MCP content blocks to MEAI AIContent types
PopulateResultContent(resultContent, result);
return resultContent;
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
await this._clientLock.WaitAsync().ConfigureAwait(false);
try
{
foreach (McpClient client in this._clients.Values)
{
await client.DisposeAsync().ConfigureAwait(false);
}
this._clients.Clear();
// Dispose only HttpClients that the handler created (not user-provided ones)
foreach (HttpClient httpClient in this._ownedHttpClients.Values)
{
httpClient.Dispose();
}
this._ownedHttpClients.Clear();
}
finally
{
this._clientLock.Release();
}
this._clientLock.Dispose();
}
private async Task<McpClient> GetOrCreateClientAsync(
string serverUrl,
string? serverLabel,
IDictionary<string, string>? headers,
CancellationToken cancellationToken)
{
string normalizedUrl = serverUrl.Trim().ToUpperInvariant();
string clientCacheKey = $"{normalizedUrl}|{ComputeHeadersHash(headers)}";
await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (this._clients.TryGetValue(clientCacheKey, out McpClient? existingClient))
{
return existingClient;
}
McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, normalizedUrl, cancellationToken).ConfigureAwait(false);
this._clients[clientCacheKey] = newClient;
return newClient;
}
finally
{
this._clientLock.Release();
}
}
private async Task<McpClient> CreateClientAsync(
string serverUrl,
string? serverLabel,
IDictionary<string, string>? headers,
string httpClientCacheKey,
CancellationToken cancellationToken)
{
// Get or create HttpClient (Can be shared across McpClients for the same server)
HttpClient? httpClient = null;
if (this._httpClientProvider is not null)
{
httpClient = await this._httpClientProvider(serverUrl, cancellationToken).ConfigureAwait(false);
}
if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient))
{
httpClient = new HttpClient();
this._ownedHttpClients[httpClientCacheKey] = httpClient;
}
HttpClientTransportOptions transportOptions = new()
{
Endpoint = new Uri(serverUrl),
Name = serverLabel ?? "McpClient",
AdditionalHeaders = headers,
TransportMode = HttpTransportMode.AutoDetect
};
HttpClientTransport transport = new(transportOptions, httpClient);
return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static string ComputeHeadersHash(IDictionary<string, string>? headers)
{
if (headers is null || headers.Count == 0)
{
return string.Empty;
}
// Build a deterministic, sorted representation of the headers
// Within a single process lifetime, the hashcodes are consistent.
// This will ensure that the same set of headers always produces the same hash, regardless of order.
SortedDictionary<string, string> sorted = new(headers.ToDictionary(h => h.Key.ToUpperInvariant(), h => h.Value.ToUpperInvariant()));
int hashCode = 17;
foreach (KeyValuePair<string, string> kvp in sorted)
{
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key);
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value);
}
return hashCode.ToString(CultureInfo.InvariantCulture);
}
private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result)
{
// Ensure Output list is initialized
resultContent.Output ??= [];
if (result.IsError == true)
{
// Collect error text from content blocks
string? errorText = null;
if (result.Content is not null)
{
foreach (ContentBlock block in result.Content)
{
if (block is TextContentBlock textBlock)
{
errorText = errorText is null ? textBlock.Text : $"{errorText}\n{textBlock.Text}";
}
}
}
resultContent.Output.Add(new TextContent($"Error: {errorText ?? "Unknown error from MCP Server call"}"));
return;
}
if (result.Content is null || result.Content.Count == 0)
{
return;
}
// Map each MCP content block to an MEAI AIContent type
foreach (ContentBlock block in result.Content)
{
AIContent content = ConvertContentBlock(block);
if (content is not null)
{
resultContent.Output.Add(content);
}
}
}
private static AIContent ConvertContentBlock(ContentBlock block)
{
return block switch
{
TextContentBlock text => new TextContent(text.Text),
ImageContentBlock image => CreateDataContentFromBase64(image.Data, image.MimeType ?? "image/*"),
AudioContentBlock audio => CreateDataContentFromBase64(audio.Data, audio.MimeType ?? "audio/*"),
_ => new TextContent(block.ToString() ?? string.Empty),
};
}
private static DataContent CreateDataContentFromBase64(string? base64Data, string mediaType)
{
if (string.IsNullOrEmpty(base64Data))
{
return new DataContent($"data:{mediaType};base64,", mediaType);
}
// If it's already a data URI, use it directly
if (base64Data.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
return new DataContent(base64Data, mediaType);
}
// Otherwise, construct a data URI from the base64 data
return new DataContent($"data:{mediaType};base64,{base64Data}", mediaType);
}
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsReleaseCandidate>true</IsReleaseCandidate>
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Declarative Workflows MCP</Title>
<Description>Provides Microsoft Agent Framework support for MCP (Model Context Protocol) server integration in declarative workflows.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests" />
</ItemGroup>
</Project>
@@ -20,6 +20,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid
/// </summary>
public ResponseAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider);
/// <summary>
/// Gets or sets the MCP tool handler for invoking MCP tools within workflows.
/// If not set, MCP tool invocations will fail with an appropriate error message.
/// </summary>
public IMcpToolHandler? McpToolHandler { get; init; }
/// <summary>
/// Defines the configuration settings for the workflow.
/// </summary>
@@ -42,6 +42,40 @@ internal static class JsonDocumentExtensions
};
}
/// <summary>
/// Creates a VariableType.List with schema inferred from the first object element in the array.
/// </summary>
public static VariableType GetListTypeFromJson(this JsonElement arrayElement)
{
// Find the first object element to infer schema
foreach (JsonElement element in arrayElement.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.Object)
{
// Build schema from the object's properties
List<(string Key, VariableType Type)> fields = [];
foreach (JsonProperty property in element.EnumerateObject())
{
VariableType fieldType = property.Value.ValueKind switch
{
JsonValueKind.String => typeof(string),
JsonValueKind.Number => typeof(decimal),
JsonValueKind.True or JsonValueKind.False => typeof(bool),
JsonValueKind.Object => VariableType.RecordType,
JsonValueKind.Array => VariableType.ListType,
_ => typeof(string),
};
fields.Add((property.Name, fieldType));
}
return VariableType.List(fields);
}
}
// Fallback for arrays of primitives or empty arrays
return VariableType.ListType;
}
private static Dictionary<string, object?> ParseRecord(this JsonElement currentElement, VariableType targetType)
{
IEnumerable<KeyValuePair<string, object?>> keyValuePairs =
@@ -111,11 +145,14 @@ internal static class JsonDocumentExtensions
VariableType? currentType =
element.ValueKind switch
{
JsonValueKind.Object => VariableType.Record(targetType.Schema?.Select(kvp => (kvp.Key, kvp.Value)) ?? []),
JsonValueKind.Object => targetType.HasSchema
? VariableType.Record(targetType.Schema!.Select(kvp => (kvp.Key, kvp.Value)))
: VariableType.RecordType,
JsonValueKind.String => typeof(string),
JsonValueKind.True => typeof(bool),
JsonValueKind.False => typeof(bool),
JsonValueKind.Number => typeof(decimal),
JsonValueKind.Array => (VariableType)VariableType.ListType, // Add support for nested arrays
_ => null,
};
@@ -283,9 +320,16 @@ internal static class JsonDocumentExtensions
private static bool TryParseList(JsonElement propertyElement, VariableType? targetType, out object? value)
{
// Handle empty arrays without needing to determine element type
if (propertyElement.GetArrayLength() == 0)
{
value = new List<object?>();
return true;
}
try
{
value = ParseTable(propertyElement, targetType ?? VariableType.ListType);
value = ParseTable(propertyElement, targetType ?? GetListTypeFromJson(propertyElement));
return true;
}
catch
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative;
/// <summary>
/// Defines the contract for invoking MCP tools within declarative workflows.
/// </summary>
/// <remarks>
/// This interface allows the MCP tool invocation to be abstracted, enabling
/// different implementations for local development, hosted workflows, and testing scenarios.
/// </remarks>
public interface IMcpToolHandler
{
/// <summary>
/// Invokes an MCP tool on the specified server.
/// </summary>
/// <param name="serverUrl">The URL of the MCP server.</param>
/// <param name="serverLabel">An optional label identifying the server connection.</param>
/// <param name="toolName">The name of the tool to invoke.</param>
/// <param name="arguments">Optional arguments to pass to the tool.</param>
/// <param name="headers">Optional headers to include in the request.</param>
/// <param name="connectionName">An optional connection name for managed connections.</param>
/// <param name="cancellationToken">A token to observe cancellation.</param>
/// <returns>
/// A task representing the asynchronous operation. The result contains a <see cref="McpServerToolResultContent"/>
/// with the tool invocation output.
/// </returns>
Task<McpServerToolResultContent> InvokeToolAsync(
string serverUrl,
string? serverLabel,
string toolName,
IDictionary<string, object?>? arguments,
IDictionary<string, string>? headers,
string? connectionName,
CancellationToken cancellationToken = default);
}
@@ -493,6 +493,42 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
this.ContinueWith(new SendActivityExecutor(item, this._workflowState));
}
protected override void Visit(InvokeMcpTool item)
{
this.Trace(item);
// Verify MCP handler is configured
if (this._workflowOptions.McpToolHandler is null)
{
throw new DeclarativeModelException("MCP tool handler not configured. Set McpToolHandler in DeclarativeWorkflowOptions to use InvokeMcpTool actions.");
}
// Entry point to invoke MCP tool - may yield for approval
InvokeMcpToolExecutor action = new(item, this._workflowOptions.McpToolHandler, this._workflowOptions.AgentProvider, this._workflowState);
this.ContinueWith(action);
// Transition to post action if no external input is required (no approval needed)
string postId = Steps.Post(action.Id);
this._workflowModel.AddLink(action.Id, postId, InvokeMcpToolExecutor.RequiresNothing);
// If approval is required, define request-port for approval flow
string externalInputPortId = InvokeMcpToolExecutor.Steps.ExternalInput(action.Id);
RequestPortAction externalInputPort = new(RequestPort.Create<ExternalInputRequest, ExternalInputResponse>(externalInputPortId));
this._workflowModel.AddNode(externalInputPort, action.ParentId);
this._workflowModel.AddLink(action.Id, externalInputPortId, InvokeMcpToolExecutor.RequiresInput);
// Capture response when external input is received
string resumeId = InvokeMcpToolExecutor.Steps.Resume(action.Id);
this._workflowModel.AddNode(new DelegateActionExecutor<ExternalInputResponse>(resumeId, this._workflowState, action.CaptureResponseAsync), action.ParentId);
this._workflowModel.AddLink(externalInputPortId, resumeId);
// After resume, transition to post action
this._workflowModel.AddLink(resumeId, postId);
// Define post action (completion)
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);
}
#region Not supported
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
@@ -365,6 +365,8 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor
#region Not supported
protected override void Visit(InvokeMcpTool item) => this.NotSupported(item);
protected override void Visit(InvokeFunctionTool item) => this.NotSupported(item);
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
@@ -204,7 +204,7 @@ internal sealed class InvokeFunctionToolExecutor(
object? parsedValue = jsonDocument.RootElement.ValueKind switch
{
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
JsonValueKind.Array => jsonDocument.ParseList(CreateListTypeFromJson(jsonDocument.RootElement)),
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
JsonValueKind.String => jsonDocument.RootElement.GetString(),
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(),
JsonValueKind.True => true,
@@ -224,40 +224,6 @@ internal sealed class InvokeFunctionToolExecutor(
await this.AssignAsync(this.Model.Output.Result?.Path, resultValue.ToFormula(), context).ConfigureAwait(false);
}
/// <summary>
/// Creates a VariableType.List with schema inferred from the first object element in the array.
/// </summary>
private static VariableType CreateListTypeFromJson(JsonElement arrayElement)
{
// Find the first object element to infer schema
foreach (JsonElement element in arrayElement.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.Object)
{
// Build schema from the object's properties
List<(string Key, VariableType Type)> fields = [];
foreach (JsonProperty property in element.EnumerateObject())
{
VariableType fieldType = property.Value.ValueKind switch
{
JsonValueKind.String => typeof(string),
JsonValueKind.Number => typeof(decimal),
JsonValueKind.True or JsonValueKind.False => typeof(bool),
JsonValueKind.Object => VariableType.RecordType,
JsonValueKind.Array => VariableType.ListType,
_ => typeof(string),
};
fields.Add((property.Name, fieldType));
}
return VariableType.List(fields);
}
}
// Fallback for arrays of primitives or empty arrays
return VariableType.ListType;
}
private string GetFunctionName() =>
this.Evaluator.GetValue(
Throw.IfNull(
@@ -0,0 +1,367 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
/// <summary>
/// Executor for the <see cref="InvokeMcpTool"/> action.
/// This executor invokes MCP tools on remote servers and handles approval flows.
/// </summary>
internal sealed class InvokeMcpToolExecutor(
InvokeMcpTool model,
IMcpToolHandler mcpToolHandler,
ResponseAgentProvider agentProvider,
WorkflowFormulaState state) :
DeclarativeActionExecutor<InvokeMcpTool>(model, state)
{
/// <summary>
/// Step identifiers for the MCP tool invocation workflow.
/// </summary>
public static class Steps
{
/// <summary>
/// Step for waiting for external input (approval or direct response).
/// </summary>
public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}";
/// <summary>
/// Step for resuming after receiving external input.
/// </summary>
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
}
/// <summary>
/// Determines if the message indicates external input is required.
/// </summary>
public static bool RequiresInput(object? message) => message is ExternalInputRequest;
/// <summary>
/// Determines if the message indicates no external input is required.
/// </summary>
public static bool RequiresNothing(object? message) => message is ActionExecutorResult;
/// <inheritdoc/>
protected override bool EmitResultEvent => false;
/// <inheritdoc/>
protected override bool IsDiscreteAction => false;
/// <inheritdoc/>
[SendsMessage(typeof(ExternalInputRequest))]
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
string serverUrl = this.GetServerUrl();
string? serverLabel = this.GetServerLabel();
string toolName = this.GetToolName();
bool requireApproval = this.GetRequireApproval();
Dictionary<string, object?>? arguments = this.GetArguments();
Dictionary<string, string>? headers = this.GetHeaders();
string? connectionName = this.GetConnectionName();
if (requireApproval)
{
// Create tool call content for approval request
McpServerToolCallContent toolCall = new(this.Id, toolName, serverLabel ?? serverUrl)
{
Arguments = arguments
};
if (headers != null)
{
toolCall.AdditionalProperties ??= [];
toolCall.AdditionalProperties.Add(headers);
}
McpServerToolApprovalRequestContent approvalRequest = new(this.Id, toolCall);
ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]);
AgentResponse agentResponse = new([requestMessage]);
// Yield to the caller for approval
ExternalInputRequest inputRequest = new(agentResponse);
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
return default;
}
// No approval required - invoke the tool directly
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
serverUrl,
serverLabel,
toolName,
arguments,
headers,
connectionName,
cancellationToken).ConfigureAwait(false);
await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false);
// Signal completion so the workflow routes via RequiresNothing
await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false);
return default;
}
/// <summary>
/// Captures the external input response and processes the MCP tool result.
/// </summary>
/// <param name="context">The workflow context.</param>
/// <param name="response">The external input response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask CaptureResponseAsync(
IWorkflowContext context,
ExternalInputResponse response,
CancellationToken cancellationToken)
{
// Check for approval response
McpServerToolApprovalResponseContent? approvalResponse = response.Messages
.SelectMany(m => m.Contents)
.OfType<McpServerToolApprovalResponseContent>()
.FirstOrDefault(r => r.Id == this.Id);
if (approvalResponse?.Approved != true)
{
// Tool call was rejected
await this.AssignErrorAsync(context, "MCP tool invocation was not approved by user.").ConfigureAwait(false);
return;
}
// Approved - now invoke the tool
string serverUrl = this.GetServerUrl();
string? serverLabel = this.GetServerLabel();
string toolName = this.GetToolName();
Dictionary<string, object?>? arguments = this.GetArguments();
Dictionary<string, string>? headers = this.GetHeaders();
string? connectionName = this.GetConnectionName();
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
serverUrl,
serverLabel,
toolName,
arguments,
headers,
connectionName,
cancellationToken).ConfigureAwait(false);
await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Completes the MCP tool invocation by raising the completion event.
/// </summary>
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
}
private async ValueTask ProcessResultAsync(IWorkflowContext context, McpServerToolResultContent resultContent, CancellationToken cancellationToken)
{
bool autoSend = this.GetAutoSendValue();
string? conversationId = this.GetConversationId();
await this.AssignResultAsync(context, resultContent).ConfigureAwait(false);
ChatMessage resultMessage = new(ChatRole.Tool, resultContent.Output);
// Store messages if output path is configured
if (this.Model.Output?.Messages is not null)
{
await this.AssignAsync(this.Model.Output.Messages?.Path, resultMessage.ToFormula(), context).ConfigureAwait(false);
}
// Auto-send the result if configured
if (autoSend)
{
AgentResponse resultResponse = new([resultMessage]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false);
}
// Add messages to conversation if conversationId is provided
if (conversationId is not null)
{
ChatMessage assistantMessage = new(ChatRole.Assistant, resultContent.Output);
await agentProvider.CreateMessageAsync(conversationId, assistantMessage, cancellationToken).ConfigureAwait(false);
}
}
private async ValueTask AssignResultAsync(IWorkflowContext context, McpServerToolResultContent toolResult)
{
if (this.Model.Output?.Result is null || toolResult.Output is null || toolResult.Output.Count == 0)
{
return;
}
List<object?> parsedResults = [];
foreach (AIContent resultContent in toolResult.Output)
{
object? resultValue = resultContent switch
{
TextContent text => text.Text,
DataContent data => data.Uri,
_ => resultContent.ToString(),
};
// Convert JsonElement to its raw JSON string for processing
if (resultValue is JsonElement jsonElement)
{
resultValue = jsonElement.GetRawText();
}
// Attempt to parse as JSON if it's a string (or was converted from JsonElement)
if (resultValue is string jsonString)
{
try
{
using JsonDocument jsonDocument = JsonDocument.Parse(jsonString);
// Handle different JSON value kinds
object? parsedValue = jsonDocument.RootElement.ValueKind switch
{
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
JsonValueKind.String => jsonDocument.RootElement.GetString(),
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => jsonString,
};
parsedResults.Add(parsedValue);
continue;
}
catch (JsonException)
{
// Not a valid JSON
}
}
parsedResults.Add(resultValue);
}
await this.AssignAsync(this.Model.Output.Result?.Path, parsedResults.ToFormula(), context).ConfigureAwait(false);
}
private async ValueTask AssignErrorAsync(IWorkflowContext context, string errorMessage)
{
// Store error in result if configured (as a simple string)
if (this.Model.Output?.Result is not null)
{
await this.AssignAsync(this.Model.Output.Result?.Path, $"Error: {errorMessage}".ToFormula(), context).ConfigureAwait(false);
}
}
private string GetServerUrl() =>
this.Evaluator.GetValue(
Throw.IfNull(
this.Model.ServerUrl,
$"{nameof(this.Model)}.{nameof(this.Model.ServerUrl)}")).Value;
private string? GetServerLabel()
{
if (this.Model.ServerLabel is null)
{
return null;
}
string value = this.Evaluator.GetValue(this.Model.ServerLabel).Value;
return value.Length == 0 ? null : value;
}
private string GetToolName() =>
this.Evaluator.GetValue(
Throw.IfNull(
this.Model.ToolName,
$"{nameof(this.Model)}.{nameof(this.Model.ToolName)}")).Value;
private string? GetConversationId()
{
if (this.Model.ConversationId is null)
{
return null;
}
string value = this.Evaluator.GetValue(this.Model.ConversationId).Value;
return value.Length == 0 ? null : value;
}
private bool GetRequireApproval()
{
if (this.Model.RequireApproval is null)
{
return false;
}
return this.Evaluator.GetValue(this.Model.RequireApproval).Value;
}
private bool GetAutoSendValue()
{
if (this.Model.Output?.AutoSend is null)
{
return true;
}
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
}
private string? GetConnectionName()
{
if (this.Model.Connection?.Name is null)
{
return null;
}
string value = this.Evaluator.GetValue(this.Model.Connection.Name).Value;
return value.Length == 0 ? null : value;
}
private Dictionary<string, object?>? GetArguments()
{
if (this.Model.Arguments is null)
{
return null;
}
Dictionary<string, object?> result = [];
foreach (KeyValuePair<string, ValueExpression> argument in this.Model.Arguments)
{
result[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject();
}
return result;
}
private Dictionary<string, string>? GetHeaders()
{
if (this.Model.Headers is null)
{
return null;
}
Dictionary<string, string> result = [];
foreach (KeyValuePair<string, StringExpression> header in this.Model.Headers)
{
string value = this.Evaluator.GetValue(header.Value).Value;
if (!string.IsNullOrEmpty(value))
{
result[header.Key] = value;
}
}
return result;
}
}
@@ -18,6 +18,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream
private int _isDisposed;
private readonly ISuperStepRunner _stepRunner;
private Activity? _sessionActivity;
public ValueTask<RunStatus> GetStatusAsync(CancellationToken cancellationToken = default) => new(this.RunStatus);
@@ -30,7 +31,16 @@ internal sealed class LockstepRunEventStream : IRunEventStream
public void Start()
{
// No-op for lockstep execution
// Save and restore Activity.Current so the long-lived session activity
// doesn't leak into caller code via AsyncLocal.
Activity? previousActivity = Activity.Current;
this._sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity();
this._sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
this._sessionActivity?.AddEvent(new ActivityEvent(EventNames.SessionStarted));
Activity.Current = previousActivity;
}
public async IAsyncEnumerable<WorkflowEvent> TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default)
@@ -44,19 +54,23 @@ internal sealed class LockstepRunEventStream : IRunEventStream
}
#endif
CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken);
using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken);
ConcurrentQueue<WorkflowEvent> eventSink = [];
this._stepRunner.OutgoingEvents.EventRaised += OnWorkflowEventAsync;
using Activity? activity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId);
// Re-establish session as parent so the run activity nests correctly.
Activity.Current = this._sessionActivity;
// Not 'using' — must dispose explicitly in finally for deterministic export.
Activity? runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId);
try
{
this.RunStatus = RunStatus.Running;
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
do
{
@@ -65,7 +79,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream
{
// Because we may be yielding out of this function, we need to ensure that the Activity.Current
// is set to our activity for the duration of this loop iteration.
Activity.Current = activity;
Activity.Current = runActivity;
// Drain SuperSteps while there are steps to run
try
@@ -75,13 +89,13 @@ internal sealed class LockstepRunEventStream : IRunEventStream
catch (OperationCanceledException)
{
}
catch (Exception ex) when (activity is not null)
catch (Exception ex) when (runActivity is not null)
{
activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
{ Tags.ErrorType, ex.GetType().FullName },
{ Tags.BuildErrorMessage, ex.Message },
{ Tags.ErrorMessage, ex.Message },
}));
activity.CaptureException(ex);
runActivity.CaptureException(ex);
throw;
}
@@ -129,12 +143,16 @@ internal sealed class LockstepRunEventStream : IRunEventStream
}
} while (!ShouldBreak());
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
}
finally
{
this.RunStatus = this._stepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle;
this._stepRunner.OutgoingEvents.EventRaised -= OnWorkflowEventAsync;
// Explicitly dispose the Activity so Activity.Stop fires deterministically,
// regardless of how the async iterator enumerator is disposed.
runActivity?.Dispose();
}
ValueTask OnWorkflowEventAsync(object? sender, WorkflowEvent e)
@@ -172,6 +190,14 @@ internal sealed class LockstepRunEventStream : IRunEventStream
{
this._stopCancellation.Cancel();
// Stop the session activity
if (this._sessionActivity is not null)
{
this._sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionCompleted));
this._sessionActivity.Dispose();
this._sessionActivity = null;
}
this._stopCancellation.Dispose();
this._inputWaiter.Dispose();
}
@@ -55,13 +55,20 @@ internal sealed class StreamingRunEventStream : IRunEventStream
private async Task RunLoopAsync(CancellationToken cancellationToken)
{
using CancellationTokenSource errorSource = new();
CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(errorSource.Token, cancellationToken);
using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(errorSource.Token, cancellationToken);
// Subscribe to events - they will flow directly to the channel as they're raised
this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync;
using Activity? activity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId);
// Start the session-level activity that spans the entire run loop lifetime.
// Individual run-stage activities are nested within this session activity.
Activity? sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity();
sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
Activity? runActivity = null;
sessionActivity?.AddEvent(new ActivityEvent(EventNames.SessionStarted));
try
{
@@ -70,10 +77,15 @@ internal sealed class StreamingRunEventStream : IRunEventStream
await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false);
this._runStatus = RunStatus.Running;
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
while (!linkedSource.Token.IsCancellationRequested)
{
// Start a new run-stage activity for this input→processing→halt cycle
runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
// Run all available supersteps continuously
// Events are streamed out in real-time as they happen via the event handler
while (this._stepRunner.HasUnprocessedMessages && !linkedSource.Token.IsCancellationRequested)
@@ -93,6 +105,15 @@ internal sealed class StreamingRunEventStream : IRunEventStream
RunStatus capturedStatus = this._runStatus;
await this._eventChannel.Writer.WriteAsync(new InternalHaltSignal(currentEpoch, capturedStatus), linkedSource.Token).ConfigureAwait(false);
// Close the run-stage activity when processing halts.
// A new run activity will be created when the next input arrives.
if (runActivity is not null)
{
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
runActivity.Dispose();
runActivity = null;
}
// Wait for next input from the consumer
// Works for both Idle (no work) and PendingRequests (waiting for responses)
await this._inputWaiter.WaitForInputAsync(TimeSpan.FromSeconds(1), linkedSource.Token).ConfigureAwait(false);
@@ -107,14 +128,26 @@ internal sealed class StreamingRunEventStream : IRunEventStream
}
catch (Exception ex)
{
if (activity != null)
// Record error on the run-stage activity if one is active
if (runActivity is not null)
{
activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
{ Tags.ErrorType, ex.GetType().FullName },
{ Tags.BuildErrorMessage, ex.Message },
{ Tags.ErrorMessage, ex.Message },
}));
activity.CaptureException(ex);
runActivity.CaptureException(ex);
}
// Record error on the session activity
if (sessionActivity is not null)
{
sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionError, tags: new() {
{ Tags.ErrorType, ex.GetType().FullName },
{ Tags.ErrorMessage, ex.Message },
}));
sessionActivity.CaptureException(ex);
}
await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(ex), linkedSource.Token).ConfigureAwait(false);
}
finally
@@ -124,7 +157,20 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Mark as ended when run loop exits
this._runStatus = RunStatus.Ended;
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
// Stop the run-stage activity if not already stopped (e.g. on cancellation or error)
if (runActivity is not null)
{
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
runActivity.Dispose();
}
// Stop the session activity — the session always ends when the run loop exits
if (sessionActivity is not null)
{
sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionCompleted));
sessionActivity.Dispose();
}
}
async ValueTask OnEventRaisedAsync(object? sender, WorkflowEvent e)
@@ -5,7 +5,8 @@ namespace Microsoft.Agents.AI.Workflows.Observability;
internal static class ActivityNames
{
public const string WorkflowBuild = "workflow.build";
public const string WorkflowRun = "workflow_invoke";
public const string WorkflowSession = "workflow.session";
public const string WorkflowInvoke = "workflow_invoke";
public const string MessageSend = "message.send";
public const string ExecutorProcess = "executor.process";
public const string EdgeGroupProcess = "edge_group.process";
@@ -8,6 +8,9 @@ internal static class EventNames
public const string BuildValidationCompleted = "build.validation_completed";
public const string BuildCompleted = "build.completed";
public const string BuildError = "build.error";
public const string SessionStarted = "session.started";
public const string SessionCompleted = "session.completed";
public const string SessionError = "session.error";
public const string WorkflowStarted = "workflow.started";
public const string WorkflowCompleted = "workflow.completed";
public const string WorkflowError = "workflow.error";
@@ -11,6 +11,7 @@ internal static class Tags
public const string BuildErrorMessage = "build.error.message";
public const string BuildErrorType = "build.error.type";
public const string ErrorType = "error.type";
public const string ErrorMessage = "error.message";
public const string SessionId = "session.id";
public const string ExecutorId = "executor.id";
public const string ExecutorType = "executor.type";
@@ -88,7 +88,25 @@ internal sealed class WorkflowTelemetryContext
}
/// <summary>
/// Starts a workflow run activity if enabled.
/// Starts a workflow session activity if enabled. This is the outer/parent span
/// that represents the entire lifetime of a workflow execution (from start
/// until stop, cancellation, or error) within the current trace.
/// Individual run stages are typically nested within it.
/// </summary>
/// <returns>An activity if workflow run telemetry is enabled, otherwise null.</returns>
public Activity? StartWorkflowSessionActivity()
{
if (!this.IsEnabled || this.Options.DisableWorkflowRun)
{
return null;
}
return this.ActivitySource.StartActivity(ActivityNames.WorkflowSession);
}
/// <summary>
/// Starts a workflow run activity if enabled. This represents a single
/// input-to-halt cycle within a workflow session.
/// </summary>
/// <returns>An activity if workflow run telemetry is enabled, otherwise null.</returns>
public Activity? StartWorkflowRunActivity()
@@ -98,7 +116,7 @@ internal sealed class WorkflowTelemetryContext
return null;
}
return this.ActivitySource.StartActivity(ActivityNames.WorkflowRun);
return this.ActivitySource.StartActivity(ActivityNames.WorkflowInvoke);
}
/// <summary>
@@ -172,7 +172,7 @@ public sealed class AIAgentBuilder
/// context enrichment, not just agents that natively support <see cref="AIContextProvider"/> instances.
/// </para>
/// </remarks>
public AIAgentBuilder Use(MessageAIContextProvider[] providers)
public AIAgentBuilder UseAIContextProviders(params MessageAIContextProvider[] providers)
{
return this.Use((innerAgent, _) => new MessageAIContextProviderAgent(innerAgent, providers));
}
@@ -0,0 +1,215 @@
// 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;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating chat client that enriches input messages, tools, and instructions by invoking a pipeline of
/// <see cref="AIContextProvider"/> instances before delegating to the inner chat client, and notifies those
/// providers after the inner client completes.
/// </summary>
/// <remarks>
/// <para>
/// This chat client must be used within the context of a running <see cref="AIAgent"/>. 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.
/// An <see cref="InvalidOperationException"/> is thrown if no run context is available.
/// </para>
/// </remarks>
internal sealed class AIContextProviderChatClient : DelegatingChatClient
{
private readonly IReadOnlyList<AIContextProvider> _providers;
/// <summary>
/// Initializes a new instance of the <see cref="AIContextProviderChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying chat client that will handle the core operations.</param>
/// <param name="providers">The AI context providers to invoke before and after the inner chat client.</param>
public AIContextProviderChatClient(IChatClient innerClient, IReadOnlyList<AIContextProvider> providers)
: base(innerClient)
{
Throw.IfNull(providers);
if (providers.Count == 0)
{
Throw.ArgumentException(nameof(providers), "At least one AIContextProvider must be provided.");
}
this._providers = providers;
}
/// <inheritdoc/>
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var runContext = GetRequiredRunContext();
var (enrichedMessages, enrichedOptions) = await this.InvokeProvidersAsync(runContext, messages, options, cancellationToken).ConfigureAwait(false);
ChatResponse response;
try
{
response = await base.GetResponseAsync(enrichedMessages, enrichedOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
await this.NotifyProvidersOfFailureAsync(runContext, enrichedMessages, ex, cancellationToken).ConfigureAwait(false);
throw;
}
await this.NotifyProvidersOfSuccessAsync(runContext, enrichedMessages, response.Messages, cancellationToken).ConfigureAwait(false);
return response;
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var runContext = GetRequiredRunContext();
var (enrichedMessages, enrichedOptions) = await this.InvokeProvidersAsync(runContext, messages, options, cancellationToken).ConfigureAwait(false);
List<ChatResponseUpdate> responseUpdates = [];
IAsyncEnumerator<ChatResponseUpdate> enumerator;
try
{
enumerator = base.GetStreamingResponseAsync(enrichedMessages, enrichedOptions, cancellationToken).GetAsyncEnumerator(cancellationToken);
}
catch (Exception ex)
{
await this.NotifyProvidersOfFailureAsync(runContext, enrichedMessages, ex, cancellationToken).ConfigureAwait(false);
throw;
}
bool hasUpdates;
try
{
hasUpdates = await enumerator.MoveNextAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
await this.NotifyProvidersOfFailureAsync(runContext, enrichedMessages, ex, 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)
{
await this.NotifyProvidersOfFailureAsync(runContext, enrichedMessages, ex, cancellationToken).ConfigureAwait(false);
throw;
}
}
var chatResponse = responseUpdates.ToChatResponse();
await this.NotifyProvidersOfSuccessAsync(runContext, enrichedMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Gets the current <see cref="AgentRunContext"/>, throwing if not available.
/// </summary>
private static AgentRunContext GetRequiredRunContext()
{
return AIAgent.CurrentRunContext
?? throw new InvalidOperationException(
$"{nameof(AIContextProviderChatClient)} 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.");
}
/// <summary>
/// Invokes each provider's <see cref="AIContextProvider.InvokingAsync"/> in sequence,
/// accumulating context (messages, tools, instructions) from each.
/// </summary>
private async Task<(IEnumerable<ChatMessage> Messages, ChatOptions? Options)> InvokeProvidersAsync(
AgentRunContext runContext,
IEnumerable<ChatMessage> messages,
ChatOptions? options,
CancellationToken cancellationToken)
{
var aiContext = new AIContext
{
Instructions = options?.Instructions,
Messages = messages,
Tools = options?.Tools
};
foreach (var provider in this._providers)
{
var invokingContext = new AIContextProvider.InvokingContext(runContext.Agent, runContext.Session, aiContext);
aiContext = await provider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
}
// Materialize the accumulated context back into messages and options.
var enrichedMessages = aiContext.Messages ?? [];
var tools = aiContext.Tools as IList<AITool> ?? aiContext.Tools?.ToList();
if (options?.Tools is { Count: > 0 } || tools is { Count: > 0 })
{
options ??= new();
options.Tools = tools;
}
if (options?.Instructions is not null || aiContext.Instructions is not null)
{
options ??= new();
options.Instructions = aiContext.Instructions;
}
return (enrichedMessages, options);
}
/// <summary>
/// Notifies each provider of a successful invocation.
/// </summary>
private async Task NotifyProvidersOfSuccessAsync(
AgentRunContext runContext,
IEnumerable<ChatMessage> requestMessages,
IEnumerable<ChatMessage> responseMessages,
CancellationToken cancellationToken)
{
var invokedContext = new AIContextProvider.InvokedContext(runContext.Agent, runContext.Session, requestMessages, responseMessages);
foreach (var provider in this._providers)
{
await provider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Notifies each provider of a failed invocation.
/// </summary>
private async Task NotifyProvidersOfFailureAsync(
AgentRunContext runContext,
IEnumerable<ChatMessage> requestMessages,
Exception exception,
CancellationToken cancellationToken)
{
var invokedContext = new AIContextProvider.InvokedContext(runContext.Agent, runContext.Session, requestMessages, exception);
foreach (var provider in this._providers)
{
await provider.InvokedAsync(invokedContext, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Provides extension methods for adding <see cref="AIContextProvider"/> support to <see cref="ChatClientBuilder"/> instances.
/// </summary>
public static class AIContextProviderChatClientBuilderExtensions
{
/// <summary>
/// Adds one or more <see cref="AIContextProvider"/> instances to the chat client pipeline, enabling context enrichment
/// (messages, tools, and instructions) for any <see cref="IChatClient"/>.
/// </summary>
/// <param name="builder">The <see cref="ChatClientBuilder"/> to which the providers will be added.</param>
/// <param name="providers">
/// The <see cref="AIContextProvider"/> instances to invoke before and after each chat client call.
/// Providers are called in sequence, with each receiving the accumulated context from the previous provider.
/// </param>
/// <returns>The <see cref="ChatClientBuilder"/> with the providers added, enabling method chaining.</returns>
/// <exception cref="System.ArgumentNullException"><paramref name="builder"/> or <paramref name="providers"/> is <see langword="null"/>.</exception>
/// <exception cref="System.ArgumentException"><paramref name="providers"/> is empty.</exception>
/// <remarks>
/// <para>
/// This method wraps the inner chat client with a decorator that calls each provider's
/// <see cref="AIContextProvider.InvokingAsync"/> in sequence before the inner client is called,
/// and calls <see cref="AIContextProvider.InvokedAsync"/> on each provider after the inner client completes.
/// </para>
/// <para>
/// The chat client must be used within the context of a running <see cref="AIAgent"/>. The agent and session
/// are retrieved from <see cref="AIAgent.CurrentRunContext"/>. An <see cref="System.InvalidOperationException"/>
/// is thrown at invocation time if no run context is available.
/// </para>
/// </remarks>
public static ChatClientBuilder UseAIContextProviders(this ChatClientBuilder builder, params AIContextProvider[] providers)
{
_ = Throw.IfNull(builder);
return builder.Use(innerClient => new AIContextProviderChatClient(innerClient, providers));
}
}
@@ -32,7 +32,13 @@ internal sealed class FunctionInvocationDelegatingAgent : DelegatingAIAgent
{
if (options is null || options.GetType() == typeof(AgentRunOptions))
{
options = new ChatClientAgentRunOptions();
options = new ChatClientAgentRunOptions()
{
ResponseFormat = options?.ResponseFormat,
AllowBackgroundResponses = options?.AllowBackgroundResponses,
ContinuationToken = options?.ContinuationToken,
AdditionalProperties = options?.AdditionalProperties,
};
}
if (options is not ChatClientAgentRunOptions aco)
@@ -41,6 +41,18 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
private const string DefaultFunctionToolName = "Search";
private const string DefaultFunctionToolDescription = "Allows searching for related previous chat history to help answer the user question.";
private const string KeyField = "Key";
private const string RoleField = "Role";
private const string MessageIdField = "MessageId";
private const string AuthorNameField = "AuthorName";
private const string ApplicationIdField = "ApplicationId";
private const string AgentIdField = "AgentId";
private const string UserIdField = "UserId";
private const string SessionIdField = "SessionId";
private const string ContentField = "Content";
private const string CreatedAtField = "CreatedAt";
private const string ContentEmbeddingField = "ContentEmbedding";
private readonly ProviderSessionState<State> _sessionState;
#pragma warning disable CA2213 // VectorStore is not owned by this class - caller is responsible for disposal
@@ -98,17 +110,17 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
{
Properties =
[
new VectorStoreKeyProperty("Key", typeof(Guid)),
new VectorStoreDataProperty("Role", typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty("MessageId", typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty("AuthorName", typeof(string)),
new VectorStoreDataProperty("ApplicationId", typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty("AgentId", typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty("UserId", typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty("SessionId", typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty("Content", typeof(string)) { IsFullTextIndexed = true },
new VectorStoreDataProperty("CreatedAt", typeof(string)) { IsIndexed = true },
new VectorStoreVectorProperty("ContentEmbedding", typeof(string), Throw.IfLessThan(vectorDimensions, 1))
new VectorStoreKeyProperty(KeyField, typeof(Guid)),
new VectorStoreDataProperty(RoleField, typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty(MessageIdField, typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty(AuthorNameField, typeof(string)),
new VectorStoreDataProperty(ApplicationIdField, typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty(AgentIdField, typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty(UserIdField, typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty(SessionIdField, typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty(ContentField, typeof(string)) { IsFullTextIndexed = true },
new VectorStoreDataProperty(CreatedAtField, typeof(string)) { IsIndexed = true },
new VectorStoreVectorProperty(ContentEmbeddingField, typeof(string), Throw.IfLessThan(vectorDimensions, 1))
]
};
@@ -233,17 +245,17 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
.Concat(context.ResponseMessages ?? [])
.Select(message => new Dictionary<string, object?>
{
["Key"] = Guid.NewGuid(),
["Role"] = message.Role.ToString(),
["MessageId"] = message.MessageId,
["AuthorName"] = message.AuthorName,
["ApplicationId"] = storageScope.ApplicationId,
["AgentId"] = storageScope.AgentId,
["UserId"] = storageScope.UserId,
["SessionId"] = storageScope.SessionId,
["Content"] = message.Text,
["CreatedAt"] = message.CreatedAt?.ToString("O") ?? DateTimeOffset.UtcNow.ToString("O"),
["ContentEmbedding"] = message.Text,
[KeyField] = Guid.NewGuid(),
[RoleField] = message.Role.ToString(),
[MessageIdField] = message.MessageId,
[AuthorNameField] = message.AuthorName,
[ApplicationIdField] = storageScope.ApplicationId,
[AgentIdField] = storageScope.AgentId,
[UserIdField] = storageScope.UserId,
[SessionIdField] = storageScope.SessionId,
[ContentField] = message.Text,
[CreatedAtField] = message.CreatedAt?.ToString("O") ?? DateTimeOffset.UtcNow.ToString("O"),
[ContentEmbeddingField] = message.Text,
})
.ToList();
@@ -288,7 +300,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
}
// Format the results as a single context message
var outputResultsText = string.Join("\n", results.Select(x => (string?)x["Content"]).Where(c => !string.IsNullOrWhiteSpace(c)));
var outputResultsText = string.Join("\n", results.Select(x => (string?)x[ContentField]).Where(c => !string.IsNullOrWhiteSpace(c)));
if (string.IsNullOrWhiteSpace(outputResultsText))
{
return string.Empty;
@@ -340,12 +352,12 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
Expression<Func<Dictionary<string, object?>, bool>>? filter = null;
if (applicationId != null)
{
filter = x => (string?)x["ApplicationId"] == applicationId;
filter = x => (string?)x[ApplicationIdField] == applicationId;
}
if (agentId != null)
{
Expression<Func<Dictionary<string, object?>, bool>> agentIdFilter = x => (string?)x["AgentId"] == agentId;
Expression<Func<Dictionary<string, object?>, bool>> agentIdFilter = x => (string?)x[AgentIdField] == agentId;
filter = filter == null ? agentIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
Expression.AndAlso(filter.Body, agentIdFilter.Body),
filter.Parameters);
@@ -353,7 +365,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
if (userId != null)
{
Expression<Func<Dictionary<string, object?>, bool>> userIdFilter = x => (string?)x["UserId"] == userId;
Expression<Func<Dictionary<string, object?>, bool>> userIdFilter = x => (string?)x[UserIdField] == userId;
filter = filter == null ? userIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
Expression.AndAlso(filter.Body, userIdFilter.Body),
filter.Parameters);
@@ -361,7 +373,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo
if (sessionId != null)
{
Expression<Func<Dictionary<string, object?>, bool>> sessionIdFilter = x => (string?)x["SessionId"] == sessionId;
Expression<Func<Dictionary<string, object?>, bool>> sessionIdFilter = x => (string?)x[SessionIdField] == sessionId;
filter = filter == null ? sessionIdFilter : Expression.Lambda<Func<Dictionary<string, object?>, bool>>(
Expression.AndAlso(filter.Body, sessionIdFilter.Body),
filter.Parameters);
@@ -2,12 +2,14 @@
<PropertyGroup>
<IsReleaseCandidate>true</IsReleaseCandidate>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
<NoWarn>$(NoWarn);MEAI001;MAAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectDiagnosticClassesOnLegacy>true</InjectDiagnosticClassesOnLegacy>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
</PropertyGroup>
@@ -0,0 +1,56 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a loaded Agent Skill discovered from a filesystem directory.
/// </summary>
/// <remarks>
/// Each skill is backed by a <c>SKILL.md</c> file containing YAML frontmatter (name and description)
/// and a markdown body with instructions. Resource files referenced in the body are validated at
/// discovery time and read from disk on demand.
/// </remarks>
internal sealed class FileAgentSkill
{
/// <summary>
/// Initializes a new instance of the <see cref="FileAgentSkill"/> class.
/// </summary>
/// <param name="frontmatter">Parsed YAML frontmatter (name and description).</param>
/// <param name="body">The SKILL.md content after the closing <c>---</c> delimiter.</param>
/// <param name="sourcePath">Absolute path to the directory containing this skill.</param>
/// <param name="resourceNames">Relative paths of resource files referenced in the skill body.</param>
public FileAgentSkill(
SkillFrontmatter frontmatter,
string body,
string sourcePath,
IReadOnlyList<string>? resourceNames = null)
{
this.Frontmatter = Throw.IfNull(frontmatter);
this.Body = Throw.IfNull(body);
this.SourcePath = Throw.IfNullOrWhitespace(sourcePath);
this.ResourceNames = resourceNames ?? [];
}
/// <summary>
/// Gets the parsed YAML frontmatter (name and description).
/// </summary>
public SkillFrontmatter Frontmatter { get; }
/// <summary>
/// Gets the SKILL.md body content (without the YAML frontmatter).
/// </summary>
public string Body { get; }
/// <summary>
/// Gets the directory path where the skill was discovered.
/// </summary>
public string SourcePath { get; }
/// <summary>
/// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md").
/// </summary>
public IReadOnlyList<string> ResourceNames { get; }
}
@@ -0,0 +1,407 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI;
/// <summary>
/// Discovers, parses, and validates SKILL.md files from filesystem directories.
/// </summary>
/// <remarks>
/// Searches directories recursively (up to <see cref="MaxSearchDepth"/> levels) for SKILL.md files.
/// Each file is validated for YAML frontmatter and resource integrity. Invalid skills are excluded
/// with logged warnings. Resource paths are checked against path traversal and symlink escape attacks.
/// </remarks>
internal sealed partial class FileAgentSkillLoader
{
private const string SkillFileName = "SKILL.md";
private const int MaxSearchDepth = 2;
private const int MaxNameLength = 64;
private const int MaxDescriptionLength = 1024;
// Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters.
// Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block.
// The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend.
// Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n"
private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
// Matches markdown links to local resource files. Group 1 = relative file path.
// Supports optional ./ or ../ prefixes; excludes URLs (no ":" in the path character class).
// Intentionally conservative: only matches paths with word characters, hyphens, dots,
// and forward slashes. Paths with spaces or special characters are not supported.
// Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", [s](./s.json) → "./s.json",
// [p](../shared/doc.txt) → "../shared/doc.txt"
private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled, TimeSpan.FromSeconds(5));
// Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value.
// Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values.
// Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _),
// "description: \"A skill\"" → (description, A skill, _)
private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5));
// Validates skill names: lowercase letters, numbers, and hyphens only; must not start or end with a hyphen.
// Examples: "my-skill" âś“, "skill123" âś“, "-bad" âś—, "bad-" âś—, "Bad" âś—
private static readonly Regex s_validNameRegex = new(@"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$", RegexOptions.Compiled);
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FileAgentSkillLoader"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
internal FileAgentSkillLoader(ILogger logger)
{
this._logger = logger;
}
/// <summary>
/// Discovers skill directories and loads valid skills from them.
/// </summary>
/// <param name="skillPaths">Paths to search for skills. Each path can point to an individual skill folder or a parent folder.</param>
/// <returns>A dictionary of loaded skills keyed by skill name.</returns>
internal Dictionary<string, FileAgentSkill> DiscoverAndLoadSkills(IEnumerable<string> skillPaths)
{
var skills = new Dictionary<string, FileAgentSkill>(StringComparer.OrdinalIgnoreCase);
var discoveredPaths = DiscoverSkillDirectories(skillPaths);
LogSkillsDiscovered(this._logger, discoveredPaths.Count);
foreach (string skillPath in discoveredPaths)
{
FileAgentSkill? skill = this.ParseSkillFile(skillPath);
if (skill is null)
{
continue;
}
if (skills.TryGetValue(skill.Frontmatter.Name, out FileAgentSkill? existing))
{
LogDuplicateSkillName(this._logger, skill.Frontmatter.Name, skillPath, existing.SourcePath);
// Skip duplicate skill names, keeping the first one found.
continue;
}
skills[skill.Frontmatter.Name] = skill;
LogSkillLoaded(this._logger, skill.Frontmatter.Name);
}
LogSkillsLoadedTotal(this._logger, skills.Count);
return skills;
}
/// <summary>
/// Reads a resource file from disk with path traversal and symlink guards.
/// </summary>
/// <param name="skill">The skill that owns the resource.</param>
/// <param name="resourceName">Relative path of the resource within the skill directory.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The UTF-8 text content of the resource file.</returns>
/// <exception cref="InvalidOperationException">
/// The resource is not registered, resolves outside the skill directory, or does not exist.
/// </exception>
internal async Task<string> ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default)
{
resourceName = NormalizeResourcePath(resourceName);
if (!skill.ResourceNames.Any(r => r.Equals(resourceName, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException($"Resource '{resourceName}' not found in skill '{skill.Frontmatter.Name}'.");
}
string fullPath = Path.GetFullPath(Path.Combine(skill.SourcePath, resourceName));
string normalizedSourcePath = Path.GetFullPath(skill.SourcePath) + Path.DirectorySeparatorChar;
if (!IsPathWithinDirectory(fullPath, normalizedSourcePath))
{
throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory.");
}
if (!File.Exists(fullPath))
{
throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'.");
}
if (HasSymlinkInPath(fullPath, normalizedSourcePath))
{
throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory.");
}
LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name);
#if NET
return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
#else
return await Task.FromResult(File.ReadAllText(fullPath, Encoding.UTF8)).ConfigureAwait(false);
#endif
}
private static List<string> DiscoverSkillDirectories(IEnumerable<string> skillPaths)
{
var discoveredPaths = new List<string>();
foreach (string rootDirectory in skillPaths)
{
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
{
continue;
}
SearchDirectoriesForSkills(rootDirectory, discoveredPaths, currentDepth: 0);
}
return discoveredPaths;
}
private static void SearchDirectoriesForSkills(string directory, List<string> results, int currentDepth)
{
string skillFilePath = Path.Combine(directory, SkillFileName);
if (File.Exists(skillFilePath))
{
results.Add(Path.GetFullPath(directory));
}
if (currentDepth >= MaxSearchDepth)
{
return;
}
foreach (string subdirectory in Directory.EnumerateDirectories(directory))
{
SearchDirectoriesForSkills(subdirectory, results, currentDepth + 1);
}
}
private FileAgentSkill? ParseSkillFile(string skillDirectoryPath)
{
string skillFilePath = Path.Combine(skillDirectoryPath, SkillFileName);
string content = File.ReadAllText(skillFilePath, Encoding.UTF8);
if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body))
{
return null;
}
List<string> resourceNames = ExtractResourcePaths(body);
if (!this.ValidateResources(skillDirectoryPath, resourceNames, frontmatter.Name))
{
return null;
}
return new FileAgentSkill(
frontmatter: frontmatter,
body: body,
sourcePath: skillDirectoryPath,
resourceNames: resourceNames);
}
private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body)
{
frontmatter = null!;
body = null!;
Match match = s_frontmatterRegex.Match(content);
if (!match.Success)
{
LogInvalidFrontmatter(this._logger, skillFilePath);
return false;
}
string? name = null;
string? description = null;
string yamlContent = match.Groups[1].Value.Trim();
foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent))
{
string key = kvMatch.Groups[1].Value;
string value = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value;
if (string.Equals(key, "name", StringComparison.OrdinalIgnoreCase))
{
name = value;
}
else if (string.Equals(key, "description", StringComparison.OrdinalIgnoreCase))
{
description = value;
}
}
if (string.IsNullOrWhiteSpace(name))
{
LogMissingFrontmatterField(this._logger, skillFilePath, "name");
return false;
}
if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name))
{
LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen.");
return false;
}
if (string.IsNullOrWhiteSpace(description))
{
LogMissingFrontmatterField(this._logger, skillFilePath, "description");
return false;
}
if (description.Length > MaxDescriptionLength)
{
LogInvalidFieldValue(this._logger, skillFilePath, "description", $"Must be {MaxDescriptionLength} characters or fewer.");
return false;
}
frontmatter = new SkillFrontmatter(name, description);
body = content.Substring(match.Index + match.Length).TrimStart();
return true;
}
private bool ValidateResources(string skillDirectoryPath, List<string> resourceNames, string skillName)
{
string normalizedSkillPath = Path.GetFullPath(skillDirectoryPath) + Path.DirectorySeparatorChar;
foreach (string resourceName in resourceNames)
{
string fullPath = Path.GetFullPath(Path.Combine(skillDirectoryPath, resourceName));
if (!IsPathWithinDirectory(fullPath, normalizedSkillPath))
{
LogResourcePathTraversal(this._logger, skillName, resourceName);
return false;
}
if (!File.Exists(fullPath))
{
LogMissingResource(this._logger, skillName, resourceName);
return false;
}
if (HasSymlinkInPath(fullPath, normalizedSkillPath))
{
LogResourceSymlinkEscape(this._logger, skillName, resourceName);
return false;
}
}
return true;
}
/// <summary>
/// Checks that <paramref name="fullPath"/> is under <paramref name="normalizedDirectoryPath"/>,
/// guarding against path traversal attacks.
/// </summary>
private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath)
{
return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Checks whether any segment in <paramref name="fullPath"/> (relative to
/// <paramref name="normalizedDirectoryPath"/>) is a symlink (reparse point).
/// Uses <see cref="FileAttributes.ReparsePoint"/> which is available on all target frameworks.
/// </summary>
private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath)
{
string relativePath = fullPath.Substring(normalizedDirectoryPath.Length);
string[] segments = relativePath.Split(
new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
StringSplitOptions.RemoveEmptyEntries);
string currentPath = normalizedDirectoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
foreach (string segment in segments)
{
currentPath = Path.Combine(currentPath, segment);
if ((File.GetAttributes(currentPath) & FileAttributes.ReparsePoint) != 0)
{
return true;
}
}
return false;
}
private static List<string> ExtractResourcePaths(string content)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var paths = new List<string>();
foreach (Match m in s_resourceLinkRegex.Matches(content))
{
string path = NormalizeResourcePath(m.Groups[1].Value);
if (seen.Add(path))
{
paths.Add(path);
}
}
return paths;
}
/// <summary>
/// Normalizes a relative resource path by trimming a leading <c>./</c> prefix and replacing
/// backslashes with forward slashes so that <c>./refs/doc.md</c> and <c>refs/doc.md</c> are
/// treated as the same resource.
/// </summary>
private static string NormalizeResourcePath(string path)
{
if (path.IndexOf('\\') >= 0)
{
path = path.Replace('\\', '/');
}
if (path.StartsWith("./", StringComparison.Ordinal))
{
path = path.Substring(2);
}
return path;
}
[LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")]
private static partial void LogSkillsDiscovered(ILogger logger, int count);
[LoggerMessage(LogLevel.Information, "Loaded skill: {SkillName}")]
private static partial void LogSkillLoaded(ILogger logger, string skillName);
[LoggerMessage(LogLevel.Information, "Successfully loaded {Count} skills")]
private static partial void LogSkillsLoadedTotal(ILogger logger, int count);
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")]
private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath);
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' is missing a '{FieldName}' field in frontmatter")]
private static partial void LogMissingFrontmatterField(ILogger logger, string skillFilePath, string fieldName);
[LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")]
private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason);
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': referenced resource '{ResourceName}' does not exist")]
private static partial void LogMissingResource(ILogger logger, string skillName, string resourceName);
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' references a path outside the skill directory")]
private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourceName);
[LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")]
private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath);
[LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' is a symlink that resolves outside the skill directory")]
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourceName);
[LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")]
private static partial void LogResourceReading(ILogger logger, string fileName, string skillName);
}
@@ -0,0 +1,213 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Security;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// An <see cref="AIContextProvider"/> that discovers and exposes Agent Skills from filesystem directories.
/// </summary>
/// <remarks>
/// <para>
/// This provider implements the progressive disclosure pattern from the
/// <see href="https://agentskills.io/">Agent Skills specification</see>:
/// </para>
/// <list type="number">
/// <item><description><strong>Advertise</strong> — skill names and descriptions are injected into the system prompt (~100 tokens per skill).</description></item>
/// <item><description><strong>Load</strong> — the full SKILL.md body is returned via the <c>load_skill</c> tool.</description></item>
/// <item><description><strong>Read resources</strong> — supplementary files are read from disk on demand via the <c>read_skill_resource</c> tool.</description></item>
/// </list>
/// <para>
/// Skills are discovered by searching the configured directories for <c>SKILL.md</c> files.
/// Referenced resources are validated at initialization; invalid skills are excluded and logged.
/// </para>
/// <para>
/// <strong>Security:</strong> this provider only reads static content. Skill metadata is XML-escaped
/// before prompt embedding, and resource reads are guarded against path traversal and symlink escape.
/// Only use skills from trusted sources.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed partial class FileAgentSkillsProvider : AIContextProvider
{
private const string DefaultSkillsInstructionPrompt =
"""
You have access to skills containing domain-specific knowledge and capabilities.
Each skill provides specialized instructions, reference documents, and assets for specific tasks.
<available_skills>
{0}
</available_skills>
When a task aligns with a skill's domain:
1. Use `load_skill` to retrieve the skill's instructions
2. Follow the provided guidance
3. Use `read_skill_resource` to read any references or other files mentioned by the skill
Only load what is needed, when it is needed.
""";
private readonly Dictionary<string, FileAgentSkill> _skills;
private readonly ILogger<FileAgentSkillsProvider> _logger;
private readonly FileAgentSkillLoader _loader;
private readonly AITool[] _tools;
private readonly string? _skillsInstructionPrompt;
/// <summary>
/// Initializes a new instance of the <see cref="FileAgentSkillsProvider"/> class that searches a single directory for skills.
/// </summary>
/// <param name="skillPath">Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories.</param>
/// <param name="options">Optional configuration for prompt customization.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
: this([skillPath], options, loggerFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="FileAgentSkillsProvider"/> class that searches multiple directories for skills.
/// </summary>
/// <param name="skillPaths">Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories.</param>
/// <param name="options">Optional configuration for prompt customization.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
public FileAgentSkillsProvider(IEnumerable<string> skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(skillPaths);
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<FileAgentSkillsProvider>();
this._loader = new FileAgentSkillLoader(this._logger);
this._skills = this._loader.DiscoverAndLoadSkills(skillPaths);
this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills);
this._tools =
[
AIFunctionFactory.Create(
this.LoadSkill,
name: "load_skill",
description: "Loads the full instructions for a specific skill."),
AIFunctionFactory.Create(
this.ReadSkillResourceAsync,
name: "read_skill_resource",
description: "Reads a file associated with a skill, such as references or assets."),
];
}
/// <inheritdoc />
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
if (this._skills.Count == 0)
{
return base.ProvideAIContextAsync(context, cancellationToken);
}
return new ValueTask<AIContext>(new AIContext
{
Instructions = this._skillsInstructionPrompt,
Tools = this._tools
});
}
private string LoadSkill(string skillName)
{
if (string.IsNullOrWhiteSpace(skillName))
{
return "Error: Skill name cannot be empty.";
}
if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill))
{
return $"Error: Skill '{skillName}' not found.";
}
LogSkillLoading(this._logger, skillName);
return skill.Body;
}
private async Task<string> ReadSkillResourceAsync(string skillName, string resourceName, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(skillName))
{
return "Error: Skill name cannot be empty.";
}
if (string.IsNullOrWhiteSpace(resourceName))
{
return "Error: Resource name cannot be empty.";
}
if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill))
{
return $"Error: Skill '{skillName}' not found.";
}
try
{
return await this._loader.ReadSkillResourceAsync(skill, resourceName, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
LogResourceReadError(this._logger, skillName, resourceName, ex);
return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'.";
}
}
private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary<string, FileAgentSkill> skills)
{
string promptTemplate = DefaultSkillsInstructionPrompt;
if (options?.SkillsInstructionPrompt is { } optionsInstructions)
{
try
{
promptTemplate = string.Format(optionsInstructions, string.Empty);
}
catch (FormatException ex)
{
throw new ArgumentException(
"The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').",
nameof(options),
ex);
}
}
if (skills.Count == 0)
{
return null;
}
var sb = new StringBuilder();
// Order by name for deterministic prompt output across process restarts
// (Dictionary enumeration order is not guaranteed and varies with hash randomization).
foreach (var skill in skills.Values.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal))
{
sb.AppendLine(" <skill>");
sb.AppendLine($" <name>{SecurityElement.Escape(skill.Frontmatter.Name)}</name>");
sb.AppendLine($" <description>{SecurityElement.Escape(skill.Frontmatter.Description)}</description>");
sb.AppendLine(" </skill>");
}
return string.Format(promptTemplate, sb.ToString().TrimEnd());
}
[LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")]
private static partial void LogSkillLoading(ILogger logger, string skillName);
[LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")]
private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception);
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Configuration options for <see cref="FileAgentSkillsProvider"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class FileAgentSkillsProviderOptions
{
/// <summary>
/// Gets or sets a custom system prompt template for advertising skills.
/// Use <c>{0}</c> as the placeholder for the generated skills list.
/// When <see langword="null"/>, a default template is used.
/// </summary>
public string? SkillsInstructionPrompt { get; set; }
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description.
/// </summary>
internal sealed class SkillFrontmatter
{
/// <summary>
/// Initializes a new instance of the <see cref="SkillFrontmatter"/> class.
/// </summary>
/// <param name="name">Skill name.</param>
/// <param name="description">Skill description.</param>
public SkillFrontmatter(string name, string description)
{
this.Name = Throw.IfNullOrWhitespace(name);
this.Description = Throw.IfNullOrWhitespace(description);
}
/// <summary>
/// Gets the skill name. Lowercase letters, numbers, and hyphens only.
/// </summary>
public string Name { get; }
/// <summary>
/// Gets the skill description. Used for discovery in the system prompt.
/// </summary>
public string Description { get; }
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Shared.IntegrationTests;
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
#pragma warning disable CA1812 // Internal class that is apparently never instantiated.
internal sealed class FoundryMemoryConfiguration
{
public string Endpoint { get; set; }
public string MemoryStoreName { get; set; }
public string? DeploymentName { get; set; }
}
@@ -22,6 +22,9 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
// Assign to enable logging
public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance;
// Assign to provide MCP tool capabilities
public IMcpToolHandler? McpToolHandler { get; init; }
/// <summary>
/// Create the workflow from the declarative YAML. Includes definition of the
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="ResponseAgentProvider"/>.
@@ -42,6 +45,7 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
Configuration = this.Configuration,
ConversationId = this.ConversationId,
LoggerFactory = this.LoggerFactory,
McpToolHandler = this.McpToolHandler,
};
string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile);
+1 -1
View File
@@ -8,7 +8,7 @@
<IsAotCompatible>false</IsAotCompatible>
<TargetFrameworks>net10.0;net472</TargetFrameworks>
<UserSecretsId>b7762d10-e29b-4bb1-8b74-b6d69a667dd4</UserSecretsId>
<NoWarn>$(NoWarn);Moq1410;xUnit2023</NoWarn>
<NoWarn>$(NoWarn);Moq1410;xUnit2023;MAAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -43,9 +43,9 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
private static AgentSession CreateMockSession() => new Moq.Mock<AgentSession>().Object;
// Cosmos DB Emulator connection settings
private const string EmulatorEndpoint = "https://localhost:8081";
private const string EmulatorKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
// Cosmos DB Emulator connection settings (can be overridden via COSMOSDB_ENDPOINT and COSMOSDB_KEY environment variables)
private static readonly string s_emulatorEndpoint = Environment.GetEnvironmentVariable("COSMOSDB_ENDPOINT") ?? "https://localhost:8081";
private static readonly string s_emulatorKey = Environment.GetEnvironmentVariable("COSMOSDB_KEY") ?? "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
private const string TestContainerId = "ChatMessages";
private const string HierarchicalTestContainerId = "HierarchicalChatMessages";
// Use unique database ID per test class instance to avoid conflicts
@@ -67,12 +67,12 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
// Set COSMOS_PRESERVE_CONTAINERS=true to keep containers and data for inspection
this._preserveContainer = string.Equals(Environment.GetEnvironmentVariable("COSMOS_PRESERVE_CONTAINERS"), "true", StringComparison.OrdinalIgnoreCase);
this._connectionString = $"AccountEndpoint={EmulatorEndpoint};AccountKey={EmulatorKey}";
this._connectionString = $"AccountEndpoint={s_emulatorEndpoint};AccountKey={s_emulatorKey}";
try
{
// Only create CosmosClient for test setup - the actual tests will use connection string constructors
this._setupClient = new CosmosClient(EmulatorEndpoint, EmulatorKey);
this._setupClient = new CosmosClient(s_emulatorEndpoint, s_emulatorKey);
// Test connection by attempting to create database
var databaseResponse = await this._setupClient.CreateDatabaseIfNotExistsAsync(s_testDatabaseId);
@@ -497,7 +497,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
// Act
TokenCredential credential = new DefaultAzureCredential();
using var provider = new CosmosChatHistoryProvider(EmulatorEndpoint, credential, s_testDatabaseId, HierarchicalTestContainerId,
using var provider = new CosmosChatHistoryProvider(s_emulatorEndpoint, credential, s_testDatabaseId, HierarchicalTestContainerId,
_ => new CosmosChatHistoryProvider.State("session-789", "tenant-123", "user-456"));
// Assert
@@ -513,7 +513,7 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
// Arrange & Act
this.SkipIfEmulatorNotAvailable();
using var cosmosClient = new CosmosClient(EmulatorEndpoint, EmulatorKey);
using var cosmosClient = new CosmosClient(s_emulatorEndpoint, s_emulatorKey);
using var provider = new CosmosChatHistoryProvider(cosmosClient, s_testDatabaseId, HierarchicalTestContainerId,
_ => new CosmosChatHistoryProvider.State("session-789", "tenant-123", "user-456"));
@@ -834,6 +834,124 @@ public sealed class CosmosChatHistoryProviderTests : IAsyncLifetime, IDisposable
Assert.Equal("Message 10", messageList[9].Text);
}
[SkippableFact]
[Trait("Category", "CosmosDB")]
public async Task GetMessageCountAsync_WithMessages_ShouldReturnCorrectCountAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
var session = CreateMockSession();
const string ConversationId = "count-test-conversation";
using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId,
_ => new CosmosChatHistoryProvider.State(ConversationId));
// Add 5 messages
var messages = new List<ChatMessage>();
for (int i = 1; i <= 5; i++)
{
messages.Add(new ChatMessage(ChatRole.User, $"Message {i}"));
}
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, messages, []);
await provider.InvokedAsync(context);
// Wait for eventual consistency
await Task.Delay(100);
// Act
var count = await provider.GetMessageCountAsync(session);
// Assert
Assert.Equal(5, count);
}
[SkippableFact]
[Trait("Category", "CosmosDB")]
public async Task GetMessageCountAsync_WithNoMessages_ShouldReturnZeroAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
var session = CreateMockSession();
const string ConversationId = "empty-count-test-conversation";
using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId,
_ => new CosmosChatHistoryProvider.State(ConversationId));
// Act
var count = await provider.GetMessageCountAsync(session);
// Assert
Assert.Equal(0, count);
}
[SkippableFact]
[Trait("Category", "CosmosDB")]
public async Task ClearMessagesAsync_WithMessages_ShouldDeleteAndReturnCountAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
var session = CreateMockSession();
const string ConversationId = "clear-test-conversation";
using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId,
_ => new CosmosChatHistoryProvider.State(ConversationId));
// Add 3 messages
var messages = new List<ChatMessage>
{
new(ChatRole.User, "Message 1"),
new(ChatRole.Assistant, "Message 2"),
new(ChatRole.User, "Message 3")
};
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, messages, []);
await provider.InvokedAsync(context);
// Wait for eventual consistency
await Task.Delay(100);
// Verify messages exist
var countBefore = await provider.GetMessageCountAsync(session);
Assert.Equal(3, countBefore);
// Act
var deletedCount = await provider.ClearMessagesAsync(session);
// Wait for eventual consistency
await Task.Delay(100);
// Assert
Assert.Equal(3, deletedCount);
// Verify messages are deleted
var countAfter = await provider.GetMessageCountAsync(session);
Assert.Equal(0, countAfter);
var invokingContext = new ChatHistoryProvider.InvokingContext(s_mockAgent, session, []);
var retrievedMessages = await provider.InvokingAsync(invokingContext);
Assert.Empty(retrievedMessages);
}
[SkippableFact]
[Trait("Category", "CosmosDB")]
public async Task ClearMessagesAsync_WithNoMessages_ShouldReturnZeroAsync()
{
// Arrange
this.SkipIfEmulatorNotAvailable();
var session = CreateMockSession();
const string ConversationId = "empty-clear-test-conversation";
using var provider = new CosmosChatHistoryProvider(this._connectionString, s_testDatabaseId, TestContainerId,
_ => new CosmosChatHistoryProvider.State(ConversationId));
// Act
var deletedCount = await provider.ClearMessagesAsync(session);
// Assert
Assert.Equal(0, deletedCount);
}
#endregion
#region Message Filter Tests
@@ -28,9 +28,9 @@ namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests;
[Collection("CosmosDB")]
public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
{
// Cosmos DB Emulator connection settings
private const string EmulatorEndpoint = "https://localhost:8081";
private const string EmulatorKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
// Cosmos DB Emulator connection settings (can be overridden via COSMOSDB_ENDPOINT and COSMOSDB_KEY environment variables)
private static readonly string s_emulatorEndpoint = Environment.GetEnvironmentVariable("COSMOSDB_ENDPOINT") ?? "https://localhost:8081";
private static readonly string s_emulatorKey = Environment.GetEnvironmentVariable("COSMOSDB_KEY") ?? "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
private const string TestContainerId = "Checkpoints";
// Use unique database ID per test class instance to avoid conflicts
#pragma warning disable CA1802 // Use literals where appropriate
@@ -64,11 +64,11 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
// Set COSMOS_PRESERVE_CONTAINERS=true to keep containers and data for inspection
this._preserveContainer = string.Equals(Environment.GetEnvironmentVariable("COSMOS_PRESERVE_CONTAINERS"), "true", StringComparison.OrdinalIgnoreCase);
this._connectionString = $"AccountEndpoint={EmulatorEndpoint};AccountKey={EmulatorKey}";
this._connectionString = $"AccountEndpoint={s_emulatorEndpoint};AccountKey={s_emulatorKey}";
try
{
this._cosmosClient = new CosmosClient(EmulatorEndpoint, EmulatorKey);
this._cosmosClient = new CosmosClient(s_emulatorEndpoint, s_emulatorKey);
// Test connection by attempting to create database
this._database = await this._cosmosClient.CreateDatabaseIfNotExistsAsync(s_testDatabaseId);
@@ -0,0 +1,132 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Shared.IntegrationTests;
namespace Microsoft.Agents.AI.FoundryMemory.IntegrationTests;
/// <summary>
/// Integration tests for <see cref="FoundryMemoryProvider"/> against a configured Azure AI Foundry Memory service.
/// </summary>
/// <remarks>
/// These integration tests are skipped by default and require a live Azure AI Foundry Memory service.
/// The tests need to be updated to use the new AIAgent-based API pattern.
/// Set <see cref="SkipReason"/> to null to enable them after configuring the service.
/// </remarks>
public sealed class FoundryMemoryProviderTests : IDisposable
{
private const string SkipReason = "Requires an Azure AI Foundry Memory service configured"; // Set to null to enable.
private readonly AIProjectClient? _client;
private readonly string? _memoryStoreName;
private readonly string? _deploymentName;
private bool _disposed;
public FoundryMemoryProviderTests()
{
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables()
.AddUserSecrets<FoundryMemoryProviderTests>(optional: true)
.Build();
var foundrySettings = configuration.GetSection("FoundryMemory").Get<FoundryMemoryConfiguration>();
if (foundrySettings is not null &&
!string.IsNullOrWhiteSpace(foundrySettings.Endpoint) &&
!string.IsNullOrWhiteSpace(foundrySettings.MemoryStoreName))
{
this._client = new AIProjectClient(new Uri(foundrySettings.Endpoint), new AzureCliCredential());
this._memoryStoreName = foundrySettings.MemoryStoreName;
this._deploymentName = foundrySettings.DeploymentName ?? "gpt-4.1-mini";
}
}
[Fact(Skip = SkipReason)]
public async Task CanAddAndRetrieveUserMemoriesAsync()
{
// Arrange
FoundryMemoryProvider memoryProvider = new(
this._client!,
this._memoryStoreName!,
stateInitializer: _ => new(new FoundryMemoryProviderScope("it-user-1")));
AIAgent agent = await this._client!.CreateAIAgentAsync(this._deploymentName!,
options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider] });
AgentSession session = await agent.CreateSessionAsync();
await memoryProvider.EnsureStoredMemoriesDeletedAsync(session);
// Act
AgentResponse resultBefore = await agent.RunAsync("What is my name?", session);
Assert.DoesNotContain("Caoimhe", resultBefore.Text);
await agent.RunAsync("Hello, my name is Caoimhe.", session);
await memoryProvider.WhenUpdatesCompletedAsync();
await Task.Delay(2000);
AgentResponse resultAfter = await agent.RunAsync("What is my name?", session);
// Cleanup
await memoryProvider.EnsureStoredMemoriesDeletedAsync(session);
// Assert
Assert.Contains("Caoimhe", resultAfter.Text);
}
[Fact(Skip = SkipReason)]
public async Task DoesNotLeakMemoriesAcrossScopesAsync()
{
// Arrange
FoundryMemoryProvider memoryProvider1 = new(
this._client!,
this._memoryStoreName!,
stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-a")));
FoundryMemoryProvider memoryProvider2 = new(
this._client!,
this._memoryStoreName!,
stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-b")));
AIAgent agent1 = await this._client!.CreateAIAgentAsync(this._deploymentName!,
options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider1] });
AIAgent agent2 = await this._client!.CreateAIAgentAsync(this._deploymentName!,
options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider2] });
AgentSession session1 = await agent1.CreateSessionAsync();
AgentSession session2 = await agent2.CreateSessionAsync();
await memoryProvider1.EnsureStoredMemoriesDeletedAsync(session1);
await memoryProvider2.EnsureStoredMemoriesDeletedAsync(session2);
// Act - add memory only to scope A
await agent1.RunAsync("Hello, I'm an AI tutor and my name is Caoimhe.", session1);
await memoryProvider1.WhenUpdatesCompletedAsync();
await Task.Delay(2000);
AgentResponse result1 = await agent1.RunAsync("What is your name?", session1);
AgentResponse result2 = await agent2.RunAsync("What is your name?", session2);
// Assert
Assert.Contains("Caoimhe", result1.Text);
Assert.DoesNotContain("Caoimhe", result2.Text);
// Cleanup
await memoryProvider1.EnsureStoredMemoriesDeletedAsync(session1);
await memoryProvider2.EnsureStoredMemoriesDeletedAsync(session2);
}
public void Dispose()
{
if (!this._disposed)
{
this._disposed = true;
}
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.FoundryMemory\Microsoft.Agents.AI.FoundryMemory.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
</ItemGroup>
</Project>

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