Compare commits

...
Author SHA1 Message Date
Dmytro StrukandGitHub cf13e35c73 Updated package versions (#2360) 2025-11-20 16:07:10 -08:00
Tao ChenandGitHub 5353b9a2f0 Update Workflow Viz sample comments (#2361) 2025-11-20 23:34:27 +00:00
Farzad SunavalaGitHubClaudeFarzad Sunavala <farzad.sunavala.enovate.ai>farzad528
04e711cd55 Python: Feature/azure ai search agentic rag (search as separate package) (#2328)
* Python: Fix pyright errors and move search provider to core (#1546)

* address pablo coments

* update azure ai search pypi version to latest prev

* init update

* Fix MyPy type annotation errors in search provider

- Add type annotation to DEFAULT_CONTEXT_PROMPT
- Add type annotation to vectorizable_fields
- Add union type annotation to vector_queries

* Fix DEFAULT_CONTEXT_PROMPT MyPy error and update test

- Rename DEFAULT_CONTEXT_PROMPT to _DEFAULT_SEARCH_CONTEXT_PROMPT to avoid conflict with base class Final variable
- Update test to use new constant name
- All core package tests passing (1123 passed)

* Python: Move Azure AI Search to separate package per PR feedback

Addresses reviewer feedback from PR #1546 by isolating the beta dependency
(azure-search-documents==11.7.0b2) into a new agent-framework-aisearch package.

Changes:
- Created new agent-framework-aisearch package with complete structure
- Moved AzureAISearchContextProvider from core to aisearch package
- Added AzureAISearchSettings class for environment variable auto-loading
- Added support for direct API key string (auto-converts to AzureKeyCredential)
- Added azure_openai_api_key parameter for Knowledge Base authentication
- Updated embedding_function type to Callable[[str], Awaitable[list[float]]]
- Moved Role import to top-level imports
- Maintained lazy loading through agent_framework.azure module
- Removed beta dependency from core package
- Updated all tests to use new package location
- All quality checks pass: ruff format/lint, pyright, mypy (0 errors)
- All 21 unit tests pass with 59% coverage

Semantic search mode verified working with both API key and managed identity authentication.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Python: Clarify top_k parameter only applies to semantic mode

Updated documentation to clarify that the top_k parameter only affects
semantic search mode. In agentic mode, the server-side Knowledge Base
determines retrieval based on query complexity and reasoning effort.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Python: Add Knowledge Base output mode and retrieval reasoning effort parameters

Added support for configurable Knowledge Base behavior in agentic mode:

- knowledge_base_output_mode: "extractive_data" (default) or "answer_synthesis"
  Some knowledge sources require answer_synthesis mode for proper functionality.

- retrieval_reasoning_effort: "minimal" (default), "medium", or "low"
  Controls query planning complexity and multi-hop reasoning depth.

These parameters give users fine-grained control over Knowledge Base behavior
and enable support for knowledge sources that require answer synthesis.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* effort and outputmode query params

* Address PR review feedback for Azure AI Search context provider

* comments eduward

* ed latest comments

---------

Co-authored-by: Farzad Sunavala <farzad.sunavala.enovate.ai>
Co-authored-by: farzad528 <farzad528@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 22:34:46 +00:00
Peter IbekweandGitHub ab3d898979 .NET: Add unit tests for RetrieveConversationMessageExecutor executor (#2232)
* Add unit tests for create conversation executor

* Update indentation and comment typo.

* Added unit tests for declarative executor SetMultipleVariablesExecutor

* Updated comments and syntactic sugar

* Add unit test for declarative executor  RetrieveConversationMessageExecutor

* Removed irrelevant code statements

* Updated based on copilot feedback.
2025-11-20 21:47:42 +00:00
Tao ChenandGitHub 02af2bc0ef Python: Remove duplicated workflow observability sample (#2357)
* Remove duplicated workflow observability sample

* Fix link
2025-11-20 21:06:12 +00:00
David WuandGitHub e5b63a1041 Python: Move evaluation folders to under evaluations (#2355)
* Move evaluation folders to under evaluations

* Change folder path
2025-11-20 20:50:23 +00:00
b575b631c8 Python: Fix for Azure AI client (#2358)
* Fix for Azure AI client

* Update python/packages/azure-ai/agent_framework_azure_ai/_client.py

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-20 20:46:28 +00:00
ce738cc6bc .NET: Add sample to show how to do RAG using Foundry's built-in service (#2324)
* Add sample to show how to do RAG using Foundry's built-in service

* Update README.me

* Update dotnet/samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/Program.cs

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

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2025-11-20 18:55:21 +00:00
f99dca033f fix(observability): handle datetime serialization in tool results (#2248)
Fixes #2219

Adds default=str to json.dumps() calls to handle non-JSON-serializable
types like datetime objects in tool function results.

Co-authored-by: kishikawa-hayato <84244732+HerBest-max@users.noreply.github.com>
2025-11-20 17:50:45 +00:00
6ae32f007d [BREAKING] Python: Schema changes for azure functions package (#2151)
* Python: Add Scaffolding for Durable AzureFunctions package to Agent Framework (#1823)

* Add scafolding

* update readme

* add code owners and label

* update owners

* .NET: Durable extension: initial src and unit tests (#1900)

* Python: Add Durable Agent Wrapper code (#1913)

* add initial changes

* Move code and add single sample

* Update logger

* Remove unused code

* address PR comments

* cleanup code and address comments

---------

Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>

* Azure Functions .NET samples (#1939)

* Python: Add Unit tests for Azurefunctions package (#1976)

* Add Unit tests for Azurefunctions

* remove duplicate import

* .NET: [Feature Branch] Migrate state schema updates and support for agents as MCP tools (#1979)

* Python: Add more samples for Azure Functions (#1980)

* Move all samples

* fix comments

* remove dead lines

* Make samples simpler

* .NET: [Feature Branch] Durable Task extension integration tests (#2017)

* .NET: [Feature Branch] Update OpenAI config for integration tests (#2063)

* Python: Add Integration tests for AzureFunctions  (#2020)

* Add Integration tests

* Remove DTS extension

* Apply suggestions from code review

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

* Apply suggestions from code review

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

* Add pyi file for type safety

* Add samples in readme

* Updated all readme instructions

* Address comments

* Update readmes

* Fix requirements

* Address comments

---------

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

* .NET: [Feature Branch] Update dotnet-build-and-test.yml to support integration tests (#2070)

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

* Fix DTS startup issue and improve logging (#2103)

* .NET: [Feature Branch] Introduce Azure OpenAI config for .NET pipeline (#2106)

Also fixes an issue where we were trying to start docker containers for integration tests on Windows, which doesn't work.

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

* Fix uv.lock after merge

* Python: Add README for Azure Functions samples setup (#2100)

* Add README for Azure Functions samples setup

Added setup instructions for Azure Functions samples, including environment setup, virtual environment creation, and running samples.

* Update python/samples/getting_started/azure_functions/README.md

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

* Apply suggestions from code review

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

* Apply suggestion from @Copilot

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

* Apply suggestions from code review

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Laveesh Rohra <larohra@microsoft.com>

* Fix or remove broken markdown file links (#2115)

* .NET: [Feature Branch] Update HTTP API to be consistent across languages (#2118)

* Python: Fix AzureFunctions Integration Tests (#2116)

* Add Identity Auth to samples

* Update python/samples/getting_started/azure_functions/README.md

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

* Update python/samples/getting_started/azure_functions/01_single_agent/function_app.py

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

* Update python/samples/getting_started/azure_functions/02_multi_agent/function_app.py

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

* Update python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/README.md

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

---------

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

* Python: Fix Http Schema (#2112)

* Rename to threadid

* Respond in plain text

* Make snake-case

* Add http prefix

* rename to wait-for-response

* Add query param check

* address comments

* .NET: Remove IsPackable=false in preparation for nuget release (#2142)

* Python: Move `azurefunctions` to `azure` for import (#2141)

* Move import to Azure

* fix mypy

* Update python/packages/azurefunctions/README.md

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

* Add missing types

* Address comments

---------

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

* Update python/packages/azurefunctions/pyproject.toml

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

* Update python/packages/azurefunctions/agent_framework_azurefunctions/__init__.py

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

* Fix imports

* Address PR feedback from westey-m (#2150)

- Adds a link from the /dotnet/samples/README.md to /dotnet/samples/AzureFunctions
- Make DurableAgentThread deserialization internal for future-proofing
- Update JSON serialization logic to address recently discovered issues with source generator serialization

* Schema changes for azure functions

* Fixed serialization bug

* update to camel case

* Adding logs

* merge with main

* sync uv.lock

* Updated schema

* Fixed tests

* Addressed comments

* Fixed mypy errors

* Fixed bug in responsetype and authorName

* Addressed feedback

* Addressed more feedback

* Python: Addressing comments for #2151 (#2315)

* Initial fixes

* Address more comments

* Address remaining comments

* Fixed remaining snake_case properties

* Fixed remaining snake_case properties

* Fixed mypy errors

* Minor changes

* revert tool names

* Fixed mypy errors

---------

Co-authored-by: Laveesh Rohra <larohra@microsoft.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Co-authored-by: Chris Gillum <cgillum@microsoft.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Anirudh Garg <anirudhg@microsoft.com>
Co-authored-by: Victoria Hall <victoriahall@microsoft.com>
2025-11-20 16:24:34 +00:00
Eduard van ValkenburgandGitHub 039e49f353 Python: small fix for logging in declarative (#2341)
* small fix for logging in declarative

* fix spaces in string
2025-11-20 10:31:00 +00:00
Evan MattsonandGitHub 61dbacd6f8 Improve exception handling (#2337) 2025-11-20 09:25:09 +00:00
David WuandGitHub c7a8c12296 Python: Move red teaming files to its own folder (#2333)
* Move red teaming files to its own folder

* Update file path

* Updated folder names

* Updated reference names
2025-11-20 08:28:22 +00:00
Evan MattsonandGitHub d714b91a14 Python: Fix tool execution bleed-over in aiohttp/Bot Framework scenarios (#2314)
* Deep copy the agent chat options to avoid mutations

* avoiding _thread.RLock pickling errors
2025-11-20 08:06:14 +00:00
Evan MattsonGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
99689add09 Python: clean up exception (#2319)
* Potential fix for code scanning alert no. 18: Information exposure through an exception

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Fix test

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-11-20 00:54:07 +00:00
Evan MattsonandGitHub 4fcc5a4b7d Python: propagate as_tool() kwargs. Add sample for runtime context with as_tool kwargs and middleware. (#2311)
* as tool kwargs

* simplify
2025-11-20 00:53:44 +00:00
79bb87061b Python: Clean up imports (#2318)
* chore: tidy imports

* Update python/packages/azurefunctions/agent_framework_azurefunctions/_errors.py

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

* Update python/packages/azurefunctions/agent_framework_azurefunctions/_callbacks.py

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

* chore: revert stub file change

* chore: trigger pre-commit hook, re-add `annotations` import

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-19 23:41:01 +00:00
b3e96b80ae Python: Use AI Foundry evaluators for self-reflection (#2250)
* First working version

* Simplify the implementations

* Remove unused env var

* Update Python syntax

* Address feedbacks

* Fix a typo

* Update names as review suggestions

* Citation for self-reflection

* Move to independent folder

* Update python/samples/getting_started/evaluation/azure_ai_foundry/evaluation/README.md

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

* Updated from parquet to JSONL and hide the default environment variables

* As review feedback, remove the purpose of using `run_self_reflection_batch` as a library, only use it as sample code

* Update python/samples/getting_started/evaluation/azure_ai_foundry/evaluation/self_reflection.py

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

---------

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2025-11-19 18:41:21 +00:00
Eduard van ValkenburgandGitHub 92df9e14bf Python: Introducing support for declarative yaml spec (#2002)
* first work on declarative

* initial version of the declarative support

* fix tests and mypy

* fix parameters of functiontool

* slight logic improvement

* remove path until merge

* updates from comments

* create dispatcher and spec type, json_schema method

* fix mypy, skipping model

* updated lock

* fixed declarative tests and renamed some other test files

* refined loader

* updated lock

* fix mypy

* added readme to samples folder

* fixes from review

* undid test file rename
2025-11-19 16:33:02 +00:00
Eduard van ValkenburgandGitHub d2d0f46e15 Python: fix all to include the latest and made that single source of truth (#2303)
* fix all to include the latest and made that single source of truth

* add lab
2025-11-19 16:09:28 +00:00
Dmytro StrukandGitHub 84e2c0cc22 Python: Added M365 Agent SDK Hosting sample (#2292)
* Added M365 Agent SDK Hosting sample

* Addressed PR feedback

* Added inline dependencies

* Addressed PR feedback
2025-11-19 15:54:47 +00:00
Eduard van ValkenburgandGitHub 4e339f841a added test to validate status set (#2265) 2025-11-19 15:54:12 +00:00
Eduard van ValkenburgandGitHub 34a00f1b8a Python: fix: @ai_function doesn't properly handle 'self' param (#2266)
* Fixes Python: @ai_function doesn't properly handle 'self' param
Fixes #1343

* fix for declaration only funcs

* fix mypy
2025-11-19 15:49:50 +00:00
Giles OdigweandGitHub d5165e2532 Python: Added Foundry Sample for A2A + SharePoint Samples (#2313)
* a2a + sharepoint samples

* small fixes
2025-11-19 11:13:47 +00:00
f83c39f924 Python: fix: resolve string annotations in FunctionExecutor (#2308)
* fix: resolve string annotations in FunctionExecutor

Enhance type hint validation in FunctionExecutor by importing `typing` and
using `get_type_hints` to correctly resolve annotations.

This fixes validation failures when `from __future__ import annotations`
is enabled, which stores annotations as strings.

Fixes #1808

* Update python/packages/core/tests/workflow/test_function_executor_future.py

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

* ran pre commit

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-19 07:26:44 +00:00
claude89757andGitHub 037349ff90 Python: fix Langfuse observability to capture ChatAgent system instructions (#2316)
Fix bug where ChatAgent system instructions were not captured in Langfuse
traces due to incorrect attribute access.

The observability code was attempting to retrieve instructions using
getattr(self, "instructions", None), but ChatAgent stores instructions
in self.chat_options.instructions. This caused system_instructions to
always be None in Langfuse traces.

Changed both _trace_agent_run and _trace_agent_run_stream functions
to correctly retrieve instructions from chat_options.instructions.

Fixes affect:
- Line 1123: _trace_agent_run (non-streaming)
- Line 1192: _trace_agent_run_stream (streaming)
2025-11-19 07:08:27 +00:00
Eduard van ValkenburgGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>eavanvalkenburgcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>CopilotVictor Dibia
293abf5b56 Python: fix for Incomplete URL substring sanitization (#2274)
* Potential fix for code scanning alert no. 29: Incomplete URL substring sanitization

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Python: Fix URL parsing to handle trailing punctuation in deployment progress detection (#2296)

* Initial plan

* Fix URL parsing to handle trailing punctuation correctly

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* updated lock

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Victor Dibia <chuvidi2003@gmail.com>
2025-11-18 22:16:58 +00:00
Evan MattsonandGitHub e2d2299a4f Python: Improve WorkflowBuilder doc strings with code samples (#1960)
* Improve WorkflowBuilder doc strings with code samples

* Cleanup
2025-11-18 22:13:17 +00:00
Eduard van ValkenburgandGitHub 8a7260140a Python: Anthropic foundry (#2302)
* added anthropic foundry sample

* updated readme

* typo
2025-11-18 16:03:40 +00:00
Korolev DmitryandGitHub 1da9107f4a .NET: Improve AIAgent and Workflow registrations for DevUI integration (#2227)
* wip

* resolve non-agent workflows as well!

* add tests for devui registrations and resolving

* fixes

* devui for net8 as well!

* simplify TFM

* update tfm...

* tfm rules....

* wip

* roll

* verify entities are registered with a devui call

* tests

* add a proper support for non-keyed workflows

* resolve default aiagent registration

* sort usings :)

* cleanup tests
2025-11-18 15:38:00 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Chris
03b74bfad4 Bump js-yaml from 4.1.0 to 4.1.1 in /python/packages/devui/frontend (#2230)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-11-18 13:47:27 +00:00
Roger BarretoandGitHub f6cd329a32 .NET: Post bugbash updates (#2279)
* Add missing README.md

* Address bugbash comments

* Address bugbash issues and suggestions
2025-11-18 09:50:22 +00:00
198 changed files with 15873 additions and 5272 deletions
+2
View File
@@ -204,6 +204,8 @@ agents.md
# AI
.claude/
WARP.md
**/memory-bank/
**/projectBrief.md
# Azurite storage emulator files
*/__azurite_db_blob__.json
+3
View File
@@ -0,0 +1,3 @@
# Declarative Agents
This folder contains sample agent definitions than be ran using the declarative agent support, for python see the [declarative agent python sample folder](../python/samples/getting_started/declarative/).
+25
View File
@@ -0,0 +1,25 @@
kind: Prompt
name: Assistant
description: Helpful assistant
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Chat as the type in your response.
model:
id: =Env.AZURE_OPENAI_DEPLOYMENT_NAME
provider: AzureOpenAI
apiType: Chat
options:
temperature: 0.9
topP: 0.95
outputSchema:
properties:
language:
kind: string
required: true
description: The language of the answer.
answer:
kind: string
required: true
description: The answer text.
type:
kind: string
required: true
description: The type of the response.
@@ -0,0 +1,25 @@
kind: Prompt
name: Assistant
description: Helpful assistant
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Assistants as the type in your response.
model:
id: =Env.AZURE_OPENAI_DEPLOYMENT_NAME
provider: AzureOpenAI
apiType: Assistants
options:
temperature: 0.9
topP: 0.95
outputSchema:
properties:
language:
kind: string
required: true
description: The language of the answer.
answer:
kind: string
required: true
description: The answer text.
type:
kind: string
required: true
description: The type of the response.
@@ -0,0 +1,28 @@
kind: Prompt
name: Assistant
description: Helpful assistant
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Responses as the type in your response.
model:
id: =Env.AZURE_OPENAI_DEPLOYMENT_NAME
provider: AzureOpenAI
apiType: Responses
options:
text:
verbosity: medium
connection:
kind: remote
endpoint: =Env.AZURE_OPENAI_ENDPOINT
outputSchema:
properties:
language:
kind: string
required: true
description: The language of the answer.
answer:
kind: string
required: true
description: The answer text.
type:
kind: string
required: true
description: The type of the response.
+18
View File
@@ -0,0 +1,18 @@
kind: Prompt
name: Assistant
description: Helpful assistant
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format.
model:
options:
temperature: 0.9
topP: 0.95
outputSchema:
properties:
language:
kind: string
required: true
description: The language of the answer.
answer:
kind: string
required: true
description: The answer text.
+27
View File
@@ -0,0 +1,27 @@
kind: Prompt
name: Assistant
description: Helpful assistant
instructions: You are a helpful assistant. You answer questions using the tools provided.
model:
options:
allowMultipleToolCalls: true
chatToolMode: auto
tools:
- kind: function
name: GetWeather
description: Get the weather for a given location.
bindings:
get_weather: get_weather
parameters:
properties:
location:
kind: string
description: The city and state, e.g. San Francisco, CA
required: true
unit:
kind: string
description: The unit of temperature. Possible values are 'celsius' and 'fahrenheit'.
required: false
enum:
- celsius
- fahrenheit
@@ -0,0 +1,21 @@
kind: Prompt
name: MicrosoftLearnAgent
description: Microsoft Learn Agent
instructions: You answer questions by searching the Microsoft Learn content only.
model:
id: =Env.AZURE_FOUNDRY_PROJECT_MODEL_ID
options:
temperature: 0.9
topP: 0.95
connection:
kind: remote
endpoint: =Env.AZURE_FOUNDRY_PROJECT_ENDPOINT
tools:
- kind: mcp
name: microsoft_learn
description: Get information from Microsoft Learn.
url: https://learn.microsoft.com/api/mcp
approvalMode:
kind: never
allowedTools:
- microsoft_docs_search
@@ -0,0 +1,22 @@
kind: Prompt
name: Assistant
description: Helpful assistant
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format.
model:
id: =Env.AZURE_FOUNDRY_PROJECT_MODEL_ID
options:
temperature: 0.9
topP: 0.95
connection:
kind: remote
endpoint: =Env.AZURE_FOUNDRY_PROJECT_ENDPOINT
outputSchema:
properties:
language:
kind: string
required: true
description: The language of the answer.
answer:
kind: string
required: true
description: The answer text.
+28
View File
@@ -0,0 +1,28 @@
kind: Prompt
name: Assistant
description: Helpful assistant
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Chat as the type in your response.
model:
id: =Env.OPENAI_MODEL
provider: OpenAI
apiType: Chat
options:
temperature: 0.9
topP: 0.95
connection:
kind: key
key: =Env.OPENAI_API_KEY
outputSchema:
properties:
language:
kind: string
required: true
description: The language of the answer.
answer:
kind: string
required: true
description: The answer text.
type:
kind: string
required: true
description: The type of the response.
@@ -0,0 +1,30 @@
kind: Prompt
name: Assistant
description: Helpful assistant
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Assistants as the type in your response.
model:
id: =Env.OPENAI_MODEL
provider: OpenAI
apiType: Assistants
options:
temperature: 0.9
topP: 0.95
connection:
kind: key
key: =Env.OPENAI_APIKEY
outputSchema:
name: AssistantResponse
description: The response from the assistant.
properties:
language:
kind: string
required: true
description: The language of the answer.
answer:
kind: string
required: true
description: The answer text.
type:
kind: string
required: true
description: The type of the response.
+28
View File
@@ -0,0 +1,28 @@
kind: Prompt
name: Assistant
description: Helpful assistant
instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Responses as the type in your response.
model:
id: =Env.OPENAI_MODEL
provider: OpenAI
apiType: Responses
options:
text:
verbosity: medium
connection:
kind: key
key: =Env.OPENAI_APIKEY
outputSchema:
properties:
language:
kind: string
required: true
description: The language of the answer.
answer:
kind: string
required: true
description: The answer text.
type:
kind: string
required: true
description: The type of the response.
+5 -3
View File
@@ -101,13 +101,14 @@
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_CustomVectorStoreRAG/AgentWithRAG_Step02_CustomVectorStoreRAG.csproj" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step03_CustomRAGDataSource/AgentWithRAG_Step03_CustomRAGDataSource.csproj" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step04_FoundryServiceRAG/AgentWithRAG_Step04_FoundryServiceRAG.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/FoundryAgents/">
<File Path="samples/GettingStarted/FoundryAgents/README.md" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.1_Basics/FoundryAgents_Step01.1_Basics.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step01.2_Running/FoundryAgents_Step01.2_Running.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/FoundryAgents_Step02_MultiturnConversation.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.1_UsingFunctionTools/FoundryAgents_Step03.1_UsingFunctionTools.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step03_UsingFunctionTools/FoundryAgents_Step03_UsingFunctionTools.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/FoundryAgents_Step04_UsingFunctionToolsWithApprovals.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/FoundryAgents_Step05_StructuredOutput.csproj" />
<Project Path="samples/GettingStarted/FoundryAgents/FoundryAgents_Step06_PersistedConversations/FoundryAgents_Step06_PersistedConversations.csproj" />
@@ -191,7 +192,7 @@
<Project Path="samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/Visualization/">
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" Id="99bf0bc6-2440-428e-b3e7-d880e4b7a5fd" />
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/_Foundational/">
<Project Path="samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj" />
@@ -374,6 +375,7 @@
<Project Path="tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.AzureAI.UnitTests/Microsoft.Agents.AI.AzureAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj" />
@@ -2,7 +2,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI;
namespace AgentWebChat.AgentHost;
@@ -10,24 +10,24 @@ internal static class ActorFrameworkWebApplicationExtensions
{
public static void MapAgentDiscovery(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string path)
{
var routeGroup = endpoints.MapGroup(path);
routeGroup.MapGet("/", async (
AgentCatalog agentCatalog,
CancellationToken cancellationToken) =>
{
var results = new List<AgentDiscoveryCard>();
await foreach (var result in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(new AgentDiscoveryCard
{
Name = result.Name!,
Description = result.Description,
});
}
var registeredAIAgents = endpoints.ServiceProvider.GetKeyedServices<AIAgent>(KeyedService.AnyKey);
return Results.Ok(results);
})
.WithName("GetAgents");
var routeGroup = endpoints.MapGroup(path);
routeGroup.MapGet("/", async (CancellationToken cancellationToken) =>
{
var results = new List<AgentDiscoveryCard>();
foreach (var result in registeredAIAgents)
{
results.Add(new AgentDiscoveryCard
{
Name = result.Name!,
Description = result.Description,
});
}
return Results.Ok(results);
})
.WithName("GetAgents");
}
internal sealed class AgentDiscoveryCard
@@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.DevUI\Microsoft.Agents.AI.DevUI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
@@ -5,6 +5,7 @@ using AgentWebChat.AgentHost;
using AgentWebChat.AgentHost.Custom;
using AgentWebChat.AgentHost.Utilities;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DevUI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
@@ -21,6 +22,13 @@ builder.Services.AddProblemDetails();
// Configure the chat model and our agent.
builder.AddKeyedChatClient("chat-model");
// Add DevUI services
builder.AddDevUI();
// Add OpenAI services
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
var pirateAgentBuilder = builder.AddAIAgent(
"pirate",
instructions: "You are a pirate. Speak like a pirate",
@@ -95,8 +103,48 @@ var scienceConcurrentWorkflow = builder.AddWorkflow("science-concurrent-workflow
return AgentWorkflowBuilder.BuildConcurrent(workflowName: key, agents: agents);
}).AddAsAIAgent();
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
builder.AddWorkflow("nonAgentWorkflow", (sp, key) =>
{
List<IHostedAgentBuilder> usedAgents = [pirateAgentBuilder, chemistryAgent];
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents);
});
builder.Services.AddKeyedSingleton("NonAgentAndNonmatchingDINameWorkflow", (sp, key) =>
{
List<IHostedAgentBuilder> usedAgents = [pirateAgentBuilder, chemistryAgent];
var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService<AIAgent>(ab.Name));
return AgentWorkflowBuilder.BuildSequential(workflowName: "random-name", agents: agents);
});
builder.Services.AddSingleton<AIAgent>(sp =>
{
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
return new ChatClientAgent(chatClient, name: "default-agent", instructions: "you are a default agent.");
});
builder.Services.AddKeyedSingleton<AIAgent>("my-di-nonmatching-agent", (sp, name) =>
{
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
return new ChatClientAgent(
chatClient,
name: "some-random-name", // demonstrating registration can be different for DI and actual agent
instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
});
builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, name) =>
{
if (name is not string nameStr)
{
throw new NotSupportedException("Name should be passed as a key");
}
var chatClient = sp.GetRequiredKeyedService<IChatClient>("chat-model");
return new ChatClientAgent(
chatClient,
name: nameStr, // demonstrating registration with the same name
instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
});
var app = builder.Build();
@@ -118,7 +166,10 @@ app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves", agentCard
// Url = "http://localhost:5390/a2a/knights-and-knaves"
});
app.MapDevUI();
app.MapOpenAIResponses();
app.MapOpenAIConversations();
app.MapOpenAIChatCompletions(pirateAgentBuilder);
app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder);
@@ -9,7 +9,9 @@ var azOpenAiResourceGroup = builder.AddParameterFromConfiguration("AzureOpenAIRe
var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-4o", o => o.AsExisting(azOpenAiResource, azOpenAiResourceGroup));
var agentHost = builder.AddProject<Projects.AgentWebChat_AgentHost>("agenthost")
.WithReference(chatModel);
.WithHttpEndpoint(name: "devui")
.WithUrlForEndpoint("devui", (url) => new() { Url = "/devui", DisplayText = "Dev UI" })
.WithReference(chatModel);
builder.AddProject<Projects.AgentWebChat_Web>("webfrontend")
.WithExternalHttpEndpoints()
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -11,7 +11,6 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.SemanticKernel.Plugins.OpenApi" />
</ItemGroup>
<ItemGroup>
@@ -19,8 +18,8 @@
</ItemGroup>
<ItemGroup>
<None Update="OpenAPISpec.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<None Update="contoso-outdoors-knowledge-base.md">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use the built in RAG capabilities that the Foundry service provides when using AI Agents provided by Foundry.
using System.ClientModel;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Files;
using OpenAI.VectorStores;
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// Create an AI Project client and get an OpenAI client that works with the foundry service.
AIProjectClient aiProjectClient = new(
new Uri(endpoint),
new AzureCliCredential());
OpenAIClient openAIClient = aiProjectClient.GetProjectOpenAIClient();
// Upload the file that contains the data to be used for RAG to the Foundry service.
OpenAIFileClient fileClient = openAIClient.GetOpenAIFileClient();
ClientResult<OpenAIFile> uploadResult = await fileClient.UploadFileAsync(
filePath: "contoso-outdoors-knowledge-base.md",
purpose: FileUploadPurpose.Assistants);
// Create a vector store in the Foundry service using the uploaded file.
VectorStoreClient vectorStoreClient = openAIClient.GetVectorStoreClient();
ClientResult<VectorStore> vectorStoreCreate = await vectorStoreClient.CreateVectorStoreAsync(options: new VectorStoreCreationOptions()
{
Name = "contoso-outdoors-knowledge-base",
FileIds = { uploadResult.Value.Id }
});
var fileSearchTool = new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreCreate.Value.Id)] };
AIAgent agent = await aiProjectClient
.CreateAIAgentAsync(
model: deploymentName,
name: "AskContoso",
instructions: "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
tools: [fileSearchTool]);
AgentThread thread = agent.GetNewThread();
Console.WriteLine(">> Asking about returns\n");
Console.WriteLine(await agent.RunAsync("Hi! I need help understanding the return policy.", thread));
Console.WriteLine("\n>> Asking about shipping\n");
Console.WriteLine(await agent.RunAsync("How long does standard shipping usually take?", thread));
Console.WriteLine("\n>> Asking about product care\n");
Console.WriteLine(await agent.RunAsync("What is the best way to maintain the TrailRunner tent fabric?", thread));
// Cleanup
await fileClient.DeleteFileAsync(uploadResult.Value.Id);
await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreCreate.Value.Id);
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
@@ -0,0 +1,19 @@
# Contoso Outdoors Knowledge Base
## Contoso Outdoors Return Policy
Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection.
## Contoso Outdoors Shipping Guide
Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout.
## Product Information
### TrailRunner Tent
The TrailRunner Tent is a lightweight, 2-person tent designed for easy setup and durability. It features waterproof materials, ventilation windows, and a compact carry bag.
#### Care Instructions
Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating.
@@ -7,3 +7,4 @@ These samples show how to create an agent with the Agent Framework that uses Ret
|[Basic Text RAG](./AgentWithRAG_Step01_BasicTextRAG/)|This sample demonstrates how to create and run a basic agent with simple text Retrieval Augmented Generation (RAG).|
|[RAG with Vector Store and custom schema](./AgentWithRAG_Step02_CustomVectorStoreRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with a vector store. It also uses a custom schema for the documents stored in the vector store.|
|[RAG with custom RAG data source](./AgentWithRAG_Step03_CustomRAGDataSource/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with a custom RAG data source.|
|[RAG with Foundry VectorStore service](./AgentWithRAG_Step04_FoundryServiceRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with the Foundry VectorStore service.|
@@ -11,14 +11,15 @@ using Microsoft.Extensions.AI;
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 JokerInstructions = "You are good at telling jokes.";
const string JokerInstructionsV1 = "You are good at telling jokes.";
const string JokerInstructionsV2 = "You are extremely hilarious at telling jokes.";
const string JokerName = "JokerAgent";
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
// Define the agent you want to create. (Prompt Agent in this case)
AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions });
AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructionsV1 });
// Azure.AI.Agents SDK creates and manages agent by name and versions.
// You can create a server side agent version with the Azure.AI.Agents SDK client below.
@@ -32,8 +33,8 @@ AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName:
// You can retrieve an AIAgent for an already created server side agent version.
AIAgent jokerAgentV1 = aiProjectClient.GetAIAgent(agentVersion);
// You can also create another AIAgent version (V2) by providing the same name with a different definition.
AIAgent jokerAgentV2 = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructions + "V2");
// You can also create another AIAgent version (V2) by providing the same name with a different definition/instruction.
AIAgent jokerAgentV2 = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructionsV2);
// You can also get the AIAgent latest version by just providing its name.
AIAgent jokerAgentLatest = aiProjectClient.GetAIAgent(name: JokerName);
@@ -43,11 +44,7 @@ AgentVersion latestVersion = jokerAgentLatest.GetService<AgentVersion>()!;
Console.WriteLine($"Latest agent version id: {latestVersion.Id}");
// Once you have the AIAgent, you can invoke it like any other AIAgent.
AgentThread thread = jokerAgentLatest.GetNewThread();
Console.WriteLine(await jokerAgentLatest.RunAsync("Tell me a joke about a pirate.", thread));
// This will use the same thread to continue the conversation.
Console.WriteLine(await jokerAgentLatest.RunAsync("Now tell me a joke about a cat and a dog using last joke as the anchor.", thread));
Console.WriteLine(await jokerAgentLatest.RunAsync("Tell me a joke about a pirate."));
// Cleanup by agent name removes both agent versions created (jokerAgentV1 + jokerAgentV2).
await aiProjectClient.Agents.DeleteAgentAsync(jokerAgentV1.Name);
@@ -26,13 +26,8 @@ AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName:
// You can retrieve an AIAgent for a already created server side agent version.
AIAgent jokerAgent = aiProjectClient.GetAIAgent(agentVersion);
// Invoke the agent and output the text result.
AgentThread thread = jokerAgent.GetNewThread();
Console.WriteLine(await jokerAgent.RunAsync("Tell me a joke about a pirate.", thread));
// Invoke the agent with streaming support.
thread = jokerAgent.GetNewThread();
await foreach (AgentRunResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate.", thread))
await foreach (AgentRunResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate."))
{
Console.WriteLine(update);
}
@@ -1,354 +0,0 @@
{
"openapi": "3.0.1",
"info": {
"title": "Github Versions API",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.github.com"
}
],
"components": {
"schemas": {
"basic-error": {
"title": "Basic Error",
"description": "Basic Error",
"type": "object",
"properties": {
"message": {
"type": "string"
},
"documentation_url": {
"type": "string"
},
"url": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"label": {
"title": "Label",
"description": "Color-coded labels help you categorize and filter your issues (just like labels in Gmail).",
"type": "object",
"properties": {
"id": {
"description": "Unique identifier for the label.",
"type": "integer",
"format": "int64",
"example": 208045946
},
"node_id": {
"type": "string",
"example": "MDU6TGFiZWwyMDgwNDU5NDY="
},
"url": {
"description": "URL for the label",
"example": "https://api.github.com/repositories/42/labels/bug",
"type": "string",
"format": "uri"
},
"name": {
"description": "The name of the label.",
"example": "bug",
"type": "string"
},
"description": {
"description": "Optional description of the label, such as its purpose.",
"type": "string",
"example": "Something isn't working",
"nullable": true
},
"color": {
"description": "6-character hex code, without the leading #, identifying the color",
"example": "FFFFFF",
"type": "string"
},
"default": {
"description": "Whether this label comes by default in a new repository.",
"type": "boolean",
"example": true
}
},
"required": [
"id",
"node_id",
"url",
"name",
"description",
"color",
"default"
]
},
"tag": {
"title": "Tag",
"description": "Tag",
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "v0.1"
},
"commit": {
"type": "object",
"properties": {
"sha": {
"type": "string"
},
"url": {
"type": "string",
"format": "uri"
}
},
"required": [
"sha",
"url"
]
},
"zipball_url": {
"type": "string",
"format": "uri",
"example": "https://github.com/octocat/Hello-World/zipball/v0.1"
},
"tarball_url": {
"type": "string",
"format": "uri",
"example": "https://github.com/octocat/Hello-World/tarball/v0.1"
},
"node_id": {
"type": "string"
}
},
"required": [
"name",
"node_id",
"commit",
"zipball_url",
"tarball_url"
]
}
},
"examples": {
"label-items": {
"value": [
{
"id": 208045946,
"node_id": "MDU6TGFiZWwyMDgwNDU5NDY=",
"url": "https://api.github.com/repos/octocat/Hello-World/labels/bug",
"name": "bug",
"description": "Something isn't working",
"color": "f29513",
"default": true
},
{
"id": 208045947,
"node_id": "MDU6TGFiZWwyMDgwNDU5NDc=",
"url": "https://api.github.com/repos/octocat/Hello-World/labels/enhancement",
"name": "enhancement",
"description": "New feature or request",
"color": "a2eeef",
"default": false
}
]
},
"tag-items": {
"value": [
{
"name": "v0.1",
"commit": {
"sha": "c5b97d5ae6c19d5c5df71a34c7fbeeda2479ccbc",
"url": "https://api.github.com/repos/octocat/Hello-World/commits/c5b97d5ae6c19d5c5df71a34c7fbeeda2479ccbc"
},
"zipball_url": "https://github.com/octocat/Hello-World/zipball/v0.1",
"tarball_url": "https://github.com/octocat/Hello-World/tarball/v0.1",
"node_id": "MDQ6VXNlcjE="
}
]
}
},
"parameters": {
"owner": {
"name": "owner",
"description": "The account owner of the repository. The name is not case sensitive.",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"repo": {
"name": "repo",
"description": "The name of the repository without the `.git` extension. The name is not case sensitive.",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"per-page": {
"name": "per_page",
"description": "The number of results per page (max 100). For more information, see \"[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api).\"",
"in": "query",
"schema": {
"type": "integer",
"default": 30
}
},
"page": {
"name": "page",
"description": "The page number of the results to fetch. For more information, see \"[Using pagination in the REST API](https://docs.github.com/rest/using-the-rest-api/using-pagination-in-the-rest-api).\"",
"in": "query",
"schema": {
"type": "integer",
"default": 1
}
}
},
"responses": {
"not_found": {
"description": "Resource not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/basic-error"
}
}
}
}
},
"headers": {
"link": {
"example": "<https://api.github.com/resource?page=2>; rel=\"next\", <https://api.github.com/resource?page=5>; rel=\"last\"",
"schema": {
"type": "string"
}
}
}
},
"paths": {
"/repos/{owner}/{repo}/tags": {
"get": {
"summary": "List repository tags",
"description": "",
"tags": [
"repos"
],
"operationId": "repos/list-tags",
"externalDocs": {
"description": "API method documentation",
"url": "https://docs.github.com/rest/repos/repos#list-repository-tags"
},
"parameters": [
{
"$ref": "#/components/parameters/owner"
},
{
"$ref": "#/components/parameters/repo"
},
{
"$ref": "#/components/parameters/per-page"
},
{
"$ref": "#/components/parameters/page"
}
],
"responses": {
"200": {
"description": "Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/tag"
}
},
"examples": {
"default": {
"$ref": "#/components/examples/tag-items"
}
}
}
},
"headers": {
"Link": {
"$ref": "#/components/headers/link"
}
}
}
},
"x-github": {
"githubCloudOnly": false,
"enabledForGitHubApps": true,
"category": "repos",
"subcategory": "repos"
}
}
},
"/repos/{owner}/{repo}/labels": {
"get": {
"summary": "List labels for a repository",
"description": "Lists all labels for a repository.",
"tags": [
"issues"
],
"operationId": "issues/list-labels-for-repo",
"externalDocs": {
"description": "API method documentation",
"url": "https://docs.github.com/rest/issues/labels#list-labels-for-a-repository"
},
"parameters": [
{
"$ref": "#/components/parameters/owner"
},
{
"$ref": "#/components/parameters/repo"
},
{
"$ref": "#/components/parameters/per-page"
},
{
"$ref": "#/components/parameters/page"
}
],
"responses": {
"200": {
"description": "Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/label"
}
},
"examples": {
"default": {
"$ref": "#/components/examples/label-items"
}
}
}
},
"headers": {
"Link": {
"$ref": "#/components/headers/link"
}
}
},
"404": {
"$ref": "#/components/responses/not_found"
}
},
"x-github": {
"githubCloudOnly": false,
"enabledForGitHubApps": true,
"category": "issues",
"subcategory": "labels"
}
}
}
}
}
@@ -1,38 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use an agent with function tools provided via an OpenAPI spec.
// It uses functionality from Semantic Kernel to parse the OpenAPI spec and create function tools to use with the Agent Framework Agent.
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Plugins.OpenApi;
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";
// Load the OpenAPI Spec from a file.
KernelPlugin plugin = await OpenApiKernelPluginFactory.CreateFromOpenApiAsync("github", "OpenAPISpec.json");
// Convert the Semantic Kernel plugin to Agent Framework function tools.
// This requires a dummy Kernel instance, since KernelFunctions cannot execute without one.
Kernel kernel = new();
List<AITool> tools = plugin.Select(x => x.WithKernel(kernel)).Cast<AITool>().ToList();
const string AssistantInstructions = "You are a helpful assistant that can query GitHub repositories.";
const string AssistantName = "GitHubAssistant";
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
// Create AIAgent directly
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: tools);
// Run the agent with the OpenAPI function tools.
AgentThread thread = agent.GetNewThread();
Console.WriteLine(await agent.RunAsync("Please list the names, colors and descriptions of all the labels available in the microsoft/agent-framework repository on github.", thread));
// Cleanup by agent name removes the agent version created.
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
@@ -1,49 +0,0 @@
# Using Function Tools from OpenAPI Specifications
This sample demonstrates how to create function tools from an OpenAPI specification and use them with AI agents.
## What this sample demonstrates
- Loading OpenAPI specifications from files
- Converting OpenAPI specifications to Semantic Kernel plugins
- Converting Semantic Kernel plugins to AI function tools
- Using OpenAPI-based function tools with AI agents
- Managing agent lifecycle (creation and deletion)
## Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 8.0 SDK or later
- Azure Foundry service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
**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).
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_Step03.2_UsingFunctionTools_FromOpenAPI
```
## Expected behavior
The sample will:
1. Load the OpenAPI specification from OpenAPISpec.json (GitHub API)
2. Convert the OpenAPI spec to Semantic Kernel plugins
3. Create an agent named "GitHubAssistant" with the OpenAPI-based function tools
4. Run the agent with a prompt to query GitHub repositories
5. The agent will invoke the appropriate OpenAPI function tools to retrieve data
6. Clean up resources by deleting the agent
@@ -26,18 +26,26 @@ AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential(
AITool tool = AIFunctionFactory.Create(GetWeather);
// Create AIAgent directly
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: [tool]);
var newAgent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: [tool]);
// Getting an already existing agent by name with tools.
/*
* IMPORTANT: Since agents that are stored in the server only know the definition of the function tools (JSON Schema),
* you need to provided all invocable function tools when retrieving the agent so it can invoke them automatically.
* If no invocable tools are provided, the function calling needs to handled manually.
*/
var existingAgent = await aiProjectClient.GetAIAgentAsync(name: AssistantName, tools: [tool]);
// Non-streaming agent interaction with function tools.
AgentThread thread = agent.GetNewThread();
Console.WriteLine(await agent.RunAsync("What is the weather like in Amsterdam?", thread));
AgentThread thread = existingAgent.GetNewThread();
Console.WriteLine(await existingAgent.RunAsync("What is the weather like in Amsterdam?", thread));
// Streaming agent interaction with function tools.
thread = agent.GetNewThread();
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync("What is the weather like in Amsterdam?", thread))
thread = existingAgent.GetNewThread();
await foreach (AgentRunResponseUpdate update in existingAgent.RunStreamingAsync("What is the weather like in Amsterdam?", thread))
{
Console.WriteLine(update);
}
// Cleanup by agent name removes the agent version created.
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
await aiProjectClient.Agents.DeleteAgentAsync(existingAgent.Name);
@@ -25,7 +25,7 @@ const string AssistantName = "WeatherAssistant";
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
ApprovalRequiredAIFunction approvalTool = new(AIFunctionFactory.Create(GetWeather));
ApprovalRequiredAIFunction approvalTool = new(AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather)));
// Create AIAgent directly
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: AssistantName, model: deploymentName, instructions: AssistantInstructions, tools: [approvalTool]);
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

@@ -16,5 +16,11 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Assets\walkway.jpg">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -8,7 +8,7 @@ using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
string deploymentName = System.Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o";
string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o";
const string VisionInstructions = "You are a helpful agent that can analyze images";
const string VisionName = "VisionAgent";
@@ -21,7 +21,7 @@ AIAgent agent = aiProjectClient.CreateAIAgent(name: VisionName, model: deploymen
ChatMessage message = new(ChatRole.User, [
new TextContent("What do you see in this image?"),
new UriContent("https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", "image/jpeg")
new DataContent(File.ReadAllBytes("assets/walkway.jpg"), "image/jpeg")
]);
AgentThread thread = agent.GetNewThread();
@@ -55,7 +55,7 @@ AgentRunResponse response = await agentOption1.RunAsync("I need to solve the equ
// AgentRunResponse response = await agentOption2.RunAsync("I need to solve the equation sin(x) + x^2 = 42");
// Get the CodeInterpreterToolCallContent
CodeInterpreterToolCallContent? toolCallContent = response.Messages.SelectMany(m => m.Contents).OfType<CodeInterpreterToolCallContent>().SingleOrDefault();
CodeInterpreterToolCallContent? toolCallContent = response.Messages.SelectMany(m => m.Contents).OfType<CodeInterpreterToolCallContent>().FirstOrDefault();
if (toolCallContent?.Inputs is not null)
{
DataContent? codeInput = toolCallContent.Inputs.OfType<DataContent>().FirstOrDefault();
@@ -15,8 +15,8 @@ internal sealed class Program
{
private static async Task Main(string[] args)
{
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "computer-use-preview";
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") ?? "computer-use-preview";
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
@@ -24,8 +24,8 @@ Before you begin, ensure you have the following prerequisites:
Set the following environment variables:
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" # Replace with your Azure Foundry resource endpoint
$env:AZURE_OPENAI_DEPLOYMENT_NAME="computer-use-preview" # Optional, defaults to computer-use-preview
$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="computer-use-preview" # Optional, defaults to computer-use-preview
```
## Run the sample
@@ -25,8 +25,7 @@ Before you begin, ensure you have the following prerequisites:
|[Basics](./FoundryAgents_Step01.1_Basics/)|This sample demonstrates how to create and manage AI agents with versioning|
|[Running a simple agent](./FoundryAgents_Step01.2_Running/)|This sample demonstrates how to create and run a basic Foundry agent|
|[Multi-turn conversation](./FoundryAgents_Step02_MultiturnConversation/)|This sample demonstrates how to implement a multi-turn conversation with a Foundry agent|
|[Using function tools](./FoundryAgents_Step03.1_UsingFunctionTools/)|This sample demonstrates how to use function tools with a Foundry agent|
|[Using OpenAPI function tools](./FoundryAgents_Step03.2_UsingFunctionTools_FromOpenAPI/)|This sample demonstrates how to create function tools from an OpenAPI spec and use them with a Foundry agent|
|[Using function tools](./FoundryAgents_Step03_UsingFunctionTools/)|This sample demonstrates how to use function tools with a Foundry agent|
|[Using function tools with approvals](./FoundryAgents_Step04_UsingFunctionToolsWithApprovals/)|This sample demonstrates how to use function tools where approvals require human in the loop approvals before execution|
|[Structured output](./FoundryAgents_Step05_StructuredOutput/)|This sample demonstrates how to use structured output with a Foundry agent|
|[Persisted conversations](./FoundryAgents_Step06_PersistedConversations/)|This sample demonstrates how to persist conversations and reload them later|
@@ -1,10 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI.DevUI.Entities;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
@@ -27,21 +24,26 @@ internal static class EntitiesApiExtensions
/// <item><description>GET /v1/entities/{entityId}/info - Get detailed information about a specific entity</description></item>
/// </list>
/// The endpoints are compatible with the Python DevUI frontend and automatically discover entities
/// from the registered <see cref="AgentCatalog"/> and <see cref="WorkflowCatalog"/> services.
/// from the registered <see cref="AIAgent">agents</see> and <see cref="Workflow">workflows</see> in the dependency injection container.
/// </remarks>
public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints)
{
var registeredAIAgents = GetRegisteredEntities<AIAgent>(endpoints.ServiceProvider);
var registeredWorkflows = GetRegisteredEntities<Workflow>(endpoints.ServiceProvider);
var group = endpoints.MapGroup("/v1/entities")
.WithTags("Entities");
// List all entities
group.MapGet("", ListEntitiesAsync)
group.MapGet("", (CancellationToken cancellationToken)
=> ListEntitiesAsync(registeredAIAgents, registeredWorkflows, cancellationToken))
.WithName("ListEntities")
.WithSummary("List all registered entities (agents and workflows)")
.Produces<DiscoveryResponse>(StatusCodes.Status200OK, contentType: "application/json");
// Get detailed entity information
group.MapGet("{entityId}/info", GetEntityInfoAsync)
group.MapGet("{entityId}/info", (string entityId, string? type, CancellationToken cancellationToken)
=> GetEntityInfoAsync(entityId, type, registeredAIAgents, registeredWorkflows, cancellationToken))
.WithName("GetEntityInfo")
.WithSummary("Get detailed information about a specific entity")
.Produces<EntityInfo>(StatusCodes.Status200OK, contentType: "application/json")
@@ -51,8 +53,8 @@ internal static class EntitiesApiExtensions
}
private static async Task<IResult> ListEntitiesAsync(
AgentCatalog? agentCatalog,
WorkflowCatalog? workflowCatalog,
IEnumerable<AIAgent> agents,
IEnumerable<Workflow> workflows,
CancellationToken cancellationToken)
{
try
@@ -60,13 +62,13 @@ internal static class EntitiesApiExtensions
var entities = new Dictionary<string, EntityInfo>();
// Discover agents
await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
foreach (var agentInfo in DiscoverAgents(agents, entityIdFilter: null))
{
entities[agentInfo.Id] = agentInfo;
}
// Discover workflows
await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
foreach (var workflowInfo in DiscoverWorkflows(workflows, entityIdFilter: null))
{
entities[workflowInfo.Id] = workflowInfo;
}
@@ -85,15 +87,15 @@ internal static class EntitiesApiExtensions
private static async Task<IResult> GetEntityInfoAsync(
string entityId,
string? type,
AgentCatalog? agentCatalog,
WorkflowCatalog? workflowCatalog,
IEnumerable<AIAgent> agents,
IEnumerable<Workflow> workflows,
CancellationToken cancellationToken)
{
try
{
if (type is null || string.Equals(type, "workflow", StringComparison.OrdinalIgnoreCase))
{
await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityId, cancellationToken).ConfigureAwait(false))
foreach (var workflowInfo in DiscoverWorkflows(workflows, entityId))
{
return Results.Json(workflowInfo, EntitiesJsonContext.Default.EntityInfo);
}
@@ -101,7 +103,7 @@ internal static class EntitiesApiExtensions
if (type is null || string.Equals(type, "agent", StringComparison.OrdinalIgnoreCase))
{
await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityId, cancellationToken).ConfigureAwait(false))
foreach (var agentInfo in DiscoverAgents(agents, entityId))
{
return Results.Json(agentInfo, EntitiesJsonContext.Default.EntityInfo);
}
@@ -118,17 +120,9 @@ internal static class EntitiesApiExtensions
}
}
private static async IAsyncEnumerable<EntityInfo> DiscoverAgentsAsync(
AgentCatalog? agentCatalog,
string? entityIdFilter,
[EnumeratorCancellation] CancellationToken cancellationToken)
private static IEnumerable<EntityInfo> DiscoverAgents(IEnumerable<AIAgent> agents, string? entityIdFilter)
{
if (agentCatalog is null)
{
yield break;
}
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
foreach (var agent in agents)
{
// If filtering by entity ID, skip non-matching agents
if (entityIdFilter is not null &&
@@ -148,17 +142,9 @@ internal static class EntitiesApiExtensions
}
}
private static async IAsyncEnumerable<EntityInfo> DiscoverWorkflowsAsync(
WorkflowCatalog? workflowCatalog,
string? entityIdFilter,
[EnumeratorCancellation] CancellationToken cancellationToken)
private static IEnumerable<EntityInfo> DiscoverWorkflows(IEnumerable<Workflow> workflows, string? entityIdFilter)
{
if (workflowCatalog is null)
{
yield break;
}
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
foreach (var workflow in workflows)
{
var workflowId = workflow.Name ?? workflow.StartExecutorId;
@@ -304,4 +290,14 @@ internal static class EntitiesApiExtensions
StartExecutorId = workflow.StartExecutorId
};
}
private static IEnumerable<T> GetRegisteredEntities<T>(IServiceProvider serviceProvider)
{
var keyedEntities = serviceProvider.GetKeyedServices<T>(KeyedService.AnyKey);
var defaultEntities = serviceProvider.GetServices<T>() ?? [];
return keyedEntities
.Concat(defaultEntities)
.Where(entity => entity is not null);
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Extensions.Hosting;
/// <summary>
/// Extension methods for <see cref="IHostApplicationBuilder"/> to configure DevUI.
/// </summary>
public static class MicrosoftAgentAIDevUIHostApplicationBuilderExtensions
{
/// <summary>
/// Adds DevUI services to the host application builder.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddDevUI();
return builder;
}
}
@@ -1,7 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net9.0</TargetFrameworks>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Microsoft.Agents.AI.DevUI</RootNamespace>
@@ -12,6 +13,10 @@
<NoWarn>$(NoWarn);CS1591;CA1852;CA1050;RCS1037;RCS1036;RCS1124;RCS1021;RCS1146;RCS1211;CA2007;CA1308;IL2026;IL3050;CA1812</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
</PropertyGroup>
<!-- Import nuget packaging properties -->
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -33,4 +38,7 @@
<Description>Provides Microsoft Agent Framework support for developer UI.</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.DevUI.UnitTests"/>
</ItemGroup>
</Project>
@@ -24,9 +24,15 @@ var builder = WebApplication.CreateBuilder(args);
// Register your agents
builder.AddAIAgent("assistant", "You are a helpful assistant.");
// Register DevUI services
if (builder.Environment.IsDevelopment())
{
builder.AddDevUI();
}
// Register services for OpenAI responses and conversations (also required for DevUI)
builder.Services.AddOpenAIResponses();
builder.Services.AddOpenAIConversations();
builder.AddOpenAIResponses();
builder.AddOpenAIConversations();
var app = builder.Build();
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Extension methods for <see cref="IServiceCollection"/> to configure DevUI.
/// </summary>
public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions
{
/// <summary>
/// Adds services required for DevUI integration.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
public static IServiceCollection AddDevUI(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// a factory that tries to construct an AIAgent from Workflow,
// even if workflow was not explicitly registered as an AIAgent.
#pragma warning disable IDE0001 // Simplify Names
services.AddKeyedSingleton<AIAgent>(KeyedService.AnyKey, (sp, key) =>
{
var keyAsStr = key as string;
Throw.IfNullOrEmpty(keyAsStr);
var workflow = sp.GetKeyedService<Workflow>(keyAsStr);
if (workflow is not null)
{
return workflow.AsAgent(name: workflow.Name);
}
// another thing we can do is resolve a non-keyed workflow.
// however, we can't rely on anything than key to be equal to the workflow.Name.
// so we try: if we fail, we return null.
workflow = sp.GetService<Workflow>();
if (workflow is not null && workflow.Name?.Equals(keyAsStr, StringComparison.Ordinal) == true)
{
return workflow.AsAgent(name: workflow.Name);
}
// and it's possible to lookup at the default-registered AIAgent
// with the condition of same name as the key.
var agent = sp.GetService<AIAgent>();
if (agent is not null && agent.Name?.Equals(keyAsStr, StringComparison.Ordinal) == true)
{
return agent;
}
return null!;
});
#pragma warning restore IDE0001 // Simplify Names
return services;
}
}
@@ -63,7 +63,11 @@ internal sealed class HostedAgentResponseExecutor : IResponseExecutor
return ValueTask.FromResult<ResponseError?>(new ResponseError
{
Code = "agent_not_found",
Message = $"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent()."
Message = $"""
Agent '{agentName}' not found.
Ensure the agent is registered with '{agentName}' name in the dependency injection container.
We recommend using 'builder.AddAIAgent()' for simplicity.
"""
});
}
@@ -1,38 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Provides a catalog of registered AI agents within the hosting environment.
/// </summary>
/// <remarks>
/// The agent catalog allows enumeration of all registered agents in the dependency injection container.
/// This is useful for scenarios where you need to discover and interact with multiple agents programmatically.
/// </remarks>
public abstract class AgentCatalog
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentCatalog"/> class.
/// </summary>
protected AgentCatalog()
{
}
/// <summary>
/// Asynchronously retrieves all registered AI agents from the catalog.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// An asynchronous enumerable of <see cref="AIAgent"/> instances representing all registered agents.
/// The enumeration will only include agents that are successfully resolved from the service provider.
/// </returns>
/// <remarks>
/// This method enumerates through all registered agent names and attempts to resolve each agent
/// from the dependency injection container. Only successfully resolved agents are yielded.
/// The enumeration is lazy and agents are resolved on-demand during iteration.
/// </remarks>
public abstract IAsyncEnumerable<AIAgent> GetAgentsAsync(CancellationToken cancellationToken = default);
}
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Hosting.Local;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
@@ -126,31 +125,9 @@ public static class AgentHostingServiceCollectionExtensions
return agent;
});
// Register the agent by name for discovery.
var agentHostBuilder = GetAgentRegistry(services);
agentHostBuilder.AgentNames.Add(name);
return new HostedAgentBuilder(name, services);
}
private static LocalAgentRegistry GetAgentRegistry(IServiceCollection services)
{
var descriptor = services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalAgentRegistry)));
if (descriptor?.ImplementationInstance is not LocalAgentRegistry instance)
{
instance = new LocalAgentRegistry();
ConfigureHostBuilder(services, instance);
}
return instance;
}
private static void ConfigureHostBuilder(IServiceCollection services, LocalAgentRegistry agentHostBuilderContext)
{
services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext));
services.AddSingleton<AgentCatalog, LocalAgentCatalog>();
}
private static IList<AITool> GetRegisteredToolsForAgent(IServiceProvider serviceProvider, string agentName)
{
var registry = serviceProvider.GetService<LocalAgentToolRegistry>();
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using Microsoft.Agents.AI.Hosting.Local;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -47,28 +45,6 @@ public static class HostApplicationBuilderWorkflowExtensions
return workflow;
});
// Register the workflow by name for discovery.
var workflowRegistry = GetWorkflowRegistry(builder);
workflowRegistry.WorkflowNames.Add(name);
return new HostedWorkflowBuilder(name, builder);
}
private static LocalWorkflowRegistry GetWorkflowRegistry(IHostApplicationBuilder builder)
{
var descriptor = builder.Services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalWorkflowRegistry)));
if (descriptor?.ImplementationInstance is not LocalWorkflowRegistry instance)
{
instance = new LocalWorkflowRegistry();
ConfigureHostBuilder(builder, instance);
}
return instance;
}
private static void ConfigureHostBuilder(IHostApplicationBuilder builder, LocalWorkflowRegistry agentHostBuilderContext)
{
builder.Services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext));
builder.Services.AddSingleton<WorkflowCatalog, LocalWorkflowCatalog>();
}
}
@@ -1,37 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.Local;
// Implementation of an AgentCatalog which enumerates agents registered in the local service provider.
internal sealed class LocalAgentCatalog : AgentCatalog
{
public readonly HashSet<string> _registeredAgents;
private readonly IServiceProvider _serviceProvider;
public LocalAgentCatalog(LocalAgentRegistry agentHostBuilder, IServiceProvider serviceProvider)
{
this._registeredAgents = [.. agentHostBuilder.AgentNames];
this._serviceProvider = serviceProvider;
}
public override async IAsyncEnumerable<AIAgent> GetAgentsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask.ConfigureAwait(false);
foreach (var name in this._registeredAgents)
{
var agent = this._serviceProvider.GetKeyedService<AIAgent>(name);
if (agent is not null)
{
yield return agent;
}
}
}
}
@@ -1,10 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
namespace Microsoft.Agents.AI.Hosting.Local;
internal sealed class LocalAgentRegistry
{
public HashSet<string> AgentNames { get; } = [];
}
@@ -1,37 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.Local;
internal sealed class LocalWorkflowCatalog : WorkflowCatalog
{
public readonly HashSet<string> _registeredWorkflows;
private readonly IServiceProvider _serviceProvider;
public LocalWorkflowCatalog(LocalWorkflowRegistry workflowRegistry, IServiceProvider serviceProvider)
{
this._registeredWorkflows = [.. workflowRegistry.WorkflowNames];
this._serviceProvider = serviceProvider;
}
public override async IAsyncEnumerable<Workflow> GetWorkflowsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask.ConfigureAwait(false);
foreach (var name in this._registeredWorkflows)
{
var workflow = this._serviceProvider.GetKeyedService<Workflow>(name);
if (workflow is not null)
{
yield return workflow;
}
}
}
}
@@ -1,10 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
namespace Microsoft.Agents.AI.Hosting.Local;
internal sealed class LocalWorkflowRegistry
{
public HashSet<string> WorkflowNames { get; } = [];
}
@@ -0,0 +1,222 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.DevUI.UnitTests;
/// <summary>
/// Unit tests for DevUI service collection extensions.
/// Tests verify that workflows and agents can be resolved even when registered non-conventionally.
/// </summary>
public class DevUIExtensionsTests
{
/// <summary>
/// Verifies that AddDevUI throws ArgumentNullException when services collection is null.
/// </summary>
[Fact]
public void AddDevUI_NullServices_ThrowsArgumentNullException()
{
IServiceCollection services = null!;
Assert.Throws<ArgumentNullException>(() => services.AddDevUI());
}
/// <summary>
/// Verifies that GetRequiredKeyedService throws for non-existent keys.
/// </summary>
[Fact]
public void AddDevUI_GetRequiredKeyedServiceNonExistent_ThrowsInvalidOperationException()
{
// Arrange
var services = new ServiceCollection();
services.AddDevUI();
var serviceProvider = services.BuildServiceProvider();
// Act & Assert
Assert.Throws<InvalidOperationException>(() => serviceProvider.GetRequiredKeyedService<AIAgent>("non-existent"));
}
/// <summary>
/// Verifies that an agent with null name can be resolved by its workflow.
/// </summary>
[Fact]
public void AddDevUI_WorkflowWithName_CanBeResolved_AsAIAgent()
{
// Arrange
var services = new ServiceCollection();
var mockChatClient = new Mock<IChatClient>();
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null);
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null);
var workflow = AgentWorkflowBuilder.BuildSequential(agent1, agent2);
services.AddKeyedSingleton("workflow", workflow);
services.AddDevUI();
var serviceProvider = services.BuildServiceProvider();
// Act
var resolvedWorkflowAsAgent = serviceProvider.GetKeyedService<AIAgent>("workflow");
// Assert
Assert.NotNull(resolvedWorkflowAsAgent);
Assert.Null(resolvedWorkflowAsAgent.Name);
}
/// <summary>
/// Verifies that an agent with null name can be resolved by its workflow.
/// </summary>
[Fact]
public void AddDevUI_MultipleWorkflowsWithName_CanBeResolved_AsAIAgent()
{
var services = new ServiceCollection();
var mockChatClient = new Mock<IChatClient>();
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null);
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null);
var workflow1 = AgentWorkflowBuilder.BuildSequential(agent1, agent2);
var workflow2 = AgentWorkflowBuilder.BuildSequential(agent1, agent2);
services.AddKeyedSingleton("workflow1", workflow1);
services.AddKeyedSingleton("workflow2", workflow2);
services.AddDevUI();
var serviceProvider = services.BuildServiceProvider();
var resolvedWorkflow1AsAgent = serviceProvider.GetKeyedService<AIAgent>("workflow1");
Assert.NotNull(resolvedWorkflow1AsAgent);
Assert.Null(resolvedWorkflow1AsAgent.Name);
var resolvedWorkflow2AsAgent = serviceProvider.GetKeyedService<AIAgent>("workflow2");
Assert.NotNull(resolvedWorkflow2AsAgent);
Assert.Null(resolvedWorkflow2AsAgent.Name);
Assert.False(resolvedWorkflow1AsAgent == resolvedWorkflow2AsAgent);
}
/// <summary>
/// Verifies that an agent with null name can be resolved by its workflow.
/// </summary>
[Fact]
public void AddDevUI_NonKeyedWorkflow_CanBeResolved_AsAIAgent()
{
var services = new ServiceCollection();
var mockChatClient = new Mock<IChatClient>();
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null);
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null);
var workflow = AgentWorkflowBuilder.BuildSequential(agent1, agent2);
services.AddKeyedSingleton("workflow", workflow);
services.AddDevUI();
var serviceProvider = services.BuildServiceProvider();
var resolvedWorkflowAsAgent = serviceProvider.GetKeyedService<AIAgent>("workflow");
Assert.NotNull(resolvedWorkflowAsAgent);
Assert.Null(resolvedWorkflowAsAgent.Name);
}
/// <summary>
/// Verifies that an agent with null name can be resolved by its workflow.
/// </summary>
[Fact]
public void AddDevUI_NonKeyedWorkflow_PlusKeyedWorkflow_CanBeResolved_AsAIAgent()
{
var services = new ServiceCollection();
var mockChatClient = new Mock<IChatClient>();
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null);
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null);
var workflow = AgentWorkflowBuilder.BuildSequential("standardname", agent1, agent2);
var keyedWorkflow = AgentWorkflowBuilder.BuildSequential("keyedname", agent1, agent2);
services.AddSingleton(workflow);
services.AddKeyedSingleton("keyed", keyedWorkflow);
services.AddDevUI();
var serviceProvider = services.BuildServiceProvider();
// resolve a workflow with the same name as workflow's name (which is registered without a key)
var standardAgent = serviceProvider.GetKeyedService<AIAgent>("standardname");
Assert.NotNull(standardAgent);
Assert.Equal("standardname", standardAgent.Name);
var keyedAgent = serviceProvider.GetKeyedService<AIAgent>("keyed");
Assert.NotNull(keyedAgent);
Assert.Equal("keyedname", keyedAgent.Name);
var nonExisting = serviceProvider.GetKeyedService<AIAgent>("random-non-existing!!!");
Assert.Null(nonExisting);
}
/// <summary>
/// Verifies that an agent registered with a different key than its name can be resolved by key.
/// </summary>
[Fact]
public void AddDevUI_AgentRegisteredWithDifferentKey_CanBeResolvedByKey()
{
// Arrange
var services = new ServiceCollection();
const string AgentName = "actual-agent-name";
const string RegistrationKey = "different-key";
var mockChatClient = new Mock<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, "Test", AgentName);
services.AddKeyedSingleton<AIAgent>(RegistrationKey, agent);
services.AddDevUI();
var serviceProvider = services.BuildServiceProvider();
// Act
var resolvedAgent = serviceProvider.GetKeyedService<AIAgent>(RegistrationKey);
// Assert
Assert.NotNull(resolvedAgent);
// The resolved agent should have the agent's name, not the registration key
Assert.Equal(AgentName, resolvedAgent.Name);
}
/// <summary>
/// Verifies that an agent registered with a different key than its name can be resolved by key.
/// </summary>
[Fact]
public void AddDevUI_Keyed_AndStandard_BothCanBeResolved()
{
// Arrange
var services = new ServiceCollection();
var mockChatClient = new Mock<IChatClient>();
var defaultAgent = new ChatClientAgent(mockChatClient.Object, "default", "default");
var keyedAgent = new ChatClientAgent(mockChatClient.Object, "keyed", "keyed");
services.AddSingleton<AIAgent>(defaultAgent);
services.AddKeyedSingleton<AIAgent>("keyed-registration", keyedAgent);
services.AddDevUI();
var serviceProvider = services.BuildServiceProvider();
var resolvedKeyedAgent = serviceProvider.GetKeyedService<AIAgent>("keyed-registration");
Assert.NotNull(resolvedKeyedAgent);
Assert.Equal("keyed", resolvedKeyedAgent.Name);
// resolving default agent based on its name, not on the registration-key
var resolvedDefaultAgent = serviceProvider.GetKeyedService<AIAgent>("default");
Assert.NotNull(resolvedDefaultAgent);
Assert.Equal("default", resolvedDefaultAgent.Name);
}
/// <summary>
/// Verifies that the DevUI fallback handler error message includes helpful information.
/// </summary>
[Fact]
public void AddDevUI_InvalidResolution_ErrorMessageIsInformative()
{
// Arrange
var services = new ServiceCollection();
services.AddDevUI();
var serviceProvider = services.BuildServiceProvider();
const string InvalidKey = "invalid-key-name";
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => serviceProvider.GetRequiredKeyedService<AIAgent>(InvalidKey));
}
}
@@ -0,0 +1,285 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.Agents.AI.DevUI.Entities;
using Microsoft.Agents.AI.Workflows;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace Microsoft.Agents.AI.DevUI.UnitTests;
public class DevUIIntegrationTests
{
private sealed class NoOpExecutor(string id) : Executor(id)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<object>(
(msg, ctx) => ctx.SendMessageAsync(msg));
}
[Fact]
public async Task TestServerWithDevUI_ResolvesRequestToWorkflow_ByKeyAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var mockChatClient = new Mock<IChatClient>();
var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name");
builder.Services.AddKeyedSingleton<AIAgent>("registration-key", agent);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
await app.StartAsync();
// Act
var resolvedAgent = app.Services.GetKeyedService<AIAgent>("registration-key");
var client = app.GetTestClient();
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
Assert.NotNull(discoveryResponse);
Assert.Single(discoveryResponse.Entities);
Assert.Equal("agent-name", discoveryResponse.Entities[0].Name);
}
[Fact]
public async Task TestServerWithDevUI_ResolvesMultipleAIAgents_ByKeyAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var mockChatClient = new Mock<IChatClient>();
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test", "agent-one");
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test", "agent-two");
var agent3 = new ChatClientAgent(mockChatClient.Object, "Test", "agent-three");
builder.Services.AddKeyedSingleton<AIAgent>("key-1", agent1);
builder.Services.AddKeyedSingleton<AIAgent>("key-2", agent2);
builder.Services.AddKeyedSingleton<AIAgent>("key-3", agent3);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
await app.StartAsync();
// Act
var client = app.GetTestClient();
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
// Assert
Assert.NotNull(discoveryResponse);
Assert.Equal(3, discoveryResponse.Entities.Count);
Assert.Contains(discoveryResponse.Entities, e => e.Name == "agent-one" && e.Type == "agent");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "agent-two" && e.Type == "agent");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "agent-three" && e.Type == "agent");
}
[Fact]
public async Task TestServerWithDevUI_ResolvesAIAgents_WithKeyedAndDefaultRegistrationAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var mockChatClient = new Mock<IChatClient>();
var agentKeyed1 = new ChatClientAgent(mockChatClient.Object, "Test", "keyed-agent-one");
var agentKeyed2 = new ChatClientAgent(mockChatClient.Object, "Test", "keyed-agent-two");
var agentDefault = new ChatClientAgent(mockChatClient.Object, "Test", "default-agent");
builder.Services.AddKeyedSingleton<AIAgent>("key-1", agentKeyed1);
builder.Services.AddKeyedSingleton<AIAgent>("key-2", agentKeyed2);
builder.Services.AddSingleton<AIAgent>(agentDefault);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
await app.StartAsync();
// Act
var client = app.GetTestClient();
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
// Assert
Assert.NotNull(discoveryResponse);
Assert.Equal(3, discoveryResponse.Entities.Count);
Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-agent-one" && e.Type == "agent");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-agent-two" && e.Type == "agent");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-agent" && e.Type == "agent");
}
[Fact]
public async Task TestServerWithDevUI_ResolvesMultipleWorkflows_ByKeyAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var workflow1 = new WorkflowBuilder("executor-1")
.WithName("workflow-one")
.WithDescription("First workflow")
.BindExecutor(new NoOpExecutor("executor-1"))
.Build();
var workflow2 = new WorkflowBuilder("executor-2")
.WithName("workflow-two")
.WithDescription("Second workflow")
.BindExecutor(new NoOpExecutor("executor-2"))
.Build();
var workflow3 = new WorkflowBuilder("executor-3")
.WithName("workflow-three")
.WithDescription("Third workflow")
.BindExecutor(new NoOpExecutor("executor-3"))
.Build();
builder.Services.AddKeyedSingleton("key-1", workflow1);
builder.Services.AddKeyedSingleton("key-2", workflow2);
builder.Services.AddKeyedSingleton("key-3", workflow3);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
await app.StartAsync();
// Act
var client = app.GetTestClient();
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
// Assert
Assert.NotNull(discoveryResponse);
Assert.Equal(3, discoveryResponse.Entities.Count);
Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-one" && e.Type == "workflow");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-two" && e.Type == "workflow");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-three" && e.Type == "workflow");
}
[Fact]
public async Task TestServerWithDevUI_ResolvesWorkflows_WithKeyedAndDefaultRegistrationAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var workflowKeyed1 = new WorkflowBuilder("executor-1")
.WithName("keyed-workflow-one")
.BindExecutor(new NoOpExecutor("executor-1"))
.Build();
var workflowKeyed2 = new WorkflowBuilder("executor-2")
.WithName("keyed-workflow-two")
.BindExecutor(new NoOpExecutor("executor-2"))
.Build();
var workflowDefault = new WorkflowBuilder("executor-default")
.WithName("default-workflow")
.BindExecutor(new NoOpExecutor("executor-default"))
.Build();
builder.Services.AddKeyedSingleton("key-1", workflowKeyed1);
builder.Services.AddKeyedSingleton("key-2", workflowKeyed2);
builder.Services.AddSingleton(workflowDefault);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
await app.StartAsync();
// Act
var client = app.GetTestClient();
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
// Assert
Assert.NotNull(discoveryResponse);
Assert.Equal(3, discoveryResponse.Entities.Count);
Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-workflow-one" && e.Type == "workflow");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-workflow-two" && e.Type == "workflow");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-workflow" && e.Type == "workflow");
}
[Fact]
public async Task TestServerWithDevUI_ResolvesMixedAgentsAndWorkflows_AllRegistrationsAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
var mockChatClient = new Mock<IChatClient>();
// Create AIAgents
var agent1 = new ChatClientAgent(mockChatClient.Object, "Test", "mixed-agent-one");
var agent2 = new ChatClientAgent(mockChatClient.Object, "Test", "mixed-agent-two");
var agentDefault = new ChatClientAgent(mockChatClient.Object, "Test", "default-mixed-agent");
// Create Workflows
var workflow1 = new WorkflowBuilder("executor-1")
.WithName("mixed-workflow-one")
.BindExecutor(new NoOpExecutor("executor-1"))
.Build();
var workflow2 = new WorkflowBuilder("executor-2")
.WithName("mixed-workflow-two")
.BindExecutor(new NoOpExecutor("executor-2"))
.Build();
var workflowDefault = new WorkflowBuilder("executor-default")
.WithName("default-mixed-workflow")
.BindExecutor(new NoOpExecutor("executor-default"))
.Build();
// Register all
builder.Services.AddKeyedSingleton<AIAgent>("agent-key-1", agent1);
builder.Services.AddKeyedSingleton<AIAgent>("agent-key-2", agent2);
builder.Services.AddSingleton<AIAgent>(agentDefault);
builder.Services.AddKeyedSingleton("workflow-key-1", workflow1);
builder.Services.AddKeyedSingleton("workflow-key-2", workflow2);
builder.Services.AddSingleton(workflowDefault);
builder.Services.AddDevUI();
using WebApplication app = builder.Build();
app.MapDevUI();
await app.StartAsync();
// Act
var client = app.GetTestClient();
var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative));
var discoveryResponse = await response.Content.ReadFromJsonAsync<DiscoveryResponse>();
// Assert
Assert.NotNull(discoveryResponse);
Assert.Equal(6, discoveryResponse.Entities.Count);
// Verify agents
Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-agent-one" && e.Type == "agent");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-agent-two" && e.Type == "agent");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-mixed-agent" && e.Type == "agent");
// Verify workflows
Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-workflow-one" && e.Type == "workflow");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-workflow-two" && e.Type == "workflow");
Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-mixed-workflow" && e.Type == "workflow");
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugCoreTargetFrameworks)</TargetFrameworks>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);CA1812</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.TestHost" VersionOverride="8.0.21" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' != 'net8.0'" />
<PackageReference Include="OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.DevUI\Microsoft.Agents.AI.DevUI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
{
"profiles": {
"Microsoft.Agents.AI.DevUI.UnitTests": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:63009;http://localhost:63010"
}
}
}
@@ -0,0 +1,12 @@
{
"profiles": {
"Microsoft.Agents.AI.Hosting.A2A.UnitTests": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:52186;http://localhost:52187"
}
}
}
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
@@ -15,10 +16,18 @@ internal sealed class MockAgentProvider : Mock<WorkflowAgentProvider>
{
public IList<string> ExistingConversationIds { get; } = [];
public ChatMessage? TestChatMessage { get; set; }
public MockAgentProvider()
{
this.Setup(provider => provider.CreateConversationAsync(It.IsAny<CancellationToken>()))
.Returns(() => Task.FromResult(this.CreateConversationId()));
this.Setup(provider => provider.GetMessageAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(this.CreateChatMessage()));
}
private string CreateConversationId()
@@ -28,4 +37,13 @@ internal sealed class MockAgentProvider : Mock<WorkflowAgentProvider>
return newConversationId;
}
private ChatMessage CreateChatMessage()
{
this.TestChatMessage = new ChatMessage(ChatRole.User, Guid.NewGuid().ToString("N"))
{
MessageId = Guid.NewGuid().ToString("N"),
};
return this.TestChatMessage;
}
}
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Bot.ObjectModel;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="RetrieveConversationMessageExecutor"/>.
/// </summary>
public sealed class RetrieveConversationMessageExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task RetrieveMessageSuccessfullyAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(nameof(RetrieveMessageSuccessfullyAsync),
"TestMessage");
}
private async Task ExecuteTestAsync(
string displayName,
string variableName)
{
// Arrange
MockAgentProvider mockAgentProvider = new();
RetrieveConversationMessage model = this.CreateModel(
this.FormatDisplayName(displayName),
FormatVariablePath(variableName),
"TestConversationId",
"DefaultMessageId");
RetrieveConversationMessageExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
ChatMessage testMessage = mockAgentProvider.TestChatMessage ?? new ChatMessage();
VerifyModel(model, action);
this.VerifyState(variableName, testMessage.ToRecord());
}
private RetrieveConversationMessage CreateModel(
string displayName,
string messageVariable,
string conversationId,
string messageId)
{
RetrieveConversationMessage.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Message = PropertyPath.Create(messageVariable),
ConversationId = StringExpression.Literal(conversationId),
MessageId = StringExpression.Literal(messageId)
};
return AssignParent<RetrieveConversationMessage>(actionBuilder);
}
}
+1
View File
@@ -59,6 +59,7 @@
"OPENAI",
"opentelemetry",
"OTEL",
"powerfx",
"protos",
"pydantic",
"pytestmark",
+8
View File
@@ -3,6 +3,14 @@ AZURE_AI_PROJECT_ENDPOINT=""
AZURE_AI_MODEL_DEPLOYMENT_NAME=""
# Bing connection for web search (optional, used by samples with web search)
BING_CONNECTION_ID=""
# Azure AI Search (optional, used by AzureAISearchContextProvider samples)
AZURE_SEARCH_ENDPOINT=""
AZURE_SEARCH_API_KEY=""
AZURE_SEARCH_INDEX_NAME=""
AZURE_SEARCH_SEMANTIC_CONFIG=""
AZURE_SEARCH_KNOWLEDGE_BASE_NAME=""
# Note: For agentic mode Knowledge Bases, also set AZURE_OPENAI_ENDPOINT below
# (different from AZURE_AI_PROJECT_ENDPOINT - Knowledge Base needs OpenAI endpoint for model calls)
# OpenAI
OPENAI_API_KEY=""
OPENAI_CHAT_MODEL_ID=""
+33 -1
View File
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0b251120] - 2025-11-20
### Added
- **agent-framework-core**: Introducing support for declarative YAML spec ([#2002](https://github.com/microsoft/agent-framework/pull/2002))
- **agent-framework-core**: Use AI Foundry evaluators for self-reflection ([#2250](https://github.com/microsoft/agent-framework/pull/2250))
- **agent-framework-core**: Propagate `as_tool()` kwargs and add runtime context + middleware sample ([#2311](https://github.com/microsoft/agent-framework/pull/2311))
- **agent-framework-anthropic**: Anthropic Foundry integration ([#2302](https://github.com/microsoft/agent-framework/pull/2302))
- **samples**: M365 Agent SDK Hosting sample ([#2292](https://github.com/microsoft/agent-framework/pull/2292))
- **samples**: Foundry Sample for A2A + SharePoint Samples ([#2313](https://github.com/microsoft/agent-framework/pull/2313))
### Changed
- **agent-framework-azurefunctions**: [BREAKING] Schema changes for Azure Functions package ([#2151](https://github.com/microsoft/agent-framework/pull/2151))
- **agent-framework-core**: Move evaluation folders under `evaluations` ([#2355](https://github.com/microsoft/agent-framework/pull/2355))
- **agent-framework-core**: Move red teaming files to their own folder ([#2333](https://github.com/microsoft/agent-framework/pull/2333))
- **agent-framework-core**: "fix all" task now single source of truth ([#2303](https://github.com/microsoft/agent-framework/pull/2303))
- **agent-framework-core**: Improve and clean up exception handling ([#2337](https://github.com/microsoft/agent-framework/pull/2337), [#2319](https://github.com/microsoft/agent-framework/pull/2319))
- **agent-framework-core**: Clean up imports ([#2318](https://github.com/microsoft/agent-framework/pull/2318))
### Fixed
- **agent-framework-azure-ai**: Fix for Azure AI client ([#2358](https://github.com/microsoft/agent-framework/pull/2358))
- **agent-framework-core**: Fix tool execution bleed-over in aiohttp/Bot Framework scenarios ([#2314](https://github.com/microsoft/agent-framework/pull/2314))
- **agent-framework-core**: `@ai_function` now correctly handles `self` parameter ([#2266](https://github.com/microsoft/agent-framework/pull/2266))
- **agent-framework-core**: Resolve string annotations in `FunctionExecutor` ([#2308](https://github.com/microsoft/agent-framework/pull/2308))
- **agent-framework-core**: Langfuse observability captures ChatAgent system instructions ([#2316](https://github.com/microsoft/agent-framework/pull/2316))
- **agent-framework-core**: Incomplete URL substring sanitization fix ([#2274](https://github.com/microsoft/agent-framework/pull/2274))
- **observability**: Handle datetime serialization in tool results ([#2248](https://github.com/microsoft/agent-framework/pull/2248))
## [1.0.0b251117] - 2025-11-17
### Fixed
@@ -260,7 +290,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251114...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251120...HEAD
[1.0.0b251120]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251117...python-1.0.0b251120
[1.0.0b251117]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251114...python-1.0.0b251117
[1.0.0b251114]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251112.post1...python-1.0.0b251114
[1.0.0b251112.post1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251112...python-1.0.0b251112.post1
[1.0.0b251112]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251111...python-1.0.0b251112
+1 -1
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251114"
version = "1.0.0b251120"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+2 -2
View File
@@ -16,7 +16,7 @@ pip install agent-framework-ag-ui
from fastapi import FastAPI
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
# Create your agent
agent = ChatAgent(
@@ -41,7 +41,7 @@ add_agent_framework_fastapi_endpoint(app, agent, "/")
```python
import asyncio
from agent_framework import TextContent
from agent_framework_ag_ui import AGUIChatClient
from agent_framework.ag_ui import AGUIChatClient
async def main():
async with AGUIChatClient(endpoint="http://localhost:8000/") as client:
@@ -91,4 +91,4 @@ def add_agent_framework_fastapi_endpoint(
)
except Exception as e:
logger.error(f"Error in agent endpoint: {e}", exc_info=True)
return {"error": str(e)}
return {"error": "An internal error has occurred."}
@@ -18,7 +18,7 @@ All example agents are factory functions that accept any `ChatClientProtocol`-co
from fastapi import FastAPI
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatClient
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework_ag_ui_examples.agents import simple_agent, weather_agent
app = FastAPI()
@@ -40,7 +40,7 @@ add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weathe
from fastapi import FastAPI
from agent_framework import ChatAgent
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
# Create your agent
agent = ChatAgent(
@@ -136,7 +136,7 @@ The server exposes endpoints at:
```python
from fastapi import FastAPI
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework_ag_ui_examples.agents import (
simple_agent,
weather_agent,
@@ -188,8 +188,8 @@ You can create your own agent factories following the same pattern as the exampl
```python
from agent_framework import ChatAgent, ai_function
from agent_framework._clients import ChatClientProtocol
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework import ChatClientProtocol
from agent_framework.ag_ui import AgentFrameworkAgent
@ai_function
def my_tool(param: str) -> str:
@@ -2,10 +2,8 @@
"""Example agent demonstrating predictive state updates with document writing."""
from agent_framework import ChatAgent, ai_function
from agent_framework._clients import ChatClientProtocol
from agent_framework_ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
from agent_framework.ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy
@ai_function
@@ -4,8 +4,7 @@
from enum import Enum
from agent_framework import ChatAgent, ai_function
from agent_framework._clients import ChatClientProtocol
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
from pydantic import BaseModel, Field
@@ -4,12 +4,10 @@
from enum import Enum
from agent_framework import ChatAgent, ai_function
from agent_framework._clients import ChatClientProtocol
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
from agent_framework.ag_ui import AgentFrameworkAgent, RecipeConfirmationStrategy
from pydantic import BaseModel, Field
from agent_framework_ag_ui import AgentFrameworkAgent, RecipeConfirmationStrategy
class SkillLevel(str, Enum):
"""The skill level required for the recipe."""
@@ -4,10 +4,8 @@
import asyncio
from agent_framework import ChatAgent, ai_function
from agent_framework._clients import ChatClientProtocol
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
from agent_framework.ag_ui import AgentFrameworkAgent
@ai_function
@@ -2,8 +2,7 @@
"""Simple agentic chat example (Feature 1: Agentic Chat)."""
from agent_framework import ChatAgent
from agent_framework._clients import ChatClientProtocol
from agent_framework import ChatAgent, ChatClientProtocol
def simple_agent(chat_client: ChatClientProtocol) -> ChatAgent:
@@ -2,10 +2,8 @@
"""Example agent demonstrating human-in-the-loop with function approvals."""
from agent_framework import ChatAgent, ai_function
from agent_framework._clients import ChatClientProtocol
from agent_framework_ag_ui import AgentFrameworkAgent, TaskPlannerConfirmationStrategy
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
from agent_framework.ag_ui import AgentFrameworkAgent, TaskPlannerConfirmationStrategy
@ai_function(approval_mode="always_require")
@@ -18,12 +18,10 @@ from ag_ui.core import (
TextMessageStartEvent,
ToolCallStartEvent,
)
from agent_framework import ChatAgent, ai_function
from agent_framework._clients import ChatClientProtocol
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
from agent_framework.ag_ui import AgentFrameworkAgent
from pydantic import BaseModel, Field
from agent_framework_ag_ui import AgentFrameworkAgent
class StepStatus(str, Enum):
"""Status of a task step."""
@@ -4,10 +4,8 @@
from typing import Any
from agent_framework import AIFunction, ChatAgent
from agent_framework._clients import ChatClientProtocol
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework import AIFunction, ChatAgent, ChatClientProtocol
from agent_framework.ag_ui import AgentFrameworkAgent
# Declaration-only tools (func=None) - actual rendering happens on the client side
generate_haiku = AIFunction[Any, str](
@@ -4,8 +4,7 @@
from typing import Any
from agent_framework import ChatAgent, ai_function
from agent_framework._clients import ChatClientProtocol
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
@ai_function
@@ -2,11 +2,10 @@
"""Backend tool rendering endpoint."""
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.azure import AzureOpenAIChatClient
from fastapi import FastAPI
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from ...agents.weather_agent import weather_agent
@@ -6,12 +6,11 @@ import logging
import os
import uvicorn
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
from agent_framework.azure import AzureOpenAIChatClient
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from ..agents.document_writer_agent import document_writer_agent
from ..agents.human_in_the_loop_agent import human_in_the_loop_agent
from ..agents.recipe_agent import recipe_agent
@@ -10,7 +10,7 @@ standard chat interface.
import asyncio
import os
from agent_framework_ag_ui import AGUIChatClient
from agent_framework.ag_ui import AGUIChatClient
async def main():
@@ -13,8 +13,7 @@ import asyncio
import os
from agent_framework import ai_function
from agent_framework_ag_ui import AGUIChatClient
from agent_framework.ag_ui import AGUIChatClient
@ai_function
@@ -23,8 +23,7 @@ import logging
import os
from agent_framework import ChatAgent, FunctionCallContent, FunctionResultContent, TextContent, ai_function
from agent_framework_ag_ui import AGUIChatClient
from agent_framework.ag_ui import AGUIChatClient
# Enable debug logging
logging.basicConfig(
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b251117"
version = "1.0.0b251120"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
-1
View File
@@ -1 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -11,7 +11,7 @@ from agent_framework._types import ChatResponseUpdate
async def test_agent_initialization_basic():
"""Test basic agent initialization without state schema."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -28,7 +28,7 @@ async def test_agent_initialization_basic():
async def test_agent_initialization_with_state_schema():
"""Test agent initialization with state_schema."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -43,7 +43,7 @@ async def test_agent_initialization_with_state_schema():
async def test_agent_initialization_with_predict_state_config():
"""Test agent initialization with predict_state_config."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -58,7 +58,7 @@ async def test_agent_initialization_with_predict_state_config():
async def test_run_started_event_emission():
"""Test RunStartedEvent is emitted at start of run."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -81,7 +81,7 @@ async def test_run_started_event_emission():
async def test_predict_state_custom_event_emission():
"""Test PredictState CustomEvent is emitted when predict_state_config is present."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -112,7 +112,7 @@ async def test_predict_state_custom_event_emission():
async def test_initial_state_snapshot_with_schema():
"""Test initial StateSnapshotEvent emission when state_schema present."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -141,7 +141,7 @@ async def test_initial_state_snapshot_with_schema():
async def test_state_initialization_object_type():
"""Test state initialization with object type in schema."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -167,7 +167,7 @@ async def test_state_initialization_object_type():
async def test_state_initialization_array_type():
"""Test state initialization with array type in schema."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -193,7 +193,7 @@ async def test_state_initialization_array_type():
async def test_run_finished_event_emission():
"""Test RunFinishedEvent is emitted at end of run."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -214,7 +214,7 @@ async def test_run_finished_event_emission():
async def test_tool_result_confirm_changes_accepted():
"""Test confirm_changes tool result handling when accepted."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -260,7 +260,7 @@ async def test_tool_result_confirm_changes_accepted():
async def test_tool_result_confirm_changes_rejected():
"""Test confirm_changes tool result handling when rejected."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -293,7 +293,7 @@ async def test_tool_result_confirm_changes_rejected():
async def test_tool_result_function_approval_accepted():
"""Test function approval tool result when steps are accepted."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -338,7 +338,7 @@ async def test_tool_result_function_approval_accepted():
async def test_tool_result_function_approval_rejected():
"""Test function approval tool result when rejected."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -374,7 +374,7 @@ async def test_tool_result_function_approval_rejected():
async def test_thread_metadata_tracking():
"""Test that thread metadata includes ag_ui_thread_id and ag_ui_run_id."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
thread_metadata = {}
@@ -405,7 +405,7 @@ async def test_thread_metadata_tracking():
async def test_state_context_injection():
"""Test that current state is injected into thread metadata."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
thread_metadata = {}
@@ -436,7 +436,7 @@ async def test_state_context_injection():
async def test_no_messages_provided():
"""Test handling when no messages are provided."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -459,7 +459,7 @@ async def test_no_messages_provided():
async def test_message_end_event_emission():
"""Test TextMessageEndEvent is emitted for assistant messages."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -486,7 +486,7 @@ async def test_message_end_event_emission():
async def test_error_handling_with_exception():
"""Test that exceptions during agent execution are re-raised."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class FailingChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -506,7 +506,7 @@ async def test_error_handling_with_exception():
async def test_json_decode_error_in_tool_result():
"""Test handling of orphaned tool result - should be sanitized out."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -543,7 +543,7 @@ async def test_json_decode_error_in_tool_result():
async def test_suppressed_summary_with_document_state():
"""Test suppressed summary uses document state for confirmation message."""
from agent_framework_ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy
from agent_framework.ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
+1 -1
View File
@@ -154,7 +154,7 @@ async def test_endpoint_error_handling():
assert response.status_code == 200
content = json.loads(response.content)
assert "error" in content
assert "Expecting value" in content["error"]
assert content["error"] == "An internal error has occurred."
async def test_endpoint_multiple_paths():
@@ -32,7 +32,7 @@ class GenericOutput(BaseModel):
async def test_structured_output_with_recipe():
"""Test structured output processing with recipe state."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -70,7 +70,7 @@ async def test_structured_output_with_recipe():
async def test_structured_output_with_steps():
"""Test structured output processing with steps state."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -109,7 +109,7 @@ async def test_structured_output_with_steps():
async def test_structured_output_with_no_schema_match():
"""Test structured output when response fields don't match state_schema keys."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -138,7 +138,7 @@ async def test_structured_output_with_no_schema_match():
async def test_structured_output_without_schema():
"""Test structured output without state_schema treats all fields as state."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class DataOutput(BaseModel):
"""Output with data and info fields."""
@@ -175,7 +175,7 @@ async def test_structured_output_without_schema():
async def test_no_structured_output_when_no_response_format():
"""Test that structured output path is skipped when no response_format."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -200,7 +200,7 @@ async def test_no_structured_output_when_no_response_format():
async def test_structured_output_with_message_field():
"""Test structured output that includes a message field."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
@@ -234,7 +234,7 @@ async def test_structured_output_with_message_field():
async def test_empty_updates_no_structured_processing():
"""Test that empty updates don't trigger structured output processing."""
from agent_framework_ag_ui import AgentFrameworkAgent
from agent_framework.ag_ui import AgentFrameworkAgent
class MockChatClient:
async def get_streaming_response(self, messages, chat_options, **kwargs):
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+23
View File
@@ -0,0 +1,23 @@
# Get Started with Microsoft Agent Framework Azure AI Search
Please install this package via pip:
```bash
pip install agent-framework-aisearch --pre
```
## Azure AI Search Integration
The Azure AI Search integration provides context providers for RAG (Retrieval Augmented Generation) capabilities with two modes:
- **Semantic Mode**: Fast hybrid search (vector + keyword) with semantic ranking
- **Agentic Mode**: Multi-hop reasoning using Knowledge Bases for complex queries
### Basic Usage Example
See the [Azure AI Search context provider examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/azure_ai/) which demonstrate:
- Semantic search with hybrid (vector + keyword) queries
- Agentic mode with Knowledge Bases for complex multi-hop reasoning
- Environment variable configuration with Settings class
- API key and managed identity authentication
@@ -0,0 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._search_provider import AzureAISearchContextProvider, AzureAISearchSettings
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"AzureAISearchContextProvider",
"AzureAISearchSettings",
"__version__",
]
@@ -0,0 +1,914 @@
# Copyright (c) Microsoft. All rights reserved.
"""Azure AI Search Context Provider for Agent Framework.
This module provides context providers for Azure AI Search integration with two modes:
- Agentic: Recommended for most scenarios. Uses Knowledge Bases for query planning and
multi-hop reasoning. Slightly slower with more token consumption, but more accurate.
- Semantic: Fast hybrid search (vector + keyword) with semantic ranker. Best for simple
queries where speed is critical.
See: https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720
"""
import sys
from collections.abc import Awaitable, Callable, MutableSequence
from typing import TYPE_CHECKING, Any, ClassVar, Literal
from agent_framework import ChatMessage, Context, ContextProvider, Role
from agent_framework._logging import get_logger
from agent_framework._pydantic import AFBaseSettings
from agent_framework.exceptions import ServiceInitializationError
from azure.core.credentials import AzureKeyCredential
from azure.core.credentials_async import AsyncTokenCredential
from azure.core.exceptions import ResourceNotFoundError
from azure.search.documents.aio import SearchClient
from azure.search.documents.indexes.aio import SearchIndexClient
from azure.search.documents.indexes.models import (
AzureOpenAIVectorizerParameters,
KnowledgeBase,
KnowledgeBaseAzureOpenAIModel,
KnowledgeRetrievalLowReasoningEffort,
KnowledgeRetrievalMediumReasoningEffort,
KnowledgeRetrievalMinimalReasoningEffort,
KnowledgeRetrievalOutputMode,
KnowledgeRetrievalReasoningEffort,
KnowledgeSourceReference,
SearchIndexKnowledgeSource,
SearchIndexKnowledgeSourceParameters,
)
from azure.search.documents.models import (
QueryCaptionType,
QueryType,
VectorizableTextQuery,
VectorizedQuery,
)
from pydantic import SecretStr, ValidationError
# Type checking imports for optional agentic mode dependencies
if TYPE_CHECKING:
from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseMessage,
KnowledgeBaseMessageTextContent,
KnowledgeBaseRetrievalRequest,
KnowledgeRetrievalIntent,
KnowledgeRetrievalSemanticIntent,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalOutputMode as KBRetrievalOutputMode,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort,
)
# Runtime imports for agentic mode (optional dependency)
try:
from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient
from azure.search.documents.knowledgebases.models import (
KnowledgeBaseMessage,
KnowledgeBaseMessageTextContent,
KnowledgeBaseRetrievalRequest,
KnowledgeRetrievalIntent,
KnowledgeRetrievalSemanticIntent,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalLowReasoningEffort as KBRetrievalLowReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalMediumReasoningEffort as KBRetrievalMediumReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalMinimalReasoningEffort as KBRetrievalMinimalReasoningEffort,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalOutputMode as KBRetrievalOutputMode,
)
from azure.search.documents.knowledgebases.models import (
KnowledgeRetrievalReasoningEffort as KBRetrievalReasoningEffort,
)
_agentic_retrieval_available = True
except ImportError:
_agentic_retrieval_available = False
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
# Module-level constants
logger = get_logger("agent_framework.azure")
_DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10
class AzureAISearchSettings(AFBaseSettings):
"""Settings for Azure AI Search Context Provider with auto-loading from environment.
The settings are first loaded from environment variables with the prefix 'AZURE_SEARCH_'.
If the environment variables are not found, the settings can be loaded from a .env file.
Keyword Args:
endpoint: Azure AI Search endpoint URL.
Can be set via environment variable AZURE_SEARCH_ENDPOINT.
index_name: Name of the search index.
Can be set via environment variable AZURE_SEARCH_INDEX_NAME.
api_key: API key for authentication (optional, use managed identity if not provided).
Can be set via environment variable AZURE_SEARCH_API_KEY.
env_file_path: If provided, the .env settings are read from this file path location.
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
Examples:
.. code-block:: python
from agent_framework_aisearch import AzureAISearchSettings
# Using environment variables
# Set AZURE_SEARCH_ENDPOINT=https://mysearch.search.windows.net
# Set AZURE_SEARCH_INDEX_NAME=my-index
settings = AzureAISearchSettings()
# Or passing parameters directly
settings = AzureAISearchSettings(
endpoint="https://mysearch.search.windows.net",
index_name="my-index",
)
# Or loading from a .env file
settings = AzureAISearchSettings(env_file_path="path/to/.env")
"""
env_prefix: ClassVar[str] = "AZURE_SEARCH_"
endpoint: str | None = None
index_name: str | None = None
api_key: SecretStr | None = None
class AzureAISearchContextProvider(ContextProvider):
"""Azure AI Search Context Provider with hybrid search and semantic ranking.
This provider retrieves relevant documents from Azure AI Search to provide context
to the AI agent. It supports two modes:
- **agentic**: Recommended for most scenarios. Uses Knowledge Bases for query planning
and multi-hop reasoning. Slightly slower with more token consumption, but provides
more accurate results (up to 36% improvement in response relevance).
- **semantic** (default): Fast hybrid search combining vector and keyword search
with semantic reranking. Best for simple queries where speed is critical.
Examples:
Using environment variables (recommended):
.. code-block:: python
from agent_framework_aisearch import AzureAISearchContextProvider
from azure.identity.aio import DefaultAzureCredential
# Set AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_INDEX_NAME in environment
search_provider = AzureAISearchContextProvider(credential=DefaultAzureCredential())
Semantic hybrid search with API key:
.. code-block:: python
# Direct API key string
search_provider = AzureAISearchContextProvider(
endpoint="https://mysearch.search.windows.net",
index_name="my-index",
api_key="my-api-key",
mode="semantic",
)
Loading from .env file:
.. code-block:: python
# Load settings from a .env file
search_provider = AzureAISearchContextProvider(
credential=DefaultAzureCredential(), env_file_path="path/to/.env"
)
Agentic retrieval for complex queries:
.. code-block:: python
# Use agentic mode for multi-hop reasoning
# Note: azure_openai_resource_url is the OpenAI endpoint for Knowledge Base model calls,
# which is different from azure_ai_project_endpoint (the AI Foundry project endpoint)
search_provider = AzureAISearchContextProvider(
endpoint="https://mysearch.search.windows.net",
index_name="my-index",
credential=DefaultAzureCredential(),
mode="agentic",
azure_openai_resource_url="https://myresource.openai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="my-knowledge-base",
)
"""
_DEFAULT_SEARCH_CONTEXT_PROMPT = "Use the following context to answer the question:"
def __init__(
self,
endpoint: str | None = None,
index_name: str | None = None,
api_key: str | AzureKeyCredential | None = None,
credential: AsyncTokenCredential | None = None,
*,
mode: Literal["semantic", "agentic"] = "semantic",
top_k: int = 5,
semantic_configuration_name: str | None = None,
vector_field_name: str | None = None,
embedding_function: Callable[[str], Awaitable[list[float]]] | None = None,
context_prompt: str | None = None,
# Agentic mode parameters (Knowledge Base)
azure_ai_project_endpoint: str | None = None,
azure_openai_resource_url: str | None = None,
model_deployment_name: str | None = None,
model_name: str | None = None,
knowledge_base_name: str | None = None,
retrieval_instructions: str | None = None,
azure_openai_api_key: str | None = None,
knowledge_base_output_mode: Literal["extractive_data", "answer_synthesis"] = "extractive_data",
retrieval_reasoning_effort: Literal["minimal", "medium", "low"] = "minimal",
agentic_message_history_count: int = _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize Azure AI Search Context Provider.
Args:
endpoint: Azure AI Search endpoint URL.
Can also be set via environment variable AZURE_SEARCH_ENDPOINT.
index_name: Name of the search index to query.
Can also be set via environment variable AZURE_SEARCH_INDEX_NAME.
api_key: API key for authentication (string or AzureKeyCredential).
Can also be set via environment variable AZURE_SEARCH_API_KEY.
credential: AsyncTokenCredential for managed identity authentication.
Use this for Entra ID authentication instead of api_key.
mode: Search mode - "semantic" for hybrid search with semantic ranking (fast)
or "agentic" for multi-hop reasoning (slower). Default: "semantic".
top_k: Maximum number of documents to retrieve. Only applies to semantic mode.
In agentic mode, the server-side Knowledge Base determines retrieval based on
query complexity and reasoning effort. Default: 5.
semantic_configuration_name: Name of semantic configuration in the index.
Required for semantic ranking. If None, uses index default.
vector_field_name: Name of the vector field in the index for hybrid search.
Required if using vector search. Default: None (keyword search only).
embedding_function: Async function to generate embeddings for vector search.
Signature: async def embed(text: str) -> list[float]
Required if vector_field_name is specified and no server-side vectorization.
context_prompt: Custom prompt to prepend to retrieved context.
Default: "Use the following context to answer the question:"
azure_ai_project_endpoint: Azure AI Foundry project endpoint URL.
This is NOT the same as azure_openai_resource_url - the project endpoint is used
for Azure AI Foundry services, while the OpenAI endpoint is used by the Knowledge
Base to call the model for query planning. Required for agentic mode.
Example: "https://myproject.services.ai.azure.com/api/projects/myproject"
azure_openai_resource_url: Azure OpenAI resource URL for Knowledge Base model calls.
This is the OpenAI endpoint used by the Knowledge Base to call the LLM for
query planning and reasoning. This is separate from the project endpoint because
the Knowledge Base directly calls Azure OpenAI for its internal operations.
Required for agentic mode. Example: "https://myresource.openai.azure.com"
model_deployment_name: Model deployment name in Azure OpenAI for Knowledge Base.
This is the deployment name the Knowledge Base uses to call the LLM.
Required for agentic mode.
model_name: The underlying model name (e.g., "gpt-4o", "gpt-4o-mini").
If not provided, defaults to model_deployment_name. Used for Knowledge Base configuration.
knowledge_base_name: Name for the Knowledge Base. Required for agentic mode.
retrieval_instructions: Custom instructions for the Knowledge Base's
retrieval planning. Only used in agentic mode.
azure_openai_api_key: Azure OpenAI API key for Knowledge Base to call the model.
Only needed when using API key authentication instead of managed identity.
knowledge_base_output_mode: Output mode for Knowledge Base retrieval. Only used in agentic mode.
"extractive_data": Returns raw chunks without synthesis (default, recommended for agent integration).
"answer_synthesis": Returns synthesized answer from the LLM.
Some knowledge sources require answer_synthesis mode. Default: "extractive_data".
retrieval_reasoning_effort: Reasoning effort for Knowledge Base query planning. Only used in agentic mode.
"minimal": Fastest, basic query planning.
"medium": Moderate reasoning with some query decomposition.
"low": Lower reasoning effort than medium.
Default: "minimal".
agentic_message_history_count: Number of recent messages from conversation history to send to
the Knowledge Base. This context helps with query planning in agentic mode, allowing the
Knowledge Base to understand the conversation flow and generate better retrieval queries.
There is no technical limit - adjust based on your use case. Default: 10.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
Examples:
.. code-block:: python
from agent_framework_aisearch import AzureAISearchContextProvider
from azure.identity.aio import DefaultAzureCredential
# Using environment variables
# Set AZURE_SEARCH_ENDPOINT=https://mysearch.search.windows.net
# Set AZURE_SEARCH_INDEX_NAME=my-index
credential = DefaultAzureCredential()
provider = AzureAISearchContextProvider(credential=credential)
# Or passing parameters directly
provider = AzureAISearchContextProvider(
endpoint="https://mysearch.search.windows.net",
index_name="my-index",
credential=credential,
)
# Or loading from a .env file
provider = AzureAISearchContextProvider(credential=credential, env_file_path="path/to/.env")
"""
# Load settings from environment/file
try:
settings = AzureAISearchSettings(
endpoint=endpoint,
index_name=index_name,
api_key=api_key if isinstance(api_key, str) else None,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create Azure AI Search settings.", ex) from ex
# Validate required parameters
if not settings.endpoint:
raise ServiceInitializationError(
"Azure AI Search endpoint is required. Set via 'endpoint' parameter "
"or 'AZURE_SEARCH_ENDPOINT' environment variable."
)
if not settings.index_name:
raise ServiceInitializationError(
"Azure AI Search index name is required. Set via 'index_name' parameter "
"or 'AZURE_SEARCH_INDEX_NAME' environment variable."
)
# Determine the credential to use
resolved_credential: AzureKeyCredential | AsyncTokenCredential
if credential:
# AsyncTokenCredential takes precedence
resolved_credential = credential
elif isinstance(api_key, AzureKeyCredential):
resolved_credential = api_key
elif settings.api_key:
resolved_credential = AzureKeyCredential(settings.api_key.get_secret_value())
else:
raise ServiceInitializationError(
"Azure credential is required. Provide 'api_key' or 'credential' parameter "
"or set 'AZURE_SEARCH_API_KEY' environment variable."
)
self.endpoint = settings.endpoint
self.index_name = settings.index_name
self.credential = resolved_credential
self.mode = mode
self.top_k = top_k
self.semantic_configuration_name = semantic_configuration_name
self.vector_field_name = vector_field_name
self.embedding_function = embedding_function
self.context_prompt = context_prompt or self._DEFAULT_SEARCH_CONTEXT_PROMPT
# Agentic mode parameters (Knowledge Base)
self.azure_openai_resource_url = azure_openai_resource_url
self.azure_openai_deployment_name = model_deployment_name
# If model_name not provided, default to deployment name
self.model_name = model_name or model_deployment_name
self.knowledge_base_name = knowledge_base_name
self.retrieval_instructions = retrieval_instructions
self.azure_openai_api_key = azure_openai_api_key
self.azure_ai_project_endpoint = azure_ai_project_endpoint
self.knowledge_base_output_mode = knowledge_base_output_mode
self.retrieval_reasoning_effort = retrieval_reasoning_effort
self.agentic_message_history_count = agentic_message_history_count
# Auto-discover vector field if not specified
self._auto_discovered_vector_field = False
self._use_vectorizable_query = False # Will be set to True if server-side vectorization detected
if not vector_field_name and mode == "semantic":
# Attempt to auto-discover vector field from index schema
# This will be done lazily on first search to avoid blocking initialization
pass
# Validation
if vector_field_name and not embedding_function:
raise ValueError("embedding_function is required when vector_field_name is specified")
if mode == "agentic":
if not _agentic_retrieval_available:
raise ImportError(
"Agentic retrieval requires azure-search-documents >= 11.7.0b1 with Knowledge Base support. "
"Please upgrade: pip install azure-search-documents>=11.7.0b1"
)
if not self.azure_openai_resource_url:
raise ValueError(
"azure_openai_resource_url is required for agentic mode. "
"This should be your Azure OpenAI endpoint (e.g., 'https://myresource.openai.azure.com')"
)
if not self.azure_openai_deployment_name:
raise ValueError("model_deployment_name is required for agentic mode")
if not knowledge_base_name:
raise ValueError("knowledge_base_name is required for agentic mode")
# Create search client for semantic mode
self._search_client = SearchClient(
endpoint=self.endpoint,
index_name=self.index_name,
credential=self.credential,
)
# Create index client and retrieval client for agentic mode (Knowledge Base)
self._index_client: SearchIndexClient | None = None
self._retrieval_client: KnowledgeBaseRetrievalClient | None = None
if mode == "agentic":
self._index_client = SearchIndexClient(
endpoint=self.endpoint,
credential=self.credential,
)
# Retrieval client will be created after Knowledge Base initialization
self._knowledge_base_initialized = False
async def __aenter__(self) -> Self:
"""Async context manager entry."""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
"""Async context manager exit - cleanup clients.
Args:
exc_type: Exception type if an error occurred.
exc_val: Exception value if an error occurred.
exc_tb: Exception traceback if an error occurred.
"""
# Close retrieval client if it was created
if self._retrieval_client is not None:
await self._retrieval_client.close()
self._retrieval_client = None
@override
async def invoking(
self,
messages: ChatMessage | MutableSequence[ChatMessage],
**kwargs: Any,
) -> Context:
"""Retrieve relevant context from Azure AI Search before model invocation.
Args:
messages: User messages to use for context retrieval.
**kwargs: Additional arguments (unused).
Returns:
Context object with retrieved documents as messages.
"""
# Convert to list and filter to USER/ASSISTANT messages with text only
messages_list = [messages] if isinstance(messages, ChatMessage) else list(messages)
filtered_messages = [
msg
for msg in messages_list
if msg and msg.text and msg.text.strip() and msg.role in [Role.USER, Role.ASSISTANT]
]
if not filtered_messages:
return Context()
# Perform search based on mode
if self.mode == "semantic":
# Semantic mode: flatten messages to single query
query = "\n".join(msg.text for msg in filtered_messages)
search_result_parts = await self._semantic_search(query)
else: # agentic
# Agentic mode: pass recent messages as conversation history
recent_messages = filtered_messages[-self.agentic_message_history_count :]
search_result_parts = await self._agentic_search(recent_messages)
# Format results as context - return multiple messages for each result part
if not search_result_parts:
return Context()
# Create context messages: first message with prompt, then one message per result part
context_messages = [ChatMessage(role=Role.USER, text=self.context_prompt)]
context_messages.extend([ChatMessage(role=Role.USER, text=part) for part in search_result_parts])
return Context(messages=context_messages)
def _find_vector_fields(self, index: Any) -> list[str]:
"""Find all fields that can store vectors (have dimensions defined).
Args:
index: SearchIndex object from Azure Search.
Returns:
List of vector field names.
"""
return [
field.name
for field in index.fields
if field.vector_search_dimensions is not None and field.vector_search_dimensions > 0
]
def _find_vectorizable_fields(self, index: Any, vector_fields: list[str]) -> list[str]:
"""Find vector fields that have auto-vectorization configured.
These are fields that have a vectorizer in their profile, meaning the index
can automatically vectorize text queries without needing a client-side embedding function.
Args:
index: SearchIndex object from Azure Search.
vector_fields: List of vector field names.
Returns:
List of vectorizable field names (subset of vector_fields).
"""
vectorizable_fields: list[str] = []
# Check if index has vector search configuration
if not index.vector_search or not index.vector_search.profiles:
return vectorizable_fields
# For each vector field, check if it has a vectorizer configured
for field in index.fields:
if field.name in vector_fields and field.vector_search_profile_name:
# Find the profile for this field
profile = next(
(p for p in index.vector_search.profiles if p.name == field.vector_search_profile_name), None
)
if profile and hasattr(profile, "vectorizer_name") and profile.vectorizer_name:
# This field has server-side vectorization configured
vectorizable_fields.append(field.name)
return vectorizable_fields
async def _auto_discover_vector_field(self) -> None:
"""Auto-discover vector field from index schema.
Attempts to find vector fields in the index and detect which have server-side
vectorization configured. Prioritizes vectorizable fields (which can auto-embed text)
over regular vector fields (which require client-side embedding).
"""
if self._auto_discovered_vector_field or self.vector_field_name:
return # Already discovered or manually specified
try:
# Use existing index client or create temporary one
if not self._index_client:
self._index_client = SearchIndexClient(endpoint=self.endpoint, credential=self.credential)
index_client = self._index_client
# Get index schema
index = await index_client.get_index(self.index_name)
# Step 1: Find all vector fields
vector_fields = self._find_vector_fields(index)
if not vector_fields:
# No vector fields found - keyword search only
logger.info(f"No vector fields found in index '{self.index_name}'. Using keyword-only search.")
self._auto_discovered_vector_field = True
return
# Step 2: Find which vector fields have server-side vectorization
vectorizable_fields = self._find_vectorizable_fields(index, vector_fields)
# Step 3: Decide which field to use
if vectorizable_fields:
# Prefer vectorizable fields (server-side embedding)
if len(vectorizable_fields) == 1:
self.vector_field_name = vectorizable_fields[0]
self._auto_discovered_vector_field = True
self._use_vectorizable_query = True # Use VectorizableTextQuery
logger.info(
f"Auto-discovered vectorizable field '{self.vector_field_name}' "
f"with server-side vectorization. No embedding_function needed."
)
else:
# Multiple vectorizable fields
logger.warning(
f"Multiple vectorizable fields found: {vectorizable_fields}. "
f"Please specify vector_field_name explicitly. Using keyword-only search."
)
elif len(vector_fields) == 1:
# Single vector field without vectorizer - needs client-side embedding
self.vector_field_name = vector_fields[0]
self._auto_discovered_vector_field = True
self._use_vectorizable_query = False
if not self.embedding_function:
logger.warning(
f"Auto-discovered vector field '{self.vector_field_name}' without server-side vectorization. "
f"Provide embedding_function for vector search, or it will fall back to keyword-only search."
)
self.vector_field_name = None
else:
# Multiple vector fields without vectorizers
logger.warning(
f"Multiple vector fields found: {vector_fields}. "
f"Please specify vector_field_name explicitly. Using keyword-only search."
)
except Exception as e:
# Log warning but continue with keyword search
logger.warning(f"Failed to auto-discover vector field: {e}. Using keyword-only search.")
self._auto_discovered_vector_field = True # Mark as attempted
async def _semantic_search(self, query: str) -> list[str]:
"""Perform semantic hybrid search with semantic ranking.
This is the recommended mode for most use cases. It combines:
- Vector search (if embedding_function provided)
- Keyword search (BM25)
- Semantic reranking (if semantic_configuration_name provided)
Args:
query: Search query text.
Returns:
List of formatted search result strings, one per document.
"""
# Auto-discover vector field if not already done
await self._auto_discover_vector_field()
vector_queries: list[VectorizableTextQuery | VectorizedQuery] = []
# Build vector query based on server-side vectorization or client-side embedding
if self.vector_field_name:
# Use larger k for vector query when semantic reranker is enabled for better ranking quality
vector_k = max(self.top_k, 50) if self.semantic_configuration_name else self.top_k
if self._use_vectorizable_query:
# Server-side vectorization: Index will auto-embed the text query
vector_queries = [
VectorizableTextQuery(
text=query,
k_nearest_neighbors=vector_k,
fields=self.vector_field_name,
)
]
elif self.embedding_function:
# Client-side embedding: We provide the vector
query_vector = await self.embedding_function(query)
vector_queries = [
VectorizedQuery(
vector=query_vector,
k_nearest_neighbors=vector_k,
fields=self.vector_field_name,
)
]
# else: vector_field_name is set but no vectorization available - skip vector search
# Build search parameters
search_params: dict[str, Any] = {
"search_text": query,
"top": self.top_k,
}
if vector_queries:
search_params["vector_queries"] = vector_queries
# Add semantic ranking if configured
if self.semantic_configuration_name:
search_params["query_type"] = QueryType.SEMANTIC
search_params["semantic_configuration_name"] = self.semantic_configuration_name
search_params["query_caption"] = QueryCaptionType.EXTRACTIVE
# Execute search
results = await self._search_client.search(**search_params) # type: ignore[reportUnknownVariableType]
# Format results with citations
formatted_results: list[str] = []
async for doc in results: # type: ignore[reportUnknownVariableType]
# Extract document ID for citation
doc_id = doc.get("id") or doc.get("@search.id") # type: ignore[reportUnknownVariableType]
# Use full document chunks with citation
doc_text: str = self._extract_document_text(doc, doc_id=doc_id) # type: ignore[reportUnknownArgumentType]
if doc_text:
formatted_results.append(doc_text) # type: ignore[reportUnknownArgumentType]
return formatted_results
async def _ensure_knowledge_base(self) -> None:
"""Ensure Knowledge Base and knowledge source are created.
This method is idempotent - it will only create resources if they don't exist.
Note: Azure SDK uses KnowledgeAgent classes internally, but the feature
is marketed as "Knowledge Bases" in Azure AI Search.
"""
if self._knowledge_base_initialized or not self._index_client:
return
# Runtime validation for agentic mode parameters
if not self.knowledge_base_name:
raise ValueError("knowledge_base_name is required for agentic mode")
if not self.azure_openai_resource_url:
raise ValueError("azure_openai_resource_url is required for agentic mode")
if not self.azure_openai_deployment_name:
raise ValueError("model_deployment_name is required for agentic mode")
knowledge_base_name = self.knowledge_base_name
# Step 1: Create or get knowledge source
knowledge_source_name = f"{self.index_name}-source"
try:
# Try to get existing knowledge source
await self._index_client.get_knowledge_source(knowledge_source_name)
except ResourceNotFoundError:
# Create new knowledge source if it doesn't exist
knowledge_source = SearchIndexKnowledgeSource(
name=knowledge_source_name,
description=f"Knowledge source for {self.index_name} search index",
search_index_parameters=SearchIndexKnowledgeSourceParameters(
search_index_name=self.index_name,
),
)
await self._index_client.create_knowledge_source(knowledge_source)
# Step 2: Create or update Knowledge Base
# Always create/update to ensure configuration is current
aoai_params = AzureOpenAIVectorizerParameters(
resource_url=self.azure_openai_resource_url,
deployment_name=self.azure_openai_deployment_name,
model_name=self.model_name,
api_key=self.azure_openai_api_key,
)
# Map output mode string to SDK enum
output_mode = (
KnowledgeRetrievalOutputMode.EXTRACTIVE_DATA
if self.knowledge_base_output_mode == "extractive_data"
else KnowledgeRetrievalOutputMode.ANSWER_SYNTHESIS
)
# Map reasoning effort string to SDK class
reasoning_effort_map: dict[str, KnowledgeRetrievalReasoningEffort] = {
"minimal": KnowledgeRetrievalMinimalReasoningEffort(),
"medium": KnowledgeRetrievalMediumReasoningEffort(),
"low": KnowledgeRetrievalLowReasoningEffort(),
}
reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort]
knowledge_base = KnowledgeBase(
name=knowledge_base_name,
description=f"Knowledge Base for multi-hop retrieval across {self.index_name}",
knowledge_sources=[
KnowledgeSourceReference(
name=knowledge_source_name,
)
],
models=[KnowledgeBaseAzureOpenAIModel(azure_open_ai_parameters=aoai_params)],
output_mode=output_mode,
retrieval_reasoning_effort=reasoning_effort,
)
await self._index_client.create_or_update_knowledge_base(knowledge_base)
self._knowledge_base_initialized = True
# Create retrieval client now that Knowledge Base is initialized
if _agentic_retrieval_available and self._retrieval_client is None:
self._retrieval_client = KnowledgeBaseRetrievalClient(
endpoint=self.endpoint,
knowledge_base_name=knowledge_base_name,
credential=self.credential,
)
async def _agentic_search(self, messages: list[ChatMessage]) -> list[str]:
"""Perform agentic retrieval with multi-hop reasoning using Knowledge Bases.
This mode uses query planning and is slightly slower than semantic search,
but provides more accurate results through intelligent retrieval.
This method uses Azure AI Search Knowledge Bases which:
1. Analyze the query and plan sub-queries
2. Retrieve relevant documents across multiple sources
3. Perform multi-hop reasoning with an LLM
4. Synthesize a comprehensive answer with references
Args:
messages: Conversation history to use for retrieval context.
Returns:
List of answer parts from the Knowledge Base, one per content item.
"""
# Ensure Knowledge Base is initialized
await self._ensure_knowledge_base()
# Map reasoning effort string to SDK class (for retrieval requests)
reasoning_effort_map: dict[str, KBRetrievalReasoningEffort] = {
"minimal": KBRetrievalMinimalReasoningEffort(),
"medium": KBRetrievalMediumReasoningEffort(),
"low": KBRetrievalLowReasoningEffort(),
}
reasoning_effort = reasoning_effort_map[self.retrieval_reasoning_effort]
# Map output mode string to SDK enum (for retrieval requests)
output_mode = (
KBRetrievalOutputMode.EXTRACTIVE_DATA
if self.knowledge_base_output_mode == "extractive_data"
else KBRetrievalOutputMode.ANSWER_SYNTHESIS
)
# For minimal reasoning, use intents API; for medium/low, use messages API
if self.retrieval_reasoning_effort == "minimal":
# Minimal reasoning uses intents with a single search query
query = "\n".join(msg.text for msg in messages if msg.text)
intents: list[KnowledgeRetrievalIntent] = [KnowledgeRetrievalSemanticIntent(search=query)]
retrieval_request = KnowledgeBaseRetrievalRequest(
intents=intents,
retrieval_reasoning_effort=reasoning_effort,
output_mode=output_mode,
include_activity=True,
)
else:
# Medium/low reasoning uses messages with conversation history
kb_messages = [
KnowledgeBaseMessage(
role=msg.role.value if hasattr(msg.role, "value") else str(msg.role),
content=[KnowledgeBaseMessageTextContent(text=msg.text)],
)
for msg in messages
if msg.text
]
retrieval_request = KnowledgeBaseRetrievalRequest(
messages=kb_messages,
retrieval_reasoning_effort=reasoning_effort,
output_mode=output_mode,
include_activity=True,
)
# Use reusable retrieval client
if not self._retrieval_client:
raise RuntimeError("Retrieval client not initialized. Ensure Knowledge Base is set up correctly.")
# Perform retrieval via Knowledge Base
retrieval_result = await self._retrieval_client.retrieve(retrieval_request=retrieval_request)
# Extract answer parts from response
if retrieval_result.response and len(retrieval_result.response) > 0:
# Get the assistant's response (last message)
assistant_message = retrieval_result.response[-1]
if assistant_message.content:
# Extract all text content items as separate parts
answer_parts: list[str] = []
for content_item in assistant_message.content:
# Check if this is a text content item
if isinstance(content_item, KnowledgeBaseMessageTextContent) and content_item.text:
answer_parts.append(content_item.text)
if answer_parts:
return answer_parts
# Fallback if no answer generated
return ["No results found from Knowledge Base."]
def _extract_document_text(self, doc: dict[str, Any], doc_id: str | None = None) -> str:
"""Extract readable text from a search document with optional citation.
Args:
doc: Search result document.
doc_id: Optional document ID for citation.
Returns:
Formatted document text with citation if doc_id provided.
"""
# Try common text field names
text = ""
for field in ["content", "text", "description", "body", "chunk"]:
if doc.get(field):
text = str(doc[field])
break
# Fallback: concatenate all string fields
if not text:
text_parts: list[str] = []
for key, value in doc.items():
if isinstance(value, str) and not key.startswith("@") and key != "id":
text_parts.append(f"{key}: {value}")
text = " | ".join(text_parts) if text_parts else ""
# Add citation if document ID provided
if doc_id and text:
return f"[Source: {doc_id}] {text}"
return text
+91
View File
@@ -0,0 +1,91 @@
[project]
name = "agent-framework-aisearch"
description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251118"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core",
"azure-search-documents==11.7.0b2",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
]
timeout = 120
[tool.ruff]
extend = "../../pyproject.toml"
exclude = ["examples"]
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_aisearch"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_aisearch"
test = "pytest --cov=agent_framework_aisearch --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,992 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportPrivateUsage=false
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import ChatMessage, Context, Role
from agent_framework.azure import AzureAISearchContextProvider
from agent_framework.exceptions import ServiceInitializationError
from azure.core.credentials import AzureKeyCredential
from azure.core.exceptions import ResourceNotFoundError
from agent_framework_aisearch import AzureAISearchSettings
@pytest.fixture
def mock_search_client() -> AsyncMock:
"""Create a mock SearchClient."""
mock_client = AsyncMock()
mock_client.search = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
return mock_client
@pytest.fixture
def mock_index_client() -> AsyncMock:
"""Create a mock SearchIndexClient."""
mock_client = AsyncMock()
mock_client.get_knowledge_source = AsyncMock()
mock_client.create_knowledge_source = AsyncMock()
mock_client.get_agent = AsyncMock()
mock_client.create_agent = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
return mock_client
@pytest.fixture
def sample_messages() -> list[ChatMessage]:
"""Create sample chat messages for testing."""
return [
ChatMessage(role=Role.USER, text="What is in the documents?"),
]
class TestAzureAISearchSettings:
"""Test AzureAISearchSettings configuration."""
def test_settings_with_direct_values(self) -> None:
"""Test settings with direct values."""
settings = AzureAISearchSettings(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
)
assert settings.endpoint == "https://test.search.windows.net"
assert settings.index_name == "test-index"
# api_key is now SecretStr
assert settings.api_key.get_secret_value() == "test-key"
def test_settings_with_env_file_path(self) -> None:
"""Test settings with env_file_path parameter."""
settings = AzureAISearchSettings(
endpoint="https://test.search.windows.net",
index_name="test-index",
env_file_path="test.env",
)
assert settings.endpoint == "https://test.search.windows.net"
assert settings.index_name == "test-index"
def test_provider_uses_settings_from_env(self) -> None:
"""Test that provider creates settings internally from env."""
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
)
assert provider.endpoint == "https://test.search.windows.net"
assert provider.index_name == "test-index"
def test_provider_missing_endpoint_raises_error(self) -> None:
"""Test that provider raises ServiceInitializationError without endpoint."""
# Use patch.dict to clear environment and pass env_file_path="" to prevent .env file loading
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")}
with (
patch.dict(os.environ, clean_env, clear=True),
pytest.raises(ServiceInitializationError, match="endpoint is required"),
):
AzureAISearchContextProvider(
index_name="test-index",
api_key="test-key",
env_file_path="", # Disable .env file loading
)
def test_provider_missing_index_name_raises_error(self) -> None:
"""Test that provider raises ServiceInitializationError without index_name."""
# Use patch.dict to clear environment and pass env_file_path="" to prevent .env file loading
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")}
with (
patch.dict(os.environ, clean_env, clear=True),
pytest.raises(ServiceInitializationError, match="index name is required"),
):
AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
api_key="test-key",
env_file_path="", # Disable .env file loading
)
def test_provider_missing_credential_raises_error(self) -> None:
"""Test that provider raises ServiceInitializationError without credential."""
# Use patch.dict to clear environment and pass env_file_path="" to prevent .env file loading
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("AZURE_SEARCH_")}
with (
patch.dict(os.environ, clean_env, clear=True),
pytest.raises(ServiceInitializationError, match="credential is required"),
):
AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
env_file_path="", # Disable .env file loading
)
class TestSearchProviderInitialization:
"""Test initialization and configuration of AzureAISearchContextProvider."""
def test_init_semantic_mode_minimal(self) -> None:
"""Test initialization with minimal semantic mode parameters."""
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
assert provider.endpoint == "https://test.search.windows.net"
assert provider.index_name == "test-index"
assert provider.mode == "semantic"
assert provider.top_k == 5
def test_init_semantic_mode_with_vector_field_requires_embedding_function(self) -> None:
"""Test that vector_field_name requires embedding_function."""
with pytest.raises(ValueError, match="embedding_function is required"):
AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
vector_field_name="embedding",
)
def test_init_agentic_mode_requires_azure_openai_resource_url(self) -> None:
"""Test that agentic mode requires azure_openai_resource_url."""
with pytest.raises(ValueError, match="azure_openai_resource_url"):
AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
)
def test_init_agentic_mode_requires_model_deployment_name(self) -> None:
"""Test that agentic mode requires model_deployment_name."""
with pytest.raises(ValueError, match="model_deployment_name"):
AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
azure_openai_resource_url="https://test.openai.azure.com",
)
def test_init_agentic_mode_requires_knowledge_base_name(self) -> None:
"""Test that agentic mode requires knowledge_base_name."""
with pytest.raises(ValueError, match="knowledge_base_name"):
AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
azure_openai_resource_url="https://test.openai.azure.com",
)
def test_init_agentic_mode_with_all_params(self) -> None:
"""Test initialization with all agentic mode parameters."""
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="my-gpt-4o-deployment",
model_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
)
assert provider.mode == "agentic"
assert provider.azure_ai_project_endpoint == "https://test.services.ai.azure.com"
assert provider.azure_openai_resource_url == "https://test.openai.azure.com"
assert provider.azure_openai_deployment_name == "my-gpt-4o-deployment"
assert provider.model_name == "gpt-4o"
assert provider.knowledge_base_name == "test-kb"
def test_init_model_name_defaults_to_deployment_name(self) -> None:
"""Test that model_name defaults to deployment_name if not provided."""
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
)
assert provider.model_name == "gpt-4o"
def test_init_with_custom_context_prompt(self) -> None:
"""Test initialization with custom context prompt."""
custom_prompt = "Use the following information:"
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
context_prompt=custom_prompt,
)
assert provider.context_prompt == custom_prompt
def test_init_uses_default_context_prompt(self) -> None:
"""Test that default context prompt is used when not provided."""
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
assert provider.context_prompt == provider._DEFAULT_SEARCH_CONTEXT_PROMPT
class TestSemanticSearch:
"""Test semantic search functionality."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_semantic_search_basic(
self, mock_search_class: MagicMock, sample_messages: list[ChatMessage]
) -> None:
"""Test basic semantic search without vector search."""
# Setup mock
mock_search_client = AsyncMock()
mock_results = AsyncMock()
mock_results.__aiter__.return_value = iter([{"content": "Test document content"}])
mock_search_client.search.return_value = mock_results
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
context = await provider.invoking(sample_messages)
assert isinstance(context, Context)
assert len(context.messages) > 1 # First message is prompt, rest are results
# First message should be the context prompt
assert "Use the following context" in context.messages[0].text
# Second message should contain the search result
assert "Test document content" in context.messages[1].text
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_semantic_search_empty_query(self, mock_search_class: MagicMock) -> None:
"""Test that empty queries return empty context."""
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Empty message
context = await provider.invoking([ChatMessage(role=Role.USER, text="")])
assert isinstance(context, Context)
assert len(context.messages) == 0
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_semantic_search_with_vector_query(
self, mock_search_class: MagicMock, sample_messages: list[ChatMessage]
) -> None:
"""Test semantic search with vector query."""
# Setup mock
mock_search_client = AsyncMock()
mock_results = AsyncMock()
mock_results.__aiter__.return_value = iter([{"content": "Vector search result"}])
mock_search_client.search.return_value = mock_results
mock_search_class.return_value = mock_search_client
# Mock embedding function
async def mock_embed(text: str) -> list[float]:
return [0.1, 0.2, 0.3]
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
vector_field_name="embedding",
embedding_function=mock_embed,
)
context = await provider.invoking(sample_messages)
assert isinstance(context, Context)
assert len(context.messages) > 0
# Verify that search was called
mock_search_client.search.assert_called_once()
class TestKnowledgeBaseSetup:
"""Test Knowledge Base setup for agentic mode."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_ensure_knowledge_base_creates_when_not_exists(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that Knowledge Base is created when it doesn't exist."""
# Setup mocks
mock_index_client = AsyncMock()
mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found")
mock_index_client.create_knowledge_source = AsyncMock()
mock_index_client.get_knowledge_base.side_effect = ResourceNotFoundError("Not found")
mock_index_client.create_or_update_knowledge_base = AsyncMock()
mock_index_class.return_value = mock_index_client
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
model_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
)
await provider._ensure_knowledge_base()
# Verify knowledge source was created
mock_index_client.create_knowledge_source.assert_called_once()
# Verify Knowledge Base was created
mock_index_client.create_or_update_knowledge_base.assert_called_once()
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_ensure_knowledge_base_skips_when_exists(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that Knowledge Base setup is skipped when already exists."""
# Setup mocks
mock_index_client = AsyncMock()
mock_index_client.get_knowledge_source.return_value = MagicMock() # Exists
mock_index_client.get_knowledge_base.return_value = MagicMock() # Exists
mock_index_class.return_value = mock_index_client
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
)
await provider._ensure_knowledge_base()
# Verify nothing was created
mock_index_client.create_knowledge_source.assert_not_called()
mock_index_client.create_agent.assert_not_called()
class TestContextProviderLifecycle:
"""Test context provider lifecycle methods."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_context_manager(self, mock_search_class: MagicMock) -> None:
"""Test that provider can be used as async context manager."""
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
async with AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
) as provider:
assert provider is not None
assert isinstance(provider, AzureAISearchContextProvider)
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient")
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_context_manager_agentic_cleanup(
self, mock_search_class: MagicMock, mock_index_class: MagicMock, mock_retrieval_class: MagicMock
) -> None:
"""Test that agentic mode provider cleans up retrieval client."""
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
mock_index_client = AsyncMock()
mock_index_class.return_value = mock_index_client
mock_retrieval_client = AsyncMock()
mock_retrieval_client.close = AsyncMock()
mock_retrieval_class.return_value = mock_retrieval_client
async with AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
) as provider:
# Simulate retrieval client being created
provider._retrieval_client = mock_retrieval_client
# Verify cleanup was called
mock_retrieval_client.close.assert_called_once()
def test_string_api_key_conversion(self) -> None:
"""Test that string api_key is converted to AzureKeyCredential."""
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="my-api-key", # String api_key
mode="semantic",
)
assert isinstance(provider.credential, AzureKeyCredential)
class TestMessageFiltering:
"""Test message filtering functionality."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_filters_non_user_assistant_messages(self, mock_search_class: MagicMock) -> None:
"""Test that only USER and ASSISTANT messages are processed."""
# Setup mock
mock_search_client = AsyncMock()
mock_results = AsyncMock()
mock_results.__aiter__.return_value = iter([{"content": "Test result"}])
mock_search_client.search.return_value = mock_results
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Mix of message types
messages = [
ChatMessage(role=Role.SYSTEM, text="System message"),
ChatMessage(role=Role.USER, text="User message"),
ChatMessage(role=Role.ASSISTANT, text="Assistant message"),
ChatMessage(role=Role.TOOL, text="Tool message"),
]
context = await provider.invoking(messages)
# Should have processed only USER and ASSISTANT messages
assert isinstance(context, Context)
mock_search_client.search.assert_called_once()
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_filters_empty_messages(self, mock_search_class: MagicMock) -> None:
"""Test that empty/whitespace messages are filtered out."""
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Messages with empty/whitespace text
messages = [
ChatMessage(role=Role.USER, text=""),
ChatMessage(role=Role.USER, text=" "),
ChatMessage(role=Role.USER, text=None),
]
context = await provider.invoking(messages)
# Should return empty context
assert len(context.messages) == 0
class TestCitations:
"""Test citation functionality."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_citations_included_in_semantic_search(self, mock_search_class: MagicMock) -> None:
"""Test that citations are included in semantic search results."""
# Setup mock with document ID
mock_search_client = AsyncMock()
mock_results = AsyncMock()
mock_doc = {"id": "doc123", "content": "Test document content"}
mock_results.__aiter__.return_value = iter([mock_doc])
mock_search_client.search.return_value = mock_results
mock_search_class.return_value = mock_search_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
context = await provider.invoking([ChatMessage(role=Role.USER, text="test query")])
# Check that citation is included
assert isinstance(context, Context)
assert len(context.messages) > 1 # First message is prompt, rest are results
# Citation should be in the result message (second message)
assert "[Source: doc123]" in context.messages[1].text
assert "Test document content" in context.messages[1].text
class TestAgenticSearch:
"""Test agentic search functionality."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient")
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_agentic_search_basic(
self,
mock_search_class: MagicMock,
mock_index_class: MagicMock,
mock_retrieval_class: MagicMock,
sample_messages: list[ChatMessage],
) -> None:
"""Test basic agentic search with Knowledge Base retrieval."""
# Setup search client mock
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
# Setup index client mock
mock_index_client = AsyncMock()
mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found")
mock_index_client.create_knowledge_source = AsyncMock()
mock_index_client.create_or_update_knowledge_base = AsyncMock()
mock_index_class.return_value = mock_index_client
# Setup retrieval client mock with response
mock_retrieval_client = AsyncMock()
mock_response = MagicMock()
mock_message = MagicMock()
mock_content = MagicMock()
mock_content.text = "Agentic search result"
# Make it pass isinstance check
from agent_framework_aisearch._search_provider import _agentic_retrieval_available
if _agentic_retrieval_available:
from azure.search.documents.knowledgebases.models import KnowledgeBaseMessageTextContent
mock_content.__class__ = KnowledgeBaseMessageTextContent
mock_message.content = [mock_content]
mock_response.response = [mock_message]
mock_retrieval_client.retrieve.return_value = mock_response
mock_retrieval_client.close = AsyncMock()
mock_retrieval_class.return_value = mock_retrieval_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
)
context = await provider.invoking(sample_messages)
assert isinstance(context, Context)
# Should have at least the prompt message
assert len(context.messages) >= 1
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient")
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_agentic_search_no_results(
self,
mock_search_class: MagicMock,
mock_index_class: MagicMock,
mock_retrieval_class: MagicMock,
sample_messages: list[ChatMessage],
) -> None:
"""Test agentic search when no results are returned."""
# Setup mocks
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
mock_index_client = AsyncMock()
mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found")
mock_index_client.create_knowledge_source = AsyncMock()
mock_index_client.create_or_update_knowledge_base = AsyncMock()
mock_index_class.return_value = mock_index_client
# Empty response
mock_retrieval_client = AsyncMock()
mock_response = MagicMock()
mock_response.response = []
mock_retrieval_client.retrieve.return_value = mock_response
mock_retrieval_client.close = AsyncMock()
mock_retrieval_class.return_value = mock_retrieval_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
)
context = await provider.invoking(sample_messages)
assert isinstance(context, Context)
# Should have fallback message
assert len(context.messages) >= 1
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.KnowledgeBaseRetrievalClient")
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_agentic_search_with_medium_reasoning(
self,
mock_search_class: MagicMock,
mock_index_class: MagicMock,
mock_retrieval_class: MagicMock,
sample_messages: list[ChatMessage],
) -> None:
"""Test agentic search with medium reasoning effort."""
# Setup mocks
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
mock_index_client = AsyncMock()
mock_index_client.get_knowledge_source.side_effect = ResourceNotFoundError("Not found")
mock_index_client.create_knowledge_source = AsyncMock()
mock_index_client.create_or_update_knowledge_base = AsyncMock()
mock_index_class.return_value = mock_index_client
mock_retrieval_client = AsyncMock()
mock_response = MagicMock()
mock_message = MagicMock()
mock_content = MagicMock()
mock_content.text = "Medium reasoning result"
from agent_framework_aisearch._search_provider import _agentic_retrieval_available
if _agentic_retrieval_available:
from azure.search.documents.knowledgebases.models import KnowledgeBaseMessageTextContent
mock_content.__class__ = KnowledgeBaseMessageTextContent
mock_message.content = [mock_content]
mock_response.response = [mock_message]
mock_retrieval_client.retrieve.return_value = mock_response
mock_retrieval_client.close = AsyncMock()
mock_retrieval_class.return_value = mock_retrieval_client
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="agentic",
azure_ai_project_endpoint="https://test.services.ai.azure.com",
model_deployment_name="gpt-4o",
knowledge_base_name="test-kb",
azure_openai_resource_url="https://test.openai.azure.com",
retrieval_reasoning_effort="medium", # Test medium reasoning
)
context = await provider.invoking(sample_messages)
assert isinstance(context, Context)
assert len(context.messages) >= 1
class TestVectorFieldAutoDiscovery:
"""Test vector field auto-discovery functionality."""
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_auto_discovers_single_vector_field(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that single vector field is auto-discovered."""
# Setup search client mock
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
# Setup index client mock
mock_index_client = AsyncMock()
mock_index = MagicMock()
# Create mock field with vector_search_dimensions attribute
mock_vector_field = MagicMock()
mock_vector_field.name = "embedding_vector"
mock_vector_field.vector_search_dimensions = 1536
mock_index.fields = [mock_vector_field]
mock_index_client.get_index.return_value = mock_index
mock_index_client.close = AsyncMock()
mock_index_class.return_value = mock_index_client
# Create provider without specifying vector_field_name
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Trigger auto-discovery
await provider._auto_discover_vector_field()
# Vector field should be auto-discovered but not used without embedding function
assert provider._auto_discovered_vector_field is True
# Should be cleared since no embedding function
assert provider.vector_field_name is None
@pytest.mark.asyncio
async def test_vector_detection_accuracy(self) -> None:
"""Test that vector field detection logic correctly identifies vector fields."""
from azure.search.documents.indexes.models import SearchField
# Create real SearchField objects to test the detection logic
vector_field = SearchField(
name="embedding_vector", type="Collection(Edm.Single)", vector_search_dimensions=1536, searchable=True
)
string_field = SearchField(name="content", type="Edm.String", searchable=True)
number_field = SearchField(name="price", type="Edm.Double", filterable=True)
# Test detection logic directly
is_vector_1 = vector_field.vector_search_dimensions is not None and vector_field.vector_search_dimensions > 0
is_vector_2 = string_field.vector_search_dimensions is not None and string_field.vector_search_dimensions > 0
is_vector_3 = number_field.vector_search_dimensions is not None and number_field.vector_search_dimensions > 0
# Only the vector field should be detected
assert is_vector_1 is True
assert is_vector_2 is False
assert is_vector_3 is False
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_no_false_positives_on_string_fields(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that regular string fields are not detected as vector fields."""
# Setup search client mock
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
# Setup index with only string fields (no vectors)
mock_index_client = AsyncMock()
mock_index = MagicMock()
# All fields have vector_search_dimensions = None
mock_fields = []
for name in ["id", "title", "content", "category"]:
field = MagicMock()
field.name = name
field.vector_search_dimensions = None
field.vector_search_profile_name = None
mock_fields.append(field)
mock_index.fields = mock_fields
mock_index_client.get_index.return_value = mock_index
mock_index_client.close = AsyncMock()
mock_index_class.return_value = mock_index_client
# Create provider
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Trigger auto-discovery
await provider._auto_discover_vector_field()
# Should NOT detect any vector fields
assert provider.vector_field_name is None
assert provider._auto_discovered_vector_field is True
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_multiple_vector_fields_without_vectorizer(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that multiple vector fields without vectorizer logs warning and uses keyword search."""
# Setup search client mock
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
# Setup index with multiple vector fields (no vectorizers)
mock_index_client = AsyncMock()
mock_index = MagicMock()
# Multiple vector fields
mock_fields = []
for name in ["embedding1", "embedding2"]:
field = MagicMock()
field.name = name
field.vector_search_dimensions = 1536
field.vector_search_profile_name = None # No vectorizer
mock_fields.append(field)
mock_index.fields = mock_fields
mock_index.vector_search = None # No vector search config
mock_index_client.get_index.return_value = mock_index
mock_index_client.close = AsyncMock()
mock_index_class.return_value = mock_index_client
# Create provider
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Trigger auto-discovery
await provider._auto_discover_vector_field()
# Should NOT use any vector field (multiple fields, can't choose)
assert provider.vector_field_name is None
assert provider._auto_discovered_vector_field is True
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_multiple_vectorizable_fields(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that multiple vectorizable fields logs warning and uses keyword search."""
# Setup search client mock
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
# Setup index with multiple vectorizable fields
mock_index_client = AsyncMock()
mock_index = MagicMock()
# Multiple vector fields with vectorizers
mock_fields = []
for name in ["embedding1", "embedding2"]:
field = MagicMock()
field.name = name
field.vector_search_dimensions = 1536
field.vector_search_profile_name = f"{name}-profile"
mock_fields.append(field)
mock_index.fields = mock_fields
# Setup vector search config with profiles that have vectorizers
mock_profile1 = MagicMock()
mock_profile1.name = "embedding1-profile"
mock_profile1.vectorizer_name = "vectorizer1"
mock_profile2 = MagicMock()
mock_profile2.name = "embedding2-profile"
mock_profile2.vectorizer_name = "vectorizer2"
mock_index.vector_search = MagicMock()
mock_index.vector_search.profiles = [mock_profile1, mock_profile2]
mock_index_client.get_index.return_value = mock_index
mock_index_client.close = AsyncMock()
mock_index_class.return_value = mock_index_client
# Create provider
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Trigger auto-discovery
await provider._auto_discover_vector_field()
# Should NOT use any vector field (multiple vectorizable fields, can't choose)
assert provider.vector_field_name is None
assert provider._auto_discovered_vector_field is True
@pytest.mark.asyncio
@patch("agent_framework_aisearch._search_provider.SearchIndexClient")
@patch("agent_framework_aisearch._search_provider.SearchClient")
async def test_single_vectorizable_field_detected(
self, mock_search_class: MagicMock, mock_index_class: MagicMock
) -> None:
"""Test that single vectorizable field is auto-detected for server-side vectorization."""
# Setup search client mock
mock_search_client = AsyncMock()
mock_search_class.return_value = mock_search_client
# Setup index with single vectorizable field
mock_index_client = AsyncMock()
mock_index = MagicMock()
# Single vector field with vectorizer
mock_field = MagicMock()
mock_field.name = "embedding"
mock_field.vector_search_dimensions = 1536
mock_field.vector_search_profile_name = "embedding-profile"
mock_index.fields = [mock_field]
# Setup vector search config with profile that has vectorizer
mock_profile = MagicMock()
mock_profile.name = "embedding-profile"
mock_profile.vectorizer_name = "openai-vectorizer"
mock_index.vector_search = MagicMock()
mock_index.vector_search.profiles = [mock_profile]
mock_index_client.get_index.return_value = mock_index
mock_index_client.close = AsyncMock()
mock_index_class.return_value = mock_index_client
# Create provider
provider = AzureAISearchContextProvider(
endpoint="https://test.search.windows.net",
index_name="test-index",
api_key="test-key",
mode="semantic",
)
# Trigger auto-discovery
await provider._auto_discover_vector_field()
# Should detect the vectorizable field
assert provider.vector_field_name == "embedding"
assert provider._auto_discovered_vector_field is True
assert provider._use_vectorizable_query is True # Server-side vectorization
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251114"
version = "1.0.0b251120"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -310,8 +310,8 @@ class AzureAIClient(OpenAIBaseResponsesClient):
return run_options
async def initialize_client(self) -> None:
"""Initialize OpenAI client asynchronously."""
self.client = await self.project_client.get_openai_client() # type: ignore
"""Initialize OpenAI client."""
self.client = self.project_client.get_openai_client() # type: ignore
def _update_agent_name(self, agent_name: str | None) -> None:
"""Update the agent name in the chat client.
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251114"
version = "1.0.0b251120"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -24,7 +24,7 @@ classifiers = [
]
dependencies = [
"agent-framework-core",
"azure-ai-projects >= 2.0.0b1",
"azure-ai-projects >= 2.0.0b2",
"azure-ai-agents == 1.2.0b5",
"aiohttp",
]
@@ -268,7 +268,7 @@ async def test_azure_ai_client_initialize_client(mock_project_client: MagicMock)
client = create_test_azure_ai_client(mock_project_client)
mock_openai_client = MagicMock()
mock_project_client.get_openai_client = AsyncMock(return_value=mock_openai_client)
mock_project_client.get_openai_client = MagicMock(return_value=mock_openai_client)
await client.initialize_client()
@@ -16,27 +16,29 @@ import azure.functions as func
from agent_framework import AgentProtocol, get_logger
from ._callbacks import AgentResponseCallbackProtocol
from ._constants import (
DEFAULT_MAX_POLL_RETRIES,
DEFAULT_POLL_INTERVAL_SECONDS,
MIMETYPE_APPLICATION_JSON,
MIMETYPE_TEXT_PLAIN,
REQUEST_RESPONSE_FORMAT_JSON,
REQUEST_RESPONSE_FORMAT_TEXT,
THREAD_ID_FIELD,
THREAD_ID_HEADER,
WAIT_FOR_RESPONSE_FIELD,
WAIT_FOR_RESPONSE_HEADER,
)
from ._durable_agent_state import DurableAgentState
from ._entities import create_agent_entity
from ._errors import IncomingRequestError
from ._models import AgentSessionId, RunRequest
from ._orchestration import AgentOrchestrationContextType, DurableAIAgent
from ._state import AgentState
logger = get_logger("agent_framework.azurefunctions")
THREAD_ID_FIELD: str = "thread_id"
RESPONSE_FORMAT_JSON: str = "json"
RESPONSE_FORMAT_TEXT: str = "text"
WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response"
WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
EntityHandler = Callable[[df.DurableEntityContext], None]
HandlerT = TypeVar("HandlerT", bound=Callable[..., Any])
DEFAULT_MAX_POLL_RETRIES: int = 30
DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0
if TYPE_CHECKING:
class DFAppBase:
@@ -317,11 +319,11 @@ class AgentFunctionApp(DFAppBase):
"""
logger.debug(f"[HTTP Trigger] Received request on route: /api/agents/{agent_name}/run")
response_format: str = RESPONSE_FORMAT_JSON
request_response_format: str = REQUEST_RESPONSE_FORMAT_JSON
thread_id: str | None = None
try:
req_body, message, response_format = self._parse_incoming_request(req)
req_body, message, request_response_format = self._parse_incoming_request(req)
thread_id = self._resolve_thread_id(req=req, req_body=req_body)
wait_for_response = self._should_wait_for_response(req=req, req_body=req_body)
@@ -334,7 +336,7 @@ class AgentFunctionApp(DFAppBase):
return self._create_http_response(
payload={"error": "Message is required"},
status_code=400,
response_format=response_format,
request_response_format=request_response_format,
thread_id=thread_id,
)
@@ -351,6 +353,7 @@ class AgentFunctionApp(DFAppBase):
message,
thread_id,
correlation_id,
request_response_format,
)
logger.debug("Signalling entity %s with request: %s", entity_instance_id, run_request)
await client.signal_entity(entity_instance_id, "run_agent", run_request)
@@ -370,7 +373,7 @@ class AgentFunctionApp(DFAppBase):
return self._create_http_response(
payload=result,
status_code=200 if result.get("status") == "success" else 500,
response_format=response_format,
request_response_format=request_response_format,
thread_id=thread_id,
)
@@ -383,7 +386,7 @@ class AgentFunctionApp(DFAppBase):
return self._create_http_response(
payload=accepted_response,
status_code=202,
response_format=response_format,
request_response_format=request_response_format,
thread_id=thread_id,
)
@@ -392,7 +395,7 @@ class AgentFunctionApp(DFAppBase):
return self._create_http_response(
payload={"error": str(exc)},
status_code=exc.status_code,
response_format=response_format,
request_response_format=request_response_format,
thread_id=thread_id,
)
except ValueError as exc:
@@ -400,7 +403,7 @@ class AgentFunctionApp(DFAppBase):
return self._create_http_response(
payload={"error": "Invalid JSON"},
status_code=400,
response_format=response_format,
request_response_format=request_response_format,
thread_id=thread_id,
)
except Exception as exc:
@@ -408,7 +411,7 @@ class AgentFunctionApp(DFAppBase):
return self._create_http_response(
payload={"error": str(exc)},
status_code=500,
response_format=response_format,
request_response_format=request_response_format,
thread_id=thread_id,
)
@@ -466,7 +469,7 @@ class AgentFunctionApp(DFAppBase):
return func.HttpResponse(
json.dumps({"status": "healthy", "agents": agent_info, "agent_count": len(self.agents)}),
status_code=200,
mimetype="application/json",
mimetype=MIMETYPE_APPLICATION_JSON,
)
_ = health_check
@@ -491,7 +494,7 @@ class AgentFunctionApp(DFAppBase):
self,
client: df.DurableOrchestrationClient,
entity_instance_id: df.EntityId,
) -> AgentState | None:
) -> DurableAgentState | None:
state_response = await client.read_entity_state(entity_instance_id)
if not state_response or not state_response.entity_exists:
return None
@@ -502,9 +505,7 @@ class AgentFunctionApp(DFAppBase):
typed_state_payload = cast(dict[str, Any], state_payload)
agent_state = AgentState()
agent_state.restore_state(typed_state_payload)
return agent_state
return DurableAgentState.from_dict(typed_state_payload)
async def _get_response_from_entity(
self,
@@ -580,31 +581,58 @@ class AgentFunctionApp(DFAppBase):
return result
def _build_response_payload(
self,
*,
response: str | None,
message: str,
thread_id: str,
status: str,
correlation_id: str,
extra_fields: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Create a consistent response structure and allow optional extra fields."""
payload = {
"response": response,
"message": message,
THREAD_ID_FIELD: thread_id,
"status": status,
"correlation_id": correlation_id,
}
if extra_fields:
payload.update(extra_fields)
return payload
async def _build_timeout_result(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
"""Create the timeout response."""
return {
"response": "Agent is still processing or timed out...",
"message": message,
THREAD_ID_FIELD: thread_id,
"status": "timeout",
"correlation_id": correlation_id,
}
return self._build_response_payload(
response="Agent is still processing or timed out...",
message=message,
thread_id=thread_id,
status="timeout",
correlation_id=correlation_id,
)
def _build_success_result(
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: AgentState
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: DurableAgentState
) -> dict[str, Any]:
"""Build the success result returned to the HTTP caller."""
return {
"response": response_data.get("content"),
"message": message,
THREAD_ID_FIELD: thread_id,
"status": "success",
"message_count": response_data.get("message_count", state.message_count),
"correlation_id": correlation_id,
}
return self._build_response_payload(
response=response_data.get("content"),
message=message,
thread_id=thread_id,
status="success",
correlation_id=correlation_id,
extra_fields={"message_count": response_data.get("message_count", state.message_count)},
)
def _build_request_data(
self, req_body: dict[str, Any], message: str, thread_id: str, correlation_id: str
self,
req_body: dict[str, Any],
message: str,
thread_id: str,
correlation_id: str,
request_response_format: str,
) -> dict[str, Any]:
"""Create the durable entity request payload."""
enable_tool_calls_value = req_body.get("enable_tool_calls")
@@ -613,6 +641,7 @@ class AgentFunctionApp(DFAppBase):
return RunRequest(
message=message,
role=req_body.get("role"),
request_response_format=request_response_format,
response_format=req_body.get("response_format"),
enable_tool_calls=enable_tool_calls,
thread_id=thread_id,
@@ -621,23 +650,23 @@ class AgentFunctionApp(DFAppBase):
def _build_accepted_response(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
"""Build the response returned when not waiting for completion."""
return {
"response": "Agent request accepted",
"message": message,
THREAD_ID_FIELD: thread_id,
"status": "accepted",
"correlation_id": correlation_id,
}
return self._build_response_payload(
response="Agent request accepted",
message=message,
thread_id=thread_id,
status="accepted",
correlation_id=correlation_id,
)
def _create_http_response(
self,
payload: dict[str, Any] | str,
status_code: int,
response_format: str,
request_response_format: str,
thread_id: str | None,
) -> func.HttpResponse:
"""Create the HTTP response using helper serializers for clarity."""
if response_format == RESPONSE_FORMAT_TEXT:
if request_response_format == REQUEST_RESPONSE_FORMAT_TEXT:
return self._build_plain_text_response(payload=payload, status_code=status_code, thread_id=thread_id)
return self._build_json_response(payload=payload, status_code=status_code)
@@ -650,13 +679,13 @@ class AgentFunctionApp(DFAppBase):
) -> func.HttpResponse:
"""Return a plain-text response with optional thread identifier header."""
body_text = payload if isinstance(payload, str) else self._convert_payload_to_text(payload)
headers = {"x-ms-thread-id": thread_id} if thread_id is not None else None
return func.HttpResponse(body_text, status_code=status_code, mimetype="text/plain", headers=headers)
headers = {THREAD_ID_HEADER: thread_id} if thread_id is not None else None
return func.HttpResponse(body_text, status_code=status_code, mimetype=MIMETYPE_TEXT_PLAIN, headers=headers)
def _build_json_response(self, payload: dict[str, Any] | str, status_code: int) -> func.HttpResponse:
"""Return the JSON response, serializing dictionaries as needed."""
body_json = payload if isinstance(payload, str) else json.dumps(payload)
return func.HttpResponse(body_json, status_code=status_code, mimetype="application/json")
return func.HttpResponse(body_json, status_code=status_code, mimetype=MIMETYPE_APPLICATION_JSON)
def _convert_payload_to_text(self, payload: dict[str, Any]) -> str:
"""Convert a structured payload into a human-readable text response."""
@@ -702,18 +731,19 @@ class AgentFunctionApp(DFAppBase):
normalized_content_type = self._extract_content_type(headers)
body_parser, body_format = self._select_body_parser(normalized_content_type)
prefers_json = self._accepts_json_response(headers)
response_format = self._select_response_format(body_format=body_format, prefers_json=prefers_json)
request_response_format = self._select_request_response_format(
body_format=body_format, prefers_json=prefers_json
)
req_body, message = body_parser(req)
return req_body, message, response_format
return req_body, message, request_response_format
def _extract_normalized_headers(self, req: func.HttpRequest) -> dict[str, str]:
"""Create a lowercase header mapping from the incoming request."""
headers: dict[str, str] = {}
raw_headers = req.headers
if isinstance(raw_headers, Mapping):
header_mapping: Mapping[str, Any] = cast(Mapping[str, Any], raw_headers)
for key, value in header_mapping.items():
for key, value in raw_headers.items():
if value is not None:
headers[str(key).lower()] = str(value)
return headers
@@ -729,9 +759,9 @@ class AgentFunctionApp(DFAppBase):
normalized_content_type: str,
) -> tuple[Callable[[func.HttpRequest], tuple[dict[str, Any], str]], str]:
"""Choose the body parser and declared body format."""
if normalized_content_type in {"application/json"} or normalized_content_type.endswith("+json"):
return self._parse_json_body, RESPONSE_FORMAT_JSON
return self._parse_text_body, RESPONSE_FORMAT_TEXT
if normalized_content_type in {MIMETYPE_APPLICATION_JSON} or normalized_content_type.endswith("+json"):
return self._parse_json_body, REQUEST_RESPONSE_FORMAT_JSON
return self._parse_text_body, REQUEST_RESPONSE_FORMAT_TEXT
@staticmethod
def _accepts_json_response(headers: dict[str, str]) -> bool:
@@ -742,16 +772,16 @@ class AgentFunctionApp(DFAppBase):
for value in accept_header.split(","):
media_type = value.split(";")[0].strip().lower()
if media_type == "application/json":
if media_type == MIMETYPE_APPLICATION_JSON:
return True
return False
@staticmethod
def _select_response_format(body_format: str, prefers_json: bool) -> str:
def _select_request_response_format(body_format: str, prefers_json: bool) -> str:
"""Combine body format and accept preference to determine response format."""
if body_format == RESPONSE_FORMAT_JSON or prefers_json:
return RESPONSE_FORMAT_JSON
return RESPONSE_FORMAT_TEXT
if body_format == REQUEST_RESPONSE_FORMAT_JSON or prefers_json:
return REQUEST_RESPONSE_FORMAT_JSON
return REQUEST_RESPONSE_FORMAT_TEXT
@staticmethod
def _parse_json_body(req: func.HttpRequest) -> tuple[dict[str, Any], str]:
@@ -6,8 +6,6 @@ This module enables callers of AgentFunctionApp to supply streaming and final-re
invoked during durable entity execution.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@@ -0,0 +1,19 @@
# Copyright (c) Microsoft. All rights reserved.
"""Constants for Azure Functions Agent Framework integration."""
# Supported request/response formats and MIME types
REQUEST_RESPONSE_FORMAT_JSON: str = "json"
REQUEST_RESPONSE_FORMAT_TEXT: str = "text"
MIMETYPE_APPLICATION_JSON: str = "application/json"
MIMETYPE_TEXT_PLAIN: str = "text/plain"
# Field and header names
THREAD_ID_FIELD: str = "thread_id"
THREAD_ID_HEADER: str = "x-ms-thread-id"
WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response"
WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
# Polling configuration
DEFAULT_MAX_POLL_RETRIES: int = 30
DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0
@@ -11,14 +11,30 @@ import asyncio
import inspect
import json
from collections.abc import AsyncIterable, Callable
from datetime import datetime, timezone
from typing import Any, cast
import azure.durable_functions as df
from agent_framework import AgentProtocol, AgentRunResponse, AgentRunResponseUpdate, Role, get_logger
from agent_framework import (
AgentProtocol,
AgentRunResponse,
AgentRunResponseUpdate,
ChatMessage,
ErrorContent,
Role,
get_logger,
)
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
from ._durable_agent_state import (
DurableAgentState,
DurableAgentStateData,
DurableAgentStateEntry,
DurableAgentStateMessage,
DurableAgentStateRequest,
DurableAgentStateResponse,
)
from ._models import AgentResponse, RunRequest
from ._state import AgentState
logger = get_logger("agent_framework.azurefunctions.entities")
@@ -38,11 +54,11 @@ class AgentEntity:
Attributes:
agent: The AgentProtocol instance
state: The AgentState managing conversation history
state: The DurableAgentState managing conversation history
"""
agent: AgentProtocol
state: AgentState
state: DurableAgentState
def __init__(
self,
@@ -56,11 +72,27 @@ class AgentEntity:
callback: Optional callback invoked during streaming updates and final responses
"""
self.agent = agent
self.state = AgentState()
self.state = DurableAgentState()
self.callback = callback
logger.debug(f"[AgentEntity] Initialized with agent type: {type(agent).__name__}")
def _is_error_response(self, entry: DurableAgentStateEntry) -> bool:
"""Check if a conversation history entry is an error response.
Error responses should be kept in history for tracking but not sent to the agent
since Azure OpenAI doesn't support 'error' content type.
Args:
entry: A conversation history entry (DurableAgentStateEntry or dict)
Returns:
True if the entry is a response containing error content, False otherwise
"""
if isinstance(entry, DurableAgentStateResponse):
return entry.is_error
return False
async def run_agent(
self,
context: df.DurableEntityContext,
@@ -94,26 +126,27 @@ class AgentEntity:
raise ValueError("RunRequest must include a thread_id")
if not correlation_id:
raise ValueError("RunRequest must include a correlation_id")
role = run_request.role or Role.USER
response_format = run_request.response_format
enable_tool_calls = run_request.enable_tool_calls
logger.debug(f"[AgentEntity.run_agent] Received message: {message}")
logger.debug(f"[AgentEntity.run_agent] Thread ID: {thread_id}")
logger.debug(f"[AgentEntity.run_agent] Correlation ID: {correlation_id}")
logger.debug(f"[AgentEntity.run_agent] Role: {role.value}")
logger.debug(f"[AgentEntity.run_agent] Enable tool calls: {enable_tool_calls}")
logger.debug(f"[AgentEntity.run_agent] Response format: {'provided' if response_format else 'none'}")
state_request = DurableAgentStateRequest.from_run_request(run_request)
self.state.data.conversation_history.append(state_request)
# Store message in history with role
self.state.add_user_message(message, role=role, correlation_id=correlation_id)
logger.debug("[AgentEntity.run_agent] Executing agent...")
logger.debug(f"[AgentEntity.run_agent] Received Message: {state_request}")
try:
logger.debug("[AgentEntity.run_agent] Starting agent invocation")
run_kwargs: dict[str, Any] = {"messages": self.state.get_chat_messages()}
# Build messages from conversation history, excluding error responses
# Error responses are kept in history for tracking but not sent to the agent
chat_messages: list[ChatMessage] = [
m.to_chat_message()
for entry in self.state.data.conversation_history
if not self._is_error_response(entry)
for m in entry.messages
]
run_kwargs: dict[str, Any] = {"messages": chat_messages}
if not enable_tool_calls:
run_kwargs["tools"] = None
if response_format:
@@ -133,8 +166,8 @@ class AgentEntity:
response_text = None
structured_response = None
response_str: str | None = None
try:
if response_format:
try:
@@ -156,18 +189,19 @@ class AgentEntity:
)
response_text = "Error extracting response"
state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_run_response)
self.state.data.conversation_history.append(state_response)
agent_response = AgentResponse(
response=response_text,
message=str(message),
thread_id=str(thread_id),
status="success",
message_count=self.state.message_count,
message_count=len(self.state.data.conversation_history),
structured_response=structured_response,
)
result = agent_response.to_dict()
content = json.dumps(structured_response) if structured_response else (response_text or "")
self.state.add_assistant_message(content, agent_run_response, correlation_id)
logger.debug("[AgentEntity.run_agent] AgentRunResponse stored in conversation history")
return result
@@ -181,12 +215,28 @@ class AgentEntity:
logger.error(f"Error type: {type(exc).__name__}")
logger.error(f"Full traceback:\n{error_traceback}")
# Create error message
error_message = DurableAgentStateMessage.from_chat_message(
ChatMessage(
role=Role.ASSISTANT, contents=[ErrorContent(message=str(exc), error_code=type(exc).__name__)]
)
)
# Create and store error response in conversation history
error_state_response = DurableAgentStateResponse(
correlation_id=correlation_id,
created_at=datetime.now(tz=timezone.utc),
messages=[error_message],
is_error=True,
)
self.state.data.conversation_history.append(error_state_response)
error_response = AgentResponse(
response=f"Error: {exc!s}",
message=str(message),
thread_id=str(thread_id),
status="error",
message_count=self.state.message_count,
message_count=len(self.state.data.conversation_history),
error=str(exc),
error_type=type(exc).__name__,
)
@@ -333,7 +383,7 @@ class AgentEntity:
def reset(self, context: df.DurableEntityContext) -> None:
"""Reset the entity state (clear conversation history)."""
logger.debug("[AgentEntity.reset] Resetting entity state")
self.state.reset()
self.state.data = DurableAgentStateData(conversation_history=[])
logger.debug("[AgentEntity.reset] State reset complete")
@@ -362,7 +412,7 @@ def create_agent_entity(
entity = AgentEntity(agent, callback)
if current_state is not None:
entity.state.restore_state(current_state)
entity.state = DurableAgentState.from_dict(current_state)
logger.debug(
"[entity_function] Restored entity from state (message_count: %s)", entity.state.message_count
)
@@ -392,8 +442,9 @@ def create_agent_entity(
logger.error("[entity_function] Unknown operation: %s", operation)
context.set_result({"error": f"Unknown operation: {operation}"})
logger.debug("State dict: %s", entity.state.to_dict())
context.set_state(entity.state.to_dict())
logger.debug(f"[entity_function] Operation {operation} completed successfully")
logger.info(f"[entity_function] Operation {operation} completed successfully")
except Exception as exc:
import traceback
@@ -2,8 +2,6 @@
"""Custom exception types for the durable agent framework."""
from __future__ import annotations
class IncomingRequestError(ValueError):
"""Raised when an incoming HTTP request cannot be parsed or validated."""
@@ -17,6 +17,8 @@ from typing import TYPE_CHECKING, Any, cast
import azure.durable_functions as df
from agent_framework import AgentThread, Role
from ._constants import REQUEST_RESPONSE_FORMAT_TEXT
if TYPE_CHECKING: # pragma: no cover - type checking imports only
from pydantic import BaseModel
@@ -278,35 +280,43 @@ class RunRequest:
Attributes:
message: The message to send to the agent
request_response_format: The desired response format (e.g., "text" or "json")
role: The role of the message sender (user, system, or assistant)
response_format: Optional Pydantic BaseModel type describing the structured response format
enable_tool_calls: Whether to enable tool calls for this request
thread_id: Optional thread ID for tracking
correlation_id: Optional correlation ID for tracking the response to this specific request
created_at: Optional timestamp when the request was created
"""
message: str
request_response_format: str
role: Role = Role.USER
response_format: type[BaseModel] | None = None
enable_tool_calls: bool = True
thread_id: str | None = None
correlation_id: str | None = None
created_at: str | None = None
def __init__(
self,
message: str,
request_response_format: str = REQUEST_RESPONSE_FORMAT_TEXT,
role: Role | str | None = Role.USER,
response_format: type[BaseModel] | None = None,
enable_tool_calls: bool = True,
thread_id: str | None = None,
correlation_id: str | None = None,
created_at: str | None = None,
) -> None:
self.message = message
self.role = self.coerce_role(role)
self.response_format = response_format
self.request_response_format = request_response_format
self.enable_tool_calls = enable_tool_calls
self.thread_id = thread_id
self.correlation_id = correlation_id
self.created_at = created_at
@staticmethod
def coerce_role(value: Role | str | None) -> Role:
@@ -326,13 +336,17 @@ class RunRequest:
"message": self.message,
"enable_tool_calls": self.enable_tool_calls,
"role": self.role.value,
"request_response_format": self.request_response_format,
}
if self.response_format:
result["response_format"] = _serialize_response_format(self.response_format)
if self.thread_id:
result["thread_id"] = self.thread_id
if self.correlation_id:
result["correlation_id"] = self.correlation_id
result["correlationId"] = self.correlation_id
if self.created_at:
result["created_at"] = self.created_at
return result
@classmethod
@@ -340,11 +354,13 @@ class RunRequest:
"""Create RunRequest from dictionary."""
return cls(
message=data.get("message", ""),
request_response_format=data.get("request_response_format", REQUEST_RESPONSE_FORMAT_TEXT),
role=cls.coerce_role(data.get("role")),
response_format=_deserialize_response_format(data.get("response_format")),
enable_tool_calls=data.get("enable_tool_calls", True),
thread_id=data.get("thread_id"),
correlation_id=data.get("correlation_id"),
correlation_id=data.get("correlationId"),
created_at=data.get("created_at"),
)

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