Compare commits

..
Author SHA1 Message Date
Dmytro StrukandGitHub 863a632ccf Updated package versions (#2104) 2025-11-11 23:30:34 +00:00
Mark WallaceandGitHub 8d8c94b312 Python: Use the last entry in the task history to avoid empty responses (#2101)
* Use the last entry in the task history to avoid empty responses

* History only contains Messages
2025-11-11 22:56:53 +00:00
Daniel CazzulinoGitHubCopilotkzucopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
180f82373b .NET: Allow extending agent run options via additional properties (#1872)
* Allow extending agent run options via additional properties

This mirrors the M.E.AI model in ChatOptions.AdditionalProperties which is very useful when building functionality pipelines.

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

* Expand XML documentation

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

* Add AdditionalProperties tests to AgentRunOptions

Co-authored-by: kzu <169707+kzu@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: kzu <169707+kzu@users.noreply.github.com>
2025-11-11 20:00:59 +00:00
Giles OdigweandGitHub d59bd20765 Python: Fix MCP Tool Parameter Descriptions Not Propagated to LLMs (#1978)
* mcp tool description fix

* small fix
2025-11-11 19:43:26 +00:00
Giles OdigweandGitHub 1d7292fba6 Python: OpenAI Responses Image Generation Stream Support, Sample and Unit Tests (#1853)
* support for image gen streaming

* small fixes

* fixes

* added comment
2025-11-11 19:01:37 +00:00
Tao ChenandGitHub cd9073aa11 Python: Handle agent user input request in AgentExecutor (#2022)
* Handle agent user input request in AgentExecutor

* fix test

* Address comments

* Fix tests

* Fix tests

* Address comments

* Address comments
2025-11-11 18:59:57 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Chris
5fd2a0c287 Bump MishaKav/pytest-coverage-comment from 1.1.57 to 1.1.59 (#2034)
Bumps [MishaKav/pytest-coverage-comment](https://github.com/mishakav/pytest-coverage-comment) from 1.1.57 to 1.1.59.
- [Release notes](https://github.com/mishakav/pytest-coverage-comment/releases)
- [Changelog](https://github.com/MishaKav/pytest-coverage-comment/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mishakav/pytest-coverage-comment/compare/v1.1.57...v1.1.59)

---
updated-dependencies:
- dependency-name: MishaKav/pytest-coverage-comment
  dependency-version: 1.1.59
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-11-11 17:52:50 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b565b25b04 Bump Roslynator.Analyzers from 4.14.0 to 4.14.1 (#1857)
---
updated-dependencies:
- dependency-name: Roslynator.Analyzers
  dependency-version: 4.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-11 17:48:49 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
24298cd89e Bump vite in /python/samples/demos/chatkit-integration/frontend (#1918)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.9 to 7.1.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.1.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.12
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-11 17:33:52 +00:00
ChrisandGitHub ad2ebfc0c8 Updated (#2076) 2025-11-11 17:17:34 +00:00
Jacob AlberandGitHub a39e6561fd .NET: fix: WorkflowAsAgent Sample (#1787)
* fix: WorkflowAsAgent Sample

* Also makes ChatForwardingExecutor public

* feat: Expand ChatForwardingExecutor handled types

Make ChatForwardingExecutor match the input types of ChatProtocolExecutor.

* fix: Update for the new AgentRunResponseUpdate merge logic

AIAgent always sends out List<ChatMessage> now.
2025-11-11 16:35:22 +00:00
dependabot[bot]GitHubrogerbarretomarkwallace-microsoftcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Copilot
69dd532cd4 .NET: Bump Microsoft.SemanticKernel.Agents.Abstractions from 1.66.0 to 1.67.0 (#1962)
* Bump Microsoft.SemanticKernel.Agents.Abstractions from 1.66.0 to 1.67.0

---
updated-dependencies:
- dependency-name: Microsoft.SemanticKernel.Agents.Abstractions
  dependency-version: 1.67.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* .NET: Bump all Microsoft.SemanticKernel packages from 1.66.* to 1.67.* (#1969)

* Initial plan

* Update all Microsoft.SemanticKernel packages to 1.67.*

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

* Remove unrelated changes to package-lock.json and yarn.lock

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>
2025-11-11 16:26:19 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cf7c9fce40 Bump OpenTelemetry.Instrumentation.Runtime from 1.12.0 to 1.13.0 (#1856)
---
updated-dependencies:
- dependency-name: OpenTelemetry.Instrumentation.Runtime
  dependency-version: 1.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-11 16:23:30 +00:00
SergeyMenshykhandGitHub 04b662543c .NET: Move catalog samples to the HostedAgents folder (#2090)
* move catalog samples to the HostedAgents folder

* move the catalog samples' projects to the HostedAgents folder
2025-11-11 15:40:58 +00:00
Evan MattsonandGitHub 32bd884bfd Python: Add concrete AGUIChatClient (#2072)
* Add concrete AGUIChatClient

* Update logging docstrings and conventions

* PR feedback

* Updates to support client-side tool calls
2025-11-11 14:39:30 +00:00
93ab43d788 .NET - Enable build & test on branch feature-foundry-agents (#2068)
* Tests good, mkay

* Update .github/workflows/dotnet-build-and-test.yml

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

* Enable feature build pipelines

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2025-11-11 10:53:35 +00:00
wuwengandGitHub 177b0c95be Update README.md to change Grafana links to Azure portal links for dashboard access (#1983) 2025-11-11 05:08:33 +00:00
Mark WallaceandGitHub 297d9d7fb3 Version 1.0.0-preview.251110.2 (#2067) 2025-11-11 00:23:01 +00:00
Mark WallaceandGitHub d3827e8c11 Version 1.0.0-preview.251107.2 (#2065) 2025-11-10 22:47:49 +00:00
Tao ChenandGitHub 448aff536a Python: Fix Model ID attribute not showing up in invoke_agent span (#2061)
* Best effort to surface the model id to invoke agent span

* Fix tests

* Fix tests
2025-11-10 22:32:04 +00:00
Reuben BondandGitHub 12fc19b360 DevUI: support having both an agent and a workflow with the same id in discovery (#2023) 2025-11-10 21:36:07 +00:00
7a45929807 Python: .Net: Dotnet devui compatibility fixes (#2026)
* DevUI: Add OpenAI Responses API proxy support with enhanced UI features

This commit adds support for proxying requests to OpenAI's Responses API,
allowing DevUI to route conversations to OpenAI models when configured to enable testing.

Backend changes:
- Add OpenAI proxy executor with conversation routing logic
- Enhance event mapper to support OpenAI Responses API format
- Extend server endpoints to handle OpenAI proxy mode
- Update models with OpenAI-specific response types
- Remove emojis from logging and CLI output for cleaner text

Frontend changes:
- Add settings modal with OpenAI proxy configuration UI
- Enhance agent and workflow views with improved state management
- Add new UI components (separator, switch) for settings
- Update debug panel with better event filtering
- Improve message renderers for OpenAI content types
- Update types and API client for OpenAI integration

* update ui, settings modal and workflow input form, add register cleanup hooks.

* add workflow HIL support, user mode, other fixes

* feat(devui): add human-in-the-loop (HIL) support with dynamic response schemas

Implement  HIL workflow support allowing workflows to pause for user input
with dynamically generated JSON schemas based on response handler type hints.

Key Features:
- Automatic response schema extraction from @response_handler decorators
- Dynamic form generation in UI based on Pydantic/dataclass response types
- Checkpoint-based conversation storage for HIL requests/responses
- Resume workflow execution after user provides HIL response

Backend Changes:
- Add extract_response_type_from_executor() to introspect response handlers
- Enrich RequestInfoEvent with response_schema via _enrich_request_info_event_with_response_schema()
- Map RequestInfoEvent to response.input.requested OpenAI event format
- Store HIL responses in conversation history and restore checkpoints

Frontend Changes:
- Add HILInputModal component with SchemaFormRenderer for dynamic forms
- Support Pydantic BaseModel and dataclass response types
- Render enum fields as dropdowns, strings as text/textarea, numbers, booleans, arrays, objects
- Display original request context alongside response form

Testing:
- Add  tests for checkpoint storage (test_checkpoints.py)
- Add schema generation tests for all input types (test_schema_generation.py)
- Validate end-to-end HIL flow with spam workflow sample

This enables workflows to seamlessly pause execution and request structured user input
with type-safe, validated forms generated automatically from response type annotations.

* improve HIL support, improve workflow execution view

* ui updates

* ui updates

* improve HIL for workflows, add auth and view modes

* update workflow

* security improvements , ui fixes

* fix mypy error

* update loading spinner in ui

* DevUI: Serialize workflow input as string to maintain conformance with OpenAI Responses format

* Phase 1: Add /meta endpoint and fix workflow event naming for .NET DevUI compatibility

* additional fixes for .NET DevUI workflow visualization item ID tracking

**Problem:**
.NET DevUI was generating different item IDs for ExecutorInvokedEvent and
ExecutorCompletedEvent, causing only the first executor to highlight in the
workflow graph. Long executor names and error messages also broke UI layout.

**Changes:**
- Add ExecutorActionItemResource to match Python DevUI implementation
- Track item IDs per executor using dictionary in AgentRunResponseUpdateExtensions
- Reuse same item ID across invoked/completed/failed events for proper pairing
- Add truncateText() utility to workflow-utils.ts
- Truncate executor names to 35 chars in execution timeline
- Truncate error messages to 150 chars in workflow graph nodes

** Details:**
- ExecutorActionItemResource registered with JSON source generation context
- Dictionary cleaned up after executor completion/failure to prevent memory leaks
- Frontend item tracking by unique item.id supports multiple executor runs
- All changes follow existing codebase patterns and conventions

Tested with review-workflow showing correct executor highlighting and state
transitions for sequential and concurrent executors.

* format fixes, remove cors tests

* remove unecessary attributes

---------

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Reuben Bond <reuben.bond@gmail.com>
2025-11-10 18:56:31 +00:00
westeyandGitHub c0c12df851 .NET: Separate all memory and rag samples into their own folders (#2000)
* Separate all memory and rag samples into their own folders

* Fix broken link.
2025-11-10 15:34:20 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6cc0e2a0d8 Bump AWSSDK.Extensions.Bedrock.MEAI from 4.0.4.2 to 4.0.4.6 (#2031)
---
updated-dependencies:
- dependency-name: AWSSDK.Extensions.Bedrock.MEAI
  dependency-version: 4.0.4.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 13:02:43 +00:00
01f3a3d881 .NET: Remove moved OpenAPI sample and point to SK one. (#1997)
* Remove moved OpenAPI sample and point to SK one.

* Update dotnet/samples/GettingStarted/Agents/README.md

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-10 12:29:26 +00:00
Mark WallaceandGitHub f41e103ee2 Version 1.0.0-preview.251110.1 (#2048) 2025-11-10 12:23:22 +00:00
Korolev DmitryandGitHub 866b4198bf .NET: assign AgentCard's URL to mapped-endpoint if not defined explicitly (#2047)
* fix serialization in chat completions on tools

* nit

* write e2e test for agent card resolve + adjust behavior

* nit
2025-11-10 12:06:59 +00:00
Korolev DmitryandGitHub 42da3cb6a4 .NET: fix ChatCompletions Tools serialization (#2043)
* fix serialization in chat completions on tools

* nit
2025-11-10 11:35:54 +00:00
Eduard van ValkenburgandGitHub e7224b5efb added missing chatAgent params (#2044) 2025-11-10 11:02:45 +00:00
Evan MattsonandGitHub 548e0f028e Python: Fix underlying tool choice bug and all for return to previous Handoff subagent (#2037)
* Fix tool_choice override bug and add enable_return_to_previous support

* Add unit test for handoff checkpointing

* Handle tools when we have them
2025-11-10 10:55:52 +00:00
Javier Calvarro NelsonandGitHub 45dc0ff073 .NET [AG-UI]: Adds support for shared state. (#1996)
* Product changes

* Tests

* Dojo project

* Cleanups
2025-11-10 09:50:11 +00:00
0e7183dbd8 DevUI: Prevent line breaks within words in the agent view (#2024)
Co-authored-by: Victor Dibia <chuvidi2003@gmail.com>
2025-11-10 01:16:24 +00:00
170 changed files with 8519 additions and 2123 deletions
+3 -3
View File
@@ -8,11 +8,11 @@ name: dotnet-build-and-test
on:
workflow_dispatch:
pull_request:
branches: ["main"]
branches: ["main", "feature*"]
merge_group:
branches: ["main"]
branches: ["main", "feature*"]
push:
branches: ["main"]
branches: ["main", "feature*"]
schedule:
- cron: "0 0 * * *" # Run at midnight UTC daily
@@ -39,7 +39,7 @@ jobs:
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
- name: Pytest coverage comment
id: coverageComment
uses: MishaKav/pytest-coverage-comment@v1.1.57
uses: MishaKav/pytest-coverage-comment@v1.1.59
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
issue-number: ${{ env.PR_NUMBER }}
+13 -13
View File
@@ -44,7 +44,7 @@
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
<!-- Microsoft.AspNetCore.* -->
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
@@ -68,15 +68,15 @@
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
<!-- Semantic Kernel -->
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.66.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Core" Version="1.66.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.OpenAI" Version="1.66.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.AzureAI" Version="1.66.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Plugins.OpenApi" Version="1.66.0" />
<!-- Vector Stores -->
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.66.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.66.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
<!-- Semantic Kernel -->
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.67.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Core" Version="1.67.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.OpenAI" Version="1.67.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.AzureAI" Version="1.67.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Plugins.OpenApi" Version="1.67.0" />
<!-- Agent SDKs -->
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.2.41" />
<!-- A2A -->
@@ -86,7 +86,7 @@
<PackageVersion Include="ModelContextProtocol" Version="0.4.0-preview.3" />
<!-- Inference SDKs -->
<PackageVersion Include="Anthropic.SDK" Version="5.8.0" />
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.2" />
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.6" />
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
<PackageVersion Include="OpenAI" Version="2.6.0" />
@@ -104,8 +104,8 @@
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="9.0.10" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageVersion Include="Moq" Version="[4.18.4]" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Abstractions" Version="1.66.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Yaml" Version="1.66.0-beta" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Abstractions" Version="1.67.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Yaml" Version="1.67.0-beta" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.abstractions" Version="2.0.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3" />
@@ -135,7 +135,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageVersion Include="Roslynator.Analyzers" Version="[4.14.0]" />
<PackageVersion Include="Roslynator.Analyzers" Version="[4.14.1]" />
<PackageReference Include="Roslynator.Analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+15 -12
View File
@@ -20,6 +20,7 @@
</Folder>
<Folder Name="/Samples/AGUIClientServer/">
<Project Path="samples/AGUIClientServer/AGUIClient/AGUIClient.csproj" />
<Project Path="samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj" />
<Project Path="samples/AGUIClientServer/AGUIServer/AGUIServer.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
@@ -47,8 +48,7 @@
<File Path="samples/GettingStarted/Agents/README.md" />
<Project Path="samples/GettingStarted/Agents/Agent_Step01_Running/Agent_Step01_Running.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Agent_Step02_MultiturnConversation.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step03.1_UsingFunctionTools/Agent_Step03.1_UsingFunctionTools.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step03.2_UsingFunctionTools_FromOpenAPI/Agent_Step03.2_UsingFunctionTools_FromOpenAPI.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Agent_Step03_UsingFunctionTools.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Agent_Step04_UsingFunctionToolsWithApprovals.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Agent_Step05_StructuredOutput.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Agent_Step06_PersistedConversations.csproj" />
@@ -58,20 +58,22 @@
<Project Path="samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step11_UsingImages/Agent_Step11_UsingImages.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Agent_Step12_AsFunctionTool.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step13_Memory/Agent_Step13_Memory.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step13_BackgroundResponsesWithToolsAndPersistence/Agent_Step13_BackgroundResponsesWithToolsAndPersistence.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step14_Middleware/Agent_Step14_Middleware.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step15_Plugins/Agent_Step15_Plugins.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Agent_Step16_ChatReduction.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Agent_Step17_BackgroundResponses.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Agent_Step18_TextSearchRag.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Agent_Step19_Mem0Provider.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/Agent_Step20_BackgroundResponsesWithToolsAndPersistence.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step21_ChatHistoryMemoryProvider/Agent_Step21_ChatHistoryMemoryProvider.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/DevUI/">
<File Path="samples/GettingStarted/DevUI/README.md" />
<Project Path="samples/GettingStarted/DevUI/DevUI_Step01_BasicUsage/DevUI_Step01_BasicUsage.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/AgentWithMemory/">
<File Path="samples/GettingStarted/AgentWithMemory/README.md" />
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj" />
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/AgentWithMemory_Step03_CustomMemory.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/AgentWithOpenAI/">
<File Path="samples/GettingStarted/AgentWithOpenAI/README.md" />
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj" />
@@ -80,7 +82,8 @@
<Folder Name="/Samples/GettingStarted/AgentWithRAG/">
<File Path="samples/GettingStarted/AgentWithRAG/README.md" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/AgentWithRAG_Step02_ExternalDataSourceRAG.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" />
</Folder>
<Folder Name="/Samples/GettingStarted/ModelContextProtocol/">
<File Path="samples/GettingStarted/ModelContextProtocol/README.md" />
@@ -155,10 +158,10 @@
<Project Path="samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj" />
<Project Path="samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj" />
</Folder>
<Folder Name="/Samples/Catalog/">
<Project Path="samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj" />
<Project Path="samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
<Project Path="samples/Catalog/DeepResearchAgent/DeepResearchAgent.csproj" />
<Folder Name="/Samples/HostedAgents/">
<Project Path="samples/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj" />
<Project Path="samples/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
<Project Path="samples/HostedAgents/DeepResearchAgent/DeepResearchAgent.csproj" />
</Folder>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
+3 -3
View File
@@ -2,9 +2,9 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251107.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251107.1</PackageVersion>
<GitTag>1.0.0-preview.251107.1</GitTag>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251110.2</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251110.2</PackageVersion>
<GitTag>1.0.0-preview.251110.2</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>b9c3f1e1-2fb4-5g29-0e52-53e2b7g9gf21</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
[JsonSerializable(typeof(WeatherInfo))]
[JsonSerializable(typeof(Recipe))]
[JsonSerializable(typeof(Ingredient))]
[JsonSerializable(typeof(RecipeResponse))]
internal sealed partial class AGUIDojoServerSerializerContext : JsonSerializerContext;
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ChatClient = OpenAI.Chat.ChatClient;
namespace AGUIDojoServer;
internal static class ChatClientAgentFactory
{
private static AzureOpenAIClient? s_azureOpenAIClient;
private static string? s_deploymentName;
public static void Initialize(IConfiguration configuration)
{
string endpoint = configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
s_deploymentName = configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
s_azureOpenAIClient = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential());
}
public static ChatClientAgent CreateAgenticChat()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "AgenticChat",
description: "A simple chat agent using Azure OpenAI");
}
public static ChatClientAgent CreateBackendToolRendering()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "BackendToolRenderer",
description: "An agent that can render backend tools using Azure OpenAI",
tools: [AIFunctionFactory.Create(
GetWeather,
name: "get_weather",
description: "Get the weather for a given location.",
AGUIDojoServerSerializerContext.Default.Options)]);
}
public static ChatClientAgent CreateHumanInTheLoop()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "HumanInTheLoopAgent",
description: "An agent that involves human feedback in its decision-making process using Azure OpenAI");
}
public static ChatClientAgent CreateToolBasedGenerativeUI()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "ToolBasedGenerativeUIAgent",
description: "An agent that uses tools to generate user interfaces using Azure OpenAI");
}
public static ChatClientAgent CreateAgenticUI()
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
return chatClient.AsIChatClient().CreateAIAgent(
name: "AgenticUIAgent",
description: "An agent that generates agentic user interfaces using Azure OpenAI");
}
public static AIAgent CreateSharedState(JsonSerializerOptions options)
{
ChatClient chatClient = s_azureOpenAIClient!.GetChatClient(s_deploymentName!);
var baseAgent = chatClient.AsIChatClient().CreateAIAgent(
name: "SharedStateAgent",
description: "An agent that demonstrates shared state patterns using Azure OpenAI");
return new SharedStateAgent(baseAgent, options);
}
[Description("Get the weather for a given location.")]
private static WeatherInfo GetWeather([Description("The location to get the weather for.")] string location) => new()
{
Temperature = 20,
Conditions = "sunny",
Humidity = 50,
WindSpeed = 10,
FeelsLike = 25
};
}
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
internal sealed class Ingredient
{
[JsonPropertyName("icon")]
public string Icon { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("amount")]
public string Amount { get; set; } = string.Empty;
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using AGUIDojoServer;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.AspNetCore.HttpLogging;
using Microsoft.Extensions.Options;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpLogging(logging =>
{
logging.LoggingFields = HttpLoggingFields.RequestPropertiesAndHeaders | HttpLoggingFields.RequestBody
| HttpLoggingFields.ResponsePropertiesAndHeaders | HttpLoggingFields.ResponseBody;
logging.RequestBodyLogLimit = int.MaxValue;
logging.ResponseBodyLogLimit = int.MaxValue;
});
builder.Services.AddHttpClient().AddLogging();
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIDojoServerSerializerContext.Default));
builder.Services.AddAGUI();
WebApplication app = builder.Build();
app.UseHttpLogging();
// Initialize the factory
ChatClientAgentFactory.Initialize(app.Configuration);
// Map the AG-UI agent endpoints for different scenarios
app.MapAGUI("/agentic_chat", ChatClientAgentFactory.CreateAgenticChat());
app.MapAGUI("/backend_tool_rendering", ChatClientAgentFactory.CreateBackendToolRendering());
app.MapAGUI("/human_in_the_loop", ChatClientAgentFactory.CreateHumanInTheLoop());
app.MapAGUI("/tool_based_generative_ui", ChatClientAgentFactory.CreateToolBasedGenerativeUI());
app.MapAGUI("/agentic_generative_ui", ChatClientAgentFactory.CreateAgenticUI());
var jsonOptions = app.Services.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>();
app.MapAGUI("/shared_state", ChatClientAgentFactory.CreateSharedState(jsonOptions.Value.SerializerOptions));
await app.RunAsync();
public partial class Program { }
@@ -0,0 +1,12 @@
{
"profiles": {
"AGUIDojoServer": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:5018"
}
}
}
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
internal sealed class Recipe
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("skill_level")]
public string SkillLevel { get; set; } = string.Empty;
[JsonPropertyName("cooking_time")]
public string CookingTime { get; set; } = string.Empty;
[JsonPropertyName("special_preferences")]
public List<string> SpecialPreferences { get; set; } = [];
[JsonPropertyName("ingredients")]
public List<Ingredient> Ingredients { get; set; } = [];
[JsonPropertyName("instructions")]
public List<string> Instructions { get; set; } = [];
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
#pragma warning disable CA1812 // Used for the JsonSchema response format
internal sealed class RecipeResponse
#pragma warning restore CA1812
{
[JsonPropertyName("recipe")]
public Recipe Recipe { get; set; } = new();
}
@@ -0,0 +1,106 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace AGUIDojoServer;
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by ChatClientAgentFactory.CreateSharedState")]
internal sealed class SharedStateAgent : DelegatingAIAgent
{
private readonly JsonSerializerOptions _jsonSerializerOptions;
public SharedStateAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions)
: base(innerAgent)
{
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } chatRunOptions ||
!properties.TryGetValue("ag_ui_state", out JsonElement state))
{
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
yield break;
}
var firstRunOptions = new ChatClientAgentRunOptions
{
ChatOptions = chatRunOptions.ChatOptions.Clone(),
AllowBackgroundResponses = chatRunOptions.AllowBackgroundResponses,
ContinuationToken = chatRunOptions.ContinuationToken,
ChatClientFactory = chatRunOptions.ChatClientFactory,
};
// Configure JSON schema response format for structured state output
firstRunOptions.ChatOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema<RecipeResponse>(
schemaName: "RecipeResponse",
schemaDescription: "A response containing a recipe with title, skill level, cooking time, preferences, ingredients, and instructions");
ChatMessage stateUpdateMessage = new(
ChatRole.System,
[
new TextContent("Here is the current state in JSON format:"),
new TextContent(state.GetRawText()),
new TextContent("The new state is:")
]);
var firstRunMessages = messages.Append(stateUpdateMessage);
var allUpdates = new List<AgentRunResponseUpdate>();
await foreach (var update in this.InnerAgent.RunStreamingAsync(firstRunMessages, thread, firstRunOptions, cancellationToken).ConfigureAwait(false))
{
allUpdates.Add(update);
// Yield all non-text updates (tool calls, etc.)
bool hasNonTextContent = update.Contents.Any(c => c is not TextContent);
if (hasNonTextContent)
{
yield return update;
}
}
var response = allUpdates.ToAgentRunResponse();
if (response.TryDeserialize(this._jsonSerializerOptions, out JsonElement stateSnapshot))
{
byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes(
stateSnapshot,
this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
yield return new AgentRunResponseUpdate
{
Contents = [new DataContent(stateBytes, "application/json")]
};
}
else
{
yield break;
}
var secondRunMessages = messages.Concat(response.Messages).Append(
new ChatMessage(
ChatRole.System,
[new TextContent("Please provide a concise summary of the state changes in at most two sentences.")]));
await foreach (var update in this.InnerAgent.RunStreamingAsync(secondRunMessages, thread, options, cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AGUIDojoServer;
internal sealed class WeatherInfo
{
[JsonPropertyName("temperature")]
public int Temperature { get; init; }
[JsonPropertyName("conditions")]
public string Conditions { get; init; } = string.Empty;
[JsonPropertyName("humidity")]
public int Humidity { get; init; }
[JsonPropertyName("wind_speed")]
public int WindSpeed { get; init; }
[JsonPropertyName("feelsLike")]
public int FeelsLike { get; init; }
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"
}
}
}
@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"
}
},
"AllowedHosts": "*"
}
@@ -107,8 +107,8 @@ app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Agents
app.UseExceptionHandler();
// attach a2a with simple message communication
app.MapA2A(agentName: "pirate", path: "/a2a/pirate");
app.MapA2A(agentName: "knights-and-knaves", path: "/a2a/knights-and-knaves", agentCard: new()
app.MapA2A(pirateAgentBuilder, path: "/a2a/pirate");
app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves", agentCard: new()
{
Name = "Knights and Knaves",
Description = "An agent that helps you solve the knights and knaves puzzle.",
@@ -142,11 +142,11 @@ You:
Besides the Aspire Dashboard and the Application Insights native UI, you can also use Grafana to visualize the telemetry data in Application Insights. There are two tailored dashboards for you to get started quickly:
### Agent Overview dashboard
Grafana Dashboard Gallery link: <https://aka.ms/amg/dash/af-agent>
Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-agent>
![Agent Overview dashboard](https://github.com/Azure/azure-managed-grafana/raw/main/samples/assets/grafana-af-agent.gif)
### Workflow Overview dashboard
Grafana Dashboard Gallery link: <https://aka.ms/amg/dash/af-workflow>
Open dashboard in Azure portal: <https://aka.ms/amg/dash/af-workflow>
![Workflow Overview dashboard](https://github.com/Azure/azure-managed-grafana/raw/main/samples/assets/grafana-af-workflow.gif)
## Key Features Demonstrated
@@ -0,0 +1,9 @@
# Agent Framework Retrieval Augmented Generation (RAG)
These samples show how to create an agent with the Agent Framework that uses Memory to remember previous conversations or facts from previous conversations.
|Sample|Description|
|---|---|
|[Chat History memory](./AgentWithMemory_Step01_ChatHistoryMemory/)|This sample demonstrates how to enable an agent to remember messages from previous conversations.|
|[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.|
|[Custom Memory Implementation](./AgentWithMemory_Step03_CustomMemory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use Qdrant to add retrieval augmented generation (RAG) capabilities to an AI agent.
// This sample shows how to use Qdrant with a custom schema to add retrieval augmented generation (RAG) capabilities to an AI agent.
// While the sample is using Qdrant, it can easily be replaced with any other vector store that implements the Microsoft.Extensions.VectorData abstractions.
// The TextSearchProvider runs a search against the vector store before each model invocation and injects the results into the model context.
@@ -1,11 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG)
// capabilities to an AI agent. The provider runs a search against an external knowledge base
// capabilities to an AI agent. This shows a mock implementation of a search function,
// which can be replaced with any custom search logic to query any external knowledge base.
// The provider invokes the custom search function
// before each model invocation and injects the results into the model context.
// Also see the AgentWithRAG folder for more advanced RAG scenarios.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
@@ -5,4 +5,5 @@ These samples show how to create an agent with the Agent Framework that uses Ret
|Sample|Description|
|---|---|
|[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 external Vector Store and custom schema](./AgentWithRAG_Step02_ExternalDataSourceRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with an external vector store. It also uses a custom schema for the documents stored in the vector store.|
|[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.|
@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.SemanticKernel.Plugins.OpenApi" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="OpenAPISpec.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -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,33 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use a ChatClientAgent 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.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Plugins.OpenApi;
using OpenAI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// 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();
// Create the chat client and agent, and provide the OpenAPI function tools to the agent.
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetChatClient(deploymentName)
.CreateAIAgent(instructions: "You are a helpful assistant", tools: tools);
// Run the agent with the OpenAPI function tools.
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."));
@@ -28,8 +28,8 @@ Before you begin, ensure you have the following prerequisites:
|---|---|
|[Running a simple agent](./Agent_Step01_Running/)|This sample demonstrates how to create and run a basic agent with instructions|
|[Multi-turn conversation with a simple agent](./Agent_Step02_MultiturnConversation/)|This sample demonstrates how to implement a multi-turn conversation with a simple agent|
|[Using function tools with a simple agent](./Agent_Step03.1_UsingFunctionTools/)|This sample demonstrates how to use function tools with a simple agent|
|[Using OpenAPI function tools with a simple agent](./Agent_Step03.2_UsingFunctionTools_FromOpenAPI/)|This sample demonstrates how to create function tools from an OpenAPI spec and use them with a simple agent|
|[Using function tools with a simple agent](./Agent_Step03_UsingFunctionTools/)|This sample demonstrates how to use function tools with a simple agent|
|[Using OpenAPI function tools with a simple agent](https://github.com/microsoft/semantic-kernel/tree/main/dotnet/samples/AgentFrameworkMigration/AzureOpenAI/Step04_ToolCall_WithOpenAPI)|This sample demonstrates how to create function tools from an OpenAPI spec and use them with a simple agent (note that this sample is in the Semantic Kernel repository)|
|[Using function tools with approvals](./Agent_Step04_UsingFunctionToolsWithApprovals/)|This sample demonstrates how to use function tools where approvals require human in the loop approvals before execution|
|[Structured output with a simple agent](./Agent_Step05_StructuredOutput/)|This sample demonstrates how to use structured output with a simple agent|
|[Persisted conversations with a simple agent](./Agent_Step06_PersistedConversations/)|This sample demonstrates how to persist conversations and reload them later. This is useful for cases where an agent is hosted in a stateless service|
@@ -39,14 +39,11 @@ Before you begin, ensure you have the following prerequisites:
|[Exposing a simple agent as MCP tool](./Agent_Step10_AsMcpTool/)|This sample demonstrates how to expose an agent as an MCP tool|
|[Using images with a simple agent](./Agent_Step11_UsingImages/)|This sample demonstrates how to use image multi-modality with an AI agent|
|[Exposing a simple agent as a function tool](./Agent_Step12_AsFunctionTool/)|This sample demonstrates how to expose an agent as a function tool|
|[Using memory with an agent](./Agent_Step13_Memory/)|This sample demonstrates how to create a simple memory component and use it with an agent|
|[Background responses with tools and persistence](./Agent_Step13_BackgroundResponsesWithToolsAndPersistence/)|This sample demonstrates advanced background response scenarios including function calling during background operations and state persistence|
|[Using middleware with an agent](./Agent_Step14_Middleware/)|This sample demonstrates how to use middleware with an agent|
|[Using plugins with an agent](./Agent_Step15_Plugins/)|This sample demonstrates how to use plugins with an agent|
|[Reducing chat history size](./Agent_Step16_ChatReduction/)|This sample demonstrates how to reduce the chat history to constrain its size, where chat history is maintained locally|
|[Background responses](./Agent_Step17_BackgroundResponses/)|This sample demonstrates how to use background responses for long-running operations with polling and resumption support|
|[Adding RAG with text search](./Agent_Step18_TextSearchRag/)|This sample demonstrates how to enrich agent responses with retrieval augmented generation using the text search provider|
|[Using Mem0-backed memory](./Agent_Step19_Mem0Provider/)|This sample demonstrates how to use the Mem0Provider to persist and recall memories across conversations|
|[Background responses with tools and persistence](./Agent_Step20_BackgroundResponsesWithToolsAndPersistence/)|This sample demonstrates advanced background response scenarios including function calling during background operations and state persistence|
## Running the samples from the console
+2
View File
@@ -9,6 +9,8 @@ of the agent framework.
|---|---|
|[Agents](./Agents/README.md)|Step by step instructions for getting started with agents|
|[Agent Providers](./AgentProviders/README.md)|Getting started with creating agents using various providers|
|[Agents With Retrieval Augmented Generation (RAG)](./AgentWithRAG/README.md)|Adding Retrieval Augmented Generation (RAG) capabilities to your agents.|
|[Agents With Memory](./AgentWithMemory/README.md)|Adding Memory capabilities to your agents.|
|[A2A](./A2A/README.md)|Getting started with A2A (Agent-to-Agent) specific features|
|[Agent Open Telemetry](./AgentOpenTelemetry/README.md)|Getting started with OpenTelemetry for agents|
|[Agent With OpenAI exchange types](./AgentWithOpenAI/README.md)|Using OpenAI exchange types with agents|
@@ -16,7 +16,7 @@ internal static class WorkflowFactory
internal static Workflow BuildWorkflow(IChatClient chatClient)
{
// Create executors
var startExecutor = new ConcurrentStartExecutor();
var startExecutor = new ChatForwardingExecutor("Start");
var aggregationExecutor = new ConcurrentAggregationExecutor();
AIAgent frenchAgent = GetLanguageAgent("French", chatClient);
AIAgent englishAgent = GetLanguageAgent("English", chatClient);
@@ -38,33 +38,11 @@ internal static class WorkflowFactory
private static ChatClientAgent GetLanguageAgent(string targetLanguage, IChatClient chatClient) =>
new(chatClient, instructions: $"You're a helpful assistant who always responds in {targetLanguage}.", name: $"{targetLanguage}Agent");
/// <summary>
/// Executor that starts the concurrent processing by sending messages to the agents.
/// </summary>
private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder
.AddHandler<List<ChatMessage>>(this.RouteMessages)
.AddHandler<TurnToken>(this.RouteTurnTokenAsync);
}
private ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
}
private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
}
}
/// <summary>
/// Executor that aggregates the results from the concurrent agents.
/// </summary>
private sealed class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
private sealed class ConcurrentAggregationExecutor() :
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor"), IResettableExecutor
{
private readonly List<ChatMessage> _messages = [];
@@ -85,5 +63,12 @@ internal static class WorkflowFactory
await context.YieldOutputAsync(formattedMessages, cancellationToken);
}
}
/// <inheritdoc/>
public ValueTask ResetAsync()
{
this._messages.Clear();
return default;
}
}
}
@@ -42,7 +42,7 @@ internal sealed class Program
Console.WriteLine(code);
}
private const string DefaultWorkflow = "HelloWorld.yaml";
private const string DefaultWorkflow = "Marketing.yaml";
private string WorkflowFile { get; }
@@ -92,11 +92,11 @@ The repository has example workflows available in the root [`/workflow-samples`]
2. Run the demo referencing a sample workflow by name:
```sh
dotnet run HelloWorld
dotnet run Marketing
```
3. Run the demo with a path to any workflow file:
```sh
dotnet run c:/myworkflows/HelloWorld.yaml
dotnet run c:/myworkflows/Marketing.yaml
```
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
@@ -152,6 +153,8 @@ public sealed class AGUIChatClient : DelegatingChatClient
private sealed class AGUIChatClientHandler : IChatClient
{
private static readonly MediaTypeHeaderValue s_json = new("application/json");
private readonly AGUIHttpService _httpService;
private readonly JsonSerializerOptions _jsonSerializerOptions;
private readonly ILogger _logger;
@@ -199,6 +202,9 @@ public sealed class AGUIChatClient : DelegatingChatClient
var threadId = ExtractTemporaryThreadId(messagesList) ??
ExtractThreadIdFromOptions(options) ?? $"thread_{Guid.NewGuid():N}";
// Extract state from the last message if it contains DataContent with application/json
JsonElement state = this.ExtractAndRemoveStateFromMessages(messagesList);
// Create the input for the AGUI service
var input = new RunAgentInput
{
@@ -207,6 +213,7 @@ public sealed class AGUIChatClient : DelegatingChatClient
ThreadId = threadId,
RunId = runId,
Messages = messagesList.AsAGUIMessages(this._jsonSerializerOptions),
State = state,
};
// Add tools if provided
@@ -300,6 +307,51 @@ public sealed class AGUIChatClient : DelegatingChatClient
return threadId;
}
// Extract state from the last message's DataContent with application/json media type
// and remove that message from the list
private JsonElement ExtractAndRemoveStateFromMessages(List<ChatMessage> messagesList)
{
if (messagesList.Count == 0)
{
return default;
}
// Check the last message for state DataContent
ChatMessage lastMessage = messagesList[messagesList.Count - 1];
for (int i = 0; i < lastMessage.Contents.Count; i++)
{
if (lastMessage.Contents[i] is DataContent dataContent &&
MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) &&
mediaType.Equals(s_json))
{
// Deserialize the state JSON directly from UTF-8 bytes
try
{
JsonElement stateElement = (JsonElement)JsonSerializer.Deserialize(
dataContent.Data.Span,
this._jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))!;
// Remove the DataContent from the message contents
lastMessage.Contents.RemoveAt(i);
// If no contents remain, remove the entire message
if (lastMessage.Contents.Count == 0)
{
messagesList.RemoveAt(messagesList.Count - 1);
}
return stateElement;
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Failed to deserialize state JSON from DataContent: {ex.Message}", ex);
}
}
}
return default;
}
public void Dispose()
{
// No resources to dispose
@@ -316,7 +368,7 @@ public sealed class AGUIChatClient : DelegatingChatClient
}
}
private class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent
private sealed class ServerFunctionCallContent(FunctionCallContent functionCall) : AIContent
{
public FunctionCallContent FunctionCallContent { get; } = functionCall;
}
@@ -27,4 +27,8 @@ internal static class AGUIEventTypes
public const string ToolCallEnd = "TOOL_CALL_END";
public const string ToolCallResult = "TOOL_CALL_RESULT";
public const string StateSnapshot = "STATE_SNAPSHOT";
public const string StateDelta = "STATE_DELTA";
}
@@ -44,6 +44,8 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(ToolCallArgsEvent))]
[JsonSerializable(typeof(ToolCallEndEvent))]
[JsonSerializable(typeof(ToolCallResultEvent))]
[JsonSerializable(typeof(StateSnapshotEvent))]
[JsonSerializable(typeof(StateDeltaEvent))]
[JsonSerializable(typeof(IDictionary<string, object?>))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(IDictionary<string, System.Text.Json.JsonElement?>))]
@@ -57,6 +59,6 @@ namespace Microsoft.Agents.AI.AGUI;
[JsonSerializable(typeof(float))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(decimal))]
internal partial class AGUIJsonSerializerContext : JsonSerializerContext
internal sealed partial class AGUIJsonSerializerContext : JsonSerializerContext
{
}
@@ -46,6 +46,7 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
AGUIEventTypes.ToolCallArgs => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallArgsEvent))) as ToolCallArgsEvent,
AGUIEventTypes.ToolCallEnd => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallEndEvent))) as ToolCallEndEvent,
AGUIEventTypes.ToolCallResult => jsonElement.Deserialize(options.GetTypeInfo(typeof(ToolCallResultEvent))) as ToolCallResultEvent,
AGUIEventTypes.StateSnapshot => jsonElement.Deserialize(options.GetTypeInfo(typeof(StateSnapshotEvent))) as StateSnapshotEvent,
_ => throw new JsonException($"Unknown BaseEvent type discriminator: '{discriminator}'")
};
@@ -95,8 +96,14 @@ internal sealed class BaseEventJsonConverter : JsonConverter<BaseEvent>
case ToolCallResultEvent toolCallResult:
JsonSerializer.Serialize(writer, toolCallResult, options.GetTypeInfo(typeof(ToolCallResultEvent)));
break;
case StateSnapshotEvent stateSnapshot:
JsonSerializer.Serialize(writer, stateSnapshot, options.GetTypeInfo(typeof(StateSnapshotEvent)));
break;
case StateDeltaEvent stateDelta:
JsonSerializer.Serialize(writer, stateDelta, options.GetTypeInfo(typeof(StateDeltaEvent)));
break;
default:
throw new JsonException($"Unknown BaseEvent type: {value.GetType().Name}");
throw new InvalidOperationException($"Unknown event type: {value.GetType().Name}");
}
}
}
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
@@ -18,6 +19,9 @@ namespace Microsoft.Agents.AI.AGUI.Shared;
internal static class ChatResponseUpdateAGUIExtensions
{
private static readonly MediaTypeHeaderValue? s_jsonPatchMediaType = new("application/json-patch+json");
private static readonly MediaTypeHeaderValue? s_json = new("application/json");
public static async IAsyncEnumerable<ChatResponseUpdate> AsChatResponseUpdatesAsync(
this IAsyncEnumerable<BaseEvent> events,
JsonSerializerOptions jsonSerializerOptions,
@@ -70,11 +74,73 @@ internal static class ChatResponseUpdateAGUIExtensions
case ToolCallResultEvent toolCallResult:
yield return toolCallAccumulator.EmitToolCallResult(toolCallResult, jsonSerializerOptions);
break;
// State snapshot events
case StateSnapshotEvent stateSnapshot:
if (stateSnapshot.Snapshot.HasValue)
{
yield return CreateStateSnapshotUpdate(stateSnapshot, conversationId, responseId, jsonSerializerOptions);
}
break;
case StateDeltaEvent stateDelta:
if (stateDelta.Delta.HasValue)
{
yield return CreateStateDeltaUpdate(stateDelta, conversationId, responseId, jsonSerializerOptions);
}
break;
}
}
}
private class TextMessageBuilder()
private static ChatResponseUpdate CreateStateSnapshotUpdate(
StateSnapshotEvent stateSnapshot,
string? conversationId,
string? responseId,
JsonSerializerOptions jsonSerializerOptions)
{
// Serialize JsonElement directly to UTF-8 bytes using AOT-safe overload
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(
stateSnapshot.Snapshot!.Value,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
DataContent dataContent = new(jsonBytes, "application/json");
return new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
{
ConversationId = conversationId,
ResponseId = responseId,
CreatedAt = DateTimeOffset.UtcNow,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["is_state_snapshot"] = true
}
};
}
private static ChatResponseUpdate CreateStateDeltaUpdate(
StateDeltaEvent stateDelta,
string? conversationId,
string? responseId,
JsonSerializerOptions jsonSerializerOptions)
{
// Serialize JsonElement directly to UTF-8 bytes using AOT-safe overload
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(
stateDelta.Delta!.Value,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)));
DataContent dataContent = new(jsonBytes, "application/json-patch+json");
return new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
{
ConversationId = conversationId,
ResponseId = responseId,
CreatedAt = DateTimeOffset.UtcNow,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["is_state_delta"] = true
}
};
}
private sealed class TextMessageBuilder()
{
private ChatRole _currentRole;
private string? _currentMessageId;
@@ -154,7 +220,7 @@ internal static class ChatResponseUpdateAGUIExtensions
};
}
private class ToolCallBuilder
private sealed class ToolCallBuilder
{
private string? _conversationId;
private string? _responseId;
@@ -348,6 +414,55 @@ internal static class ChatResponseUpdateAGUIExtensions
Role = AGUIRoles.Tool
};
}
else if (content is DataContent dataContent)
{
if (MediaTypeHeaderValue.TryParse(dataContent.MediaType, out var mediaType) && mediaType.Equals(s_json))
{
// State snapshot event
yield return new StateSnapshotEvent
{
#if NET472 || NETSTANDARD2_0
Snapshot = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.ToArray(),
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#else
Snapshot = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.Span,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#endif
};
}
else if (mediaType is { } && mediaType.Equals(s_jsonPatchMediaType))
{
// State snapshot patch event must be a valid JSON patch,
// but its not up to us to validate that here.
yield return new StateDeltaEvent
{
#if NET472 || NETSTANDARD2_0
Delta = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.ToArray(),
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#else
Delta = (JsonElement?)JsonSerializer.Deserialize(
dataContent.Data.Span,
jsonSerializerOptions.GetTypeInfo(typeof(JsonElement)))
#endif
};
}
else
{
// Text content event
yield return new TextMessageContentEvent
{
MessageId = chatResponse.MessageId!,
#if NET472 || NETSTANDARD2_0
Delta = Encoding.UTF8.GetString(dataContent.Data.ToArray())
#else
Delta = Encoding.UTF8.GetString(dataContent.Data.Span)
#endif
};
}
}
}
}
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class StateDeltaEvent : BaseEvent
{
public StateDeltaEvent()
{
this.Type = AGUIEventTypes.StateDelta;
}
[JsonPropertyName("delta")]
public JsonElement? Delta { get; set; }
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
#if ASPNETCORE
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
#else
namespace Microsoft.Agents.AI.AGUI.Shared;
#endif
internal sealed class StateSnapshotEvent : BaseEvent
{
public StateSnapshotEvent()
{
this.Type = AGUIEventTypes.StateSnapshot;
}
[JsonPropertyName("snapshot")]
public JsonElement? Snapshot { get; set; }
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
@@ -32,6 +33,7 @@ public class AgentRunOptions
_ = Throw.IfNull(options);
this.ContinuationToken = options.ContinuationToken;
this.AllowBackgroundResponses = options.AllowBackgroundResponses;
this.AdditionalProperties = options.AdditionalProperties?.Clone();
}
/// <summary>
@@ -74,4 +76,18 @@ public class AgentRunOptions
/// </para>
/// </remarks>
public bool? AllowBackgroundResponses { get; set; }
/// <summary>
/// Gets or sets additional properties associated with these options.
/// </summary>
/// <value>
/// An <see cref="AdditionalPropertiesDictionary"/> containing custom properties,
/// or <see langword="null"/> if no additional properties are present.
/// </value>
/// <remarks>
/// Additional properties provide a way to include custom metadata or provider-specific
/// information that doesn't fit into the standard options schema. This is useful for
/// preserving implementation-specific details or extending the options with custom data.
/// </remarks>
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
}
@@ -32,6 +32,7 @@ public static class DevUIExtensions
{
var group = endpoints.MapGroup("");
group.MapDevUI(pattern: "/devui");
group.MapMeta();
group.MapEntities();
return group;
}
@@ -15,10 +15,12 @@ namespace Microsoft.Agents.AI.DevUI.Entities;
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(EntityInfo))]
[JsonSerializable(typeof(DiscoveryResponse))]
[JsonSerializable(typeof(MetaResponse))]
[JsonSerializable(typeof(EnvVarRequirement))]
[JsonSerializable(typeof(List<EntityInfo>))]
[JsonSerializable(typeof(List<JsonElement>))]
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]
[JsonSerializable(typeof(Dictionary<string, bool>))]
[JsonSerializable(typeof(JsonElement))]
[ExcludeFromCodeCoverage]
internal sealed partial class EntitiesJsonContext : JsonSerializerContext;
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DevUI.Entities;
/// <summary>
/// Server metadata response for the /meta endpoint.
/// Provides information about the DevUI server configuration, capabilities, and requirements.
/// </summary>
/// <remarks>
/// This response is used by the frontend to:
/// - Determine the UI mode (developer vs user interface)
/// - Check server capabilities (tracing, OpenAI proxy support)
/// - Verify authentication requirements
/// - Display framework and version information
/// </remarks>
internal sealed record MetaResponse
{
/// <summary>
/// Gets the UI interface mode.
/// "developer" shows debug tools and advanced features, "user" shows a simplified interface.
/// </summary>
[JsonPropertyName("ui_mode")]
public string UiMode { get; init; } = "developer";
/// <summary>
/// Gets the DevUI version string.
/// </summary>
[JsonPropertyName("version")]
public string Version { get; init; } = "0.1.0";
/// <summary>
/// Gets the backend framework identifier.
/// Always "agent_framework" for Agent Framework implementations.
/// </summary>
[JsonPropertyName("framework")]
public string Framework { get; init; } = "agent_framework";
/// <summary>
/// Gets the backend runtime/language.
/// "dotnet" for .NET implementations, "python" for Python implementations.
/// Used by frontend for deployment guides and feature availability.
/// </summary>
[JsonPropertyName("runtime")]
public string Runtime { get; init; } = "dotnet";
/// <summary>
/// Gets the server capabilities dictionary.
/// Key-value pairs indicating which optional features are enabled.
/// </summary>
/// <remarks>
/// Standard capability keys:
/// - "tracing": Whether trace events are emitted for debugging
/// - "openai_proxy": Whether the server can proxy requests to OpenAI
/// </remarks>
[JsonPropertyName("capabilities")]
public Dictionary<string, bool> Capabilities { get; init; } = new();
/// <summary>
/// Gets a value indicating whether Bearer token authentication is required for API access.
/// When true, clients must include "Authorization: Bearer {token}" header in requests.
/// </summary>
[JsonPropertyName("auth_required")]
public bool AuthRequired { get; init; }
}
@@ -1,9 +1,11 @@
// 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;
namespace Microsoft.Agents.AI.DevUI;
@@ -56,79 +58,19 @@ internal static class EntitiesApiExtensions
{
var entities = new List<EntityInfo>();
// Discover agents from the agent catalog
if (agentCatalog is not null)
// Discover agents
await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
{
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
{
if (agent.GetType().Name == "WorkflowHostAgent")
{
// HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows,
// and workflows are handled below.
continue;
}
entities.Add(new EntityInfo(
Id: agent.Name ?? agent.Id,
Type: "agent",
Name: agent.Name ?? agent.Id,
Description: agent.Description,
Framework: "agent-framework",
Tools: null,
Metadata: []
)
{
Source = "in_memory"
});
}
entities.Add(agentInfo);
}
// Discover workflows from the workflow catalog
if (workflowCatalog is not null)
// Discover workflows
await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false))
{
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
{
// Extract executor IDs from the workflow structure
var executorIds = new HashSet<string> { workflow.StartExecutorId };
var reflectedEdges = workflow.ReflectEdges();
foreach (var (sourceId, edgeSet) in reflectedEdges)
{
executorIds.Add(sourceId);
foreach (var edge in edgeSet)
{
foreach (var sinkId in edge.Connection.SinkIds)
{
executorIds.Add(sinkId);
}
}
}
// Create a default input schema (string type)
var defaultInputSchema = new Dictionary<string, object>
{
["type"] = "string"
};
entities.Add(new EntityInfo(
Id: workflow.Name ?? workflow.StartExecutorId,
Type: "workflow",
Name: workflow.Name ?? workflow.StartExecutorId,
Description: workflow.Description,
Framework: "agent-framework",
Tools: [.. executorIds],
Metadata: []
)
{
Source = "in_memory",
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
InputTypeName = "string",
StartExecutorId = workflow.StartExecutorId
});
}
entities.Add(workflowInfo);
}
return Results.Json(new DiscoveryResponse(entities), EntitiesJsonContext.Default.DiscoveryResponse);
return Results.Json(new DiscoveryResponse([.. entities]), EntitiesJsonContext.Default.DiscoveryResponse);
}
catch (Exception ex)
{
@@ -141,93 +83,26 @@ internal static class EntitiesApiExtensions
private static async Task<IResult> GetEntityInfoAsync(
string entityId,
string? type,
AgentCatalog? agentCatalog,
WorkflowCatalog? workflowCatalog,
CancellationToken cancellationToken)
{
try
{
// Try to find the entity among discovered agents
if (agentCatalog is not null)
if (type is null || string.Equals(type, "agent", StringComparison.OrdinalIgnoreCase))
{
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityId, cancellationToken).ConfigureAwait(false))
{
if (agent.GetType().Name == "WorkflowHostAgent")
{
// HACK: ignore WorkflowHostAgent instances as they are just wrappers around workflows,
// and workflows are handled below.
continue;
}
if (string.Equals(agent.Name, entityId, StringComparison.OrdinalIgnoreCase) ||
string.Equals(agent.Id, entityId, StringComparison.OrdinalIgnoreCase))
{
var entityInfo = new EntityInfo(
Id: agent.Name ?? agent.Id,
Type: "agent",
Name: agent.Name ?? agent.Id,
Description: agent.Description,
Framework: "agent-framework",
Tools: null,
Metadata: []
)
{
Source = "in_memory"
};
return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo);
}
return Results.Json(agentInfo, EntitiesJsonContext.Default.EntityInfo);
}
}
// Try to find the entity among discovered workflows
if (workflowCatalog is not null)
if (type is null || string.Equals(type, "workflow", StringComparison.OrdinalIgnoreCase))
{
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityId, cancellationToken).ConfigureAwait(false))
{
var workflowId = workflow.Name ?? workflow.StartExecutorId;
if (string.Equals(workflowId, entityId, StringComparison.OrdinalIgnoreCase))
{
// Extract executor IDs from the workflow structure
var executorIds = new HashSet<string> { workflow.StartExecutorId };
var reflectedEdges = workflow.ReflectEdges();
foreach (var (sourceId, edgeSet) in reflectedEdges)
{
executorIds.Add(sourceId);
foreach (var edge in edgeSet)
{
foreach (var sinkId in edge.Connection.SinkIds)
{
executorIds.Add(sinkId);
}
}
}
// Create a default input schema (string type)
var defaultInputSchema = new Dictionary<string, object>
{
["type"] = "string"
};
var entityInfo = new EntityInfo(
Id: workflowId,
Type: "workflow",
Name: workflow.Name ?? workflow.StartExecutorId,
Description: workflow.Description,
Framework: "agent-framework",
Tools: [.. executorIds],
Metadata: []
)
{
Source = "in_memory",
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
InputTypeName = "Input",
StartExecutorId = workflow.StartExecutorId
};
return Results.Json(entityInfo, EntitiesJsonContext.Default.EntityInfo);
}
return Results.Json(workflowInfo, EntitiesJsonContext.Default.EntityInfo);
}
}
@@ -241,4 +116,123 @@ internal static class EntitiesApiExtensions
title: "Error getting entity info");
}
}
private static async IAsyncEnumerable<EntityInfo> DiscoverAgentsAsync(
AgentCatalog? agentCatalog,
string? entityIdFilter,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (agentCatalog is null)
{
yield break;
}
await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false))
{
// If filtering by entity ID, skip non-matching agents
if (entityIdFilter is not null &&
!string.Equals(agent.Name, entityIdFilter, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(agent.Id, entityIdFilter, StringComparison.OrdinalIgnoreCase))
{
continue;
}
yield return CreateAgentEntityInfo(agent);
// If we found the entity we're looking for, we're done
if (entityIdFilter is not null)
{
yield break;
}
}
}
private static async IAsyncEnumerable<EntityInfo> DiscoverWorkflowsAsync(
WorkflowCatalog? workflowCatalog,
string? entityIdFilter,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (workflowCatalog is null)
{
yield break;
}
await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false))
{
var workflowId = workflow.Name ?? workflow.StartExecutorId;
// If filtering by entity ID, skip non-matching workflows
if (entityIdFilter is not null && !string.Equals(workflowId, entityIdFilter, StringComparison.OrdinalIgnoreCase))
{
continue;
}
yield return CreateWorkflowEntityInfo(workflow);
// If we found the entity we're looking for, we're done
if (entityIdFilter is not null)
{
yield break;
}
}
}
private static EntityInfo CreateAgentEntityInfo(AIAgent agent)
{
var entityId = agent.Name ?? agent.Id;
return new EntityInfo(
Id: entityId,
Type: "agent",
Name: entityId,
Description: agent.Description,
Framework: "agent-framework",
Tools: null,
Metadata: []
)
{
Source = "in_memory"
};
}
private static EntityInfo CreateWorkflowEntityInfo(Workflow workflow)
{
// Extract executor IDs from the workflow structure
var executorIds = new HashSet<string> { workflow.StartExecutorId };
var reflectedEdges = workflow.ReflectEdges();
foreach (var (sourceId, edgeSet) in reflectedEdges)
{
executorIds.Add(sourceId);
foreach (var edge in edgeSet)
{
foreach (var sinkId in edge.Connection.SinkIds)
{
executorIds.Add(sinkId);
}
}
}
// Create a default input schema (string type)
var defaultInputSchema = new Dictionary<string, object>
{
["type"] = "string"
};
var workflowId = workflow.Name ?? workflow.StartExecutorId;
return new EntityInfo(
Id: workflowId,
Type: "workflow",
Name: workflowId,
Description: workflow.Description,
Framework: "agent-framework",
Tools: [.. executorIds],
Metadata: []
)
{
Source = "in_memory",
WorkflowDump = JsonSerializer.SerializeToElement(workflow.ToDevUIDict()),
InputSchema = JsonSerializer.SerializeToElement(defaultInputSchema),
InputTypeName = "string",
StartExecutorId = workflow.StartExecutorId
};
}
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DevUI.Entities;
namespace Microsoft.Agents.AI.DevUI;
/// <summary>
/// Provides extension methods for mapping the server metadata endpoint to an <see cref="IEndpointRouteBuilder"/>.
/// </summary>
internal static class MetaApiExtensions
{
/// <summary>
/// Maps the HTTP API endpoint for retrieving server metadata.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <returns>The <see cref="IEndpointConventionBuilder"/> for method chaining.</returns>
/// <remarks>
/// This extension method registers the following endpoint:
/// <list type="bullet">
/// <item><description>GET /meta - Retrieve server metadata including UI mode, version, capabilities, and auth requirements</description></item>
/// </list>
/// The endpoint is compatible with the Python DevUI frontend and provides essential
/// configuration information needed for proper frontend initialization.
/// </remarks>
public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints)
{
return endpoints.MapGet("/meta", GetMeta)
.WithName("GetMeta")
.WithSummary("Get server metadata and configuration")
.WithDescription("Returns server metadata including UI mode, version, framework identifier, capabilities, and authentication requirements. Used by the frontend for initialization and feature detection.")
.Produces<MetaResponse>(StatusCodes.Status200OK, contentType: "application/json");
}
private static IResult GetMeta()
{
// TODO: Consider making these configurable via IOptions<DevUIOptions>
// For now, using sensible defaults that match Python DevUI behavior
var meta = new MetaResponse
{
UiMode = "developer", // Could be made configurable to support "user" mode
Version = "0.1.0", // TODO: Extract from assembly version attribute
Framework = "agent_framework",
Runtime = "dotnet", // .NET runtime for deployment guides
Capabilities = new Dictionary<string, bool>
{
// Tracing capability - will be enabled when trace event support is added
["tracing"] = false,
// OpenAI proxy capability - not currently supported in .NET DevUI
["openai_proxy"] = false,
// Deployment capability - not currently supported in .NET DevUI
["deployment"] = false
},
AuthRequired = false // Could be made configurable based on authentication middleware
};
return Results.Json(meta, EntitiesJsonContext.Default.MetaResponse);
}
}
@@ -83,7 +83,16 @@ public static class AIAgentExtensions
{
// A2A SDK assigns the url on its own
// we can help user if they did not set Url explicitly.
agentCard.Url ??= context;
if (string.IsNullOrEmpty(agentCard.Url))
{
var agentCardUrl = context.TrimEnd('/');
if (!context.EndsWith("/v1/card", StringComparison.Ordinal))
{
agentCardUrl += "/v1/card";
}
agentCard.Url = agentCardUrl;
}
return Task.FromResult(agentCard);
};
@@ -44,22 +44,27 @@ public static class AGUIEndpointRouteBuilderExtensions
var jsonSerializerOptions = jsonOptions.Value.SerializerOptions;
var messages = input.Messages.AsChatMessages(jsonSerializerOptions);
var agent = aiAgent;
var clientTools = input.Tools?.AsAITools().ToList();
ChatClientAgentRunOptions? runOptions = null;
List<AITool>? clientTools = input.Tools?.AsAITools().ToList();
if (clientTools?.Count > 0)
// Create run options with AG-UI context in AdditionalProperties
var runOptions = new ChatClientAgentRunOptions
{
runOptions = new ChatClientAgentRunOptions
ChatOptions = new ChatOptions
{
ChatOptions = new ChatOptions
Tools = clientTools,
AdditionalProperties = new AdditionalPropertiesDictionary
{
Tools = clientTools
["ag_ui_state"] = input.State,
["ag_ui_context"] = input.Context?.Select(c => new KeyValuePair<string, string>(c.Description, c.Value)).ToArray(),
["ag_ui_forwarded_properties"] = input.ForwardedProperties,
["ag_ui_thread_id"] = input.ThreadId,
["ag_ui_run_id"] = input.RunId
}
};
}
}
};
var events = agent.RunStreamingAsync(
// Run the agent and convert to AG-UI events
var events = aiAgent.RunStreamingAsync(
messages,
options: runOptions,
cancellationToken: cancellationToken)
@@ -18,7 +18,7 @@ internal abstract record Tool
/// <summary>
/// The type of the tool.
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore]
public abstract string Type { get; }
}
@@ -30,7 +30,7 @@ internal sealed record FunctionTool : Tool
/// <summary>
/// The type of the tool. Always "function".
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore]
public override string Type => "function";
/// <summary>
@@ -88,7 +88,7 @@ internal sealed record CustomTool : Tool
/// <summary>
/// The type of the tool. Always "custom".
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore]
public override string Type => "custom";
/// <summary>
@@ -109,6 +109,7 @@ internal static class OpenAIHostingJsonUtilities
[JsonSerializable(typeof(MCPApprovalRequestItemResource))]
[JsonSerializable(typeof(MCPApprovalResponseItemResource))]
[JsonSerializable(typeof(MCPCallItemResource))]
[JsonSerializable(typeof(ExecutorActionItemResource))]
[JsonSerializable(typeof(List<ItemResource>))]
// ItemParam types
[JsonSerializable(typeof(ItemParam))]
@@ -45,6 +45,9 @@ internal static class AgentRunResponseUpdateExtensions
var updateEnumerator = updates.GetAsyncEnumerator(cancellationToken);
await using var _ = updateEnumerator.ConfigureAwait(false);
// Track active item IDs by executor ID to pair invoked/completed/failed events
Dictionary<string, string> executorItemIds = [];
AgentRunResponseUpdate? previousUpdate = null;
StreamingEventGenerator? generator = null;
while (await updateEnumerator.MoveNextAsync().ConfigureAwait(false))
@@ -55,7 +58,92 @@ internal static class AgentRunResponseUpdateExtensions
// Special-case for agent framework workflow events.
if (update.RawRepresentation is WorkflowEvent workflowEvent)
{
yield return CreateWorkflowEventResponse(workflowEvent, seq.Increment(), outputIndex);
// Convert executor events to standard OpenAI output_item events
if (workflowEvent is ExecutorInvokedEvent invokedEvent)
{
var itemId = IdGenerator.NewId(prefix: "item");
// Store the item ID for this executor so we can reuse it for completion/failure
executorItemIds[invokedEvent.ExecutorId] = itemId;
var item = new ExecutorActionItemResource
{
Id = itemId,
ExecutorId = invokedEvent.ExecutorId,
Status = "in_progress",
CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
yield return new StreamingOutputItemAdded
{
SequenceNumber = seq.Increment(),
OutputIndex = outputIndex,
Item = item
};
}
else if (workflowEvent is ExecutorCompletedEvent completedEvent)
{
// Reuse the item ID from the invoked event, or generate a new one if not found
var itemId = executorItemIds.TryGetValue(completedEvent.ExecutorId, out var existingId)
? existingId
: IdGenerator.NewId(prefix: "item");
// Remove from tracking as this executor run is now complete
executorItemIds.Remove(completedEvent.ExecutorId);
JsonElement? resultData = null;
if (completedEvent.Data != null && JsonSerializer.IsReflectionEnabledByDefault)
{
resultData = JsonSerializer.SerializeToElement(
completedEvent.Data,
OpenAIHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
}
var item = new ExecutorActionItemResource
{
Id = itemId,
ExecutorId = completedEvent.ExecutorId,
Status = "completed",
Result = resultData,
CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
yield return new StreamingOutputItemDone
{
SequenceNumber = seq.Increment(),
OutputIndex = outputIndex,
Item = item
};
}
else if (workflowEvent is ExecutorFailedEvent failedEvent)
{
// Reuse the item ID from the invoked event, or generate a new one if not found
var itemId = executorItemIds.TryGetValue(failedEvent.ExecutorId, out var existingId)
? existingId
: IdGenerator.NewId(prefix: "item");
// Remove from tracking as this executor run has now failed
executorItemIds.Remove(failedEvent.ExecutorId);
var item = new ExecutorActionItemResource
{
Id = itemId,
ExecutorId = failedEvent.ExecutorId,
Status = "failed",
Error = failedEvent.Data?.ToString(),
CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
yield return new StreamingOutputItemDone
{
SequenceNumber = seq.Increment(),
OutputIndex = outputIndex,
Item = item
};
}
else
{
// For other workflow events (not executor-specific), keep the old format as fallback
yield return CreateWorkflowEventResponse(workflowEvent, seq.Increment(), outputIndex);
}
continue;
}
@@ -45,6 +45,7 @@ internal sealed class ItemResourceConverter : JsonConverter<ItemResource>
MCPApprovalRequestItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPApprovalRequestItemResource),
MCPApprovalResponseItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPApprovalResponseItemResource),
MCPCallItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.MCPCallItemResource),
ExecutorActionItemResource.ItemType => doc.Deserialize(OpenAIHostingJsonContext.Default.ExecutorActionItemResource),
_ => null
};
}
@@ -106,6 +107,9 @@ internal sealed class ItemResourceConverter : JsonConverter<ItemResource>
case MCPCallItemResource mcpCall:
JsonSerializer.Serialize(writer, mcpCall, OpenAIHostingJsonContext.Default.MCPCallItemResource);
break;
case ExecutorActionItemResource executorAction:
JsonSerializer.Serialize(writer, executorAction, OpenAIHostingJsonContext.Default.ExecutorActionItemResource);
break;
default:
throw new JsonException($"Unknown item type: {value.GetType().Name}");
}
@@ -888,3 +888,47 @@ internal sealed class MCPCallItemResource : ItemResource
[JsonPropertyName("error")]
public string? Error { get; init; }
}
/// <summary>
/// An executor action item resource for workflow execution visualization.
/// </summary>
internal sealed class ExecutorActionItemResource : ItemResource
{
/// <summary>
/// The constant item type identifier for executor action items.
/// </summary>
public const string ItemType = "executor_action";
/// <inheritdoc/>
public override string Type => ItemType;
/// <summary>
/// The executor identifier.
/// </summary>
[JsonPropertyName("executor_id")]
public required string ExecutorId { get; init; }
/// <summary>
/// The execution status: "in_progress", "completed", "failed", or "cancelled".
/// </summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
/// <summary>
/// The executor result data (for completed status).
/// </summary>
[JsonPropertyName("result")]
public JsonElement? Result { get; init; }
/// <summary>
/// The error message (for failed status).
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
/// <summary>
/// The creation timestamp.
/// </summary>
[JsonPropertyName("created_at")]
public long CreatedAt { get; init; }
}
@@ -565,7 +565,7 @@ internal sealed class StreamingWorkflowEventComplete : StreamingResponseEvent
/// <summary>
/// The constant event type identifier for workflow event events.
/// </summary>
public const string EventType = "response.workflow_event.complete";
public const string EventType = "response.workflow_event.completed";
/// <inheritdoc/>
[JsonIgnore]
@@ -0,0 +1,71 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides configuration options for <see cref="ChatForwardingExecutor"/>.
/// </summary>
public class ChatForwardingExecutorOptions
{
/// <summary>
/// Gets or sets the chat role to use when converting string messages to <see cref="ChatMessage"/> instances.
/// If set, the executor will accept string messages and convert them to chat messages with this role.
/// </summary>
public ChatRole? StringMessageChatRole { get; set; }
}
/// <summary>
/// A ChatProtocol executor that forwards all messages it receives. Useful for splitting inputs into parallel
/// processing paths.
/// </summary>
/// <remarks>This executor is designed to be cross-run shareable and can be reset to its initial state. It handles
/// multiple chat-related types, enabling flexible message forwarding scenarios. Thread safety and reusability are
/// ensured by its design.</remarks>
/// <param name="id">The unique identifier for the executor instance. Used to distinguish this executor within the system.</param>
/// <param name="options">Optional configuration settings for the executor. If null, default options are used.</param>
public sealed class ChatForwardingExecutor(string id, ChatForwardingExecutorOptions? options = null) : Executor(id, declareCrossRunShareable: true), IResettableExecutor
{
private readonly ChatRole? _stringMessageChatRole = options?.StringMessageChatRole;
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
if (this._stringMessageChatRole.HasValue)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message)));
}
return routeBuilder.AddHandler<ChatMessage>(ForwardMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(ForwardMessagesAsync)
.AddHandler<ChatMessage[]>(ForwardMessagesAsync)
.AddHandler<List<ChatMessage>>(ForwardMessagesAsync)
.AddHandler<TurnToken>(ForwardTurnTokenAsync);
}
private static ValueTask ForwardMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> context.SendMessageAsync(message, cancellationToken);
// Note that this can be used to split a turn into multiple parallel turns taken, which will cause streaming ChatMessages
// to overlap.
private static ValueTask ForwardTurnTokenAsync(TurnToken message, IWorkflowContext context, CancellationToken cancellationToken)
=> context.SendMessageAsync(message, cancellationToken);
// TODO: This is not ideal, but until we have a way of guaranteeing correct routing of interfaces across serialization
// boundaries, we need to do type unification. It behaves better when used as a handler in ChatProtocolExecutor because
// it is a strictly contravariant use, whereas this forces invariance on the type because it is directly forwarded.
private static ValueTask ForwardMessagesAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
=> context.SendMessageAsync(messages is List<ChatMessage> messageList ? messageList : messages.ToList(), cancellationToken);
private static ValueTask ForwardMessagesAsync(ChatMessage[] messages, IWorkflowContext context, CancellationToken cancellationToken)
=> context.SendMessageAsync(messages, cancellationToken);
/// <inheritdoc/>
public ValueTask ResetAsync() => default;
}
@@ -1,20 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Executor that forwards all messages.</summary>
internal sealed class ChatForwardingExecutor(string id) : Executor(id, declareCrossRunShareable: true), IResettableExecutor
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<string>((message, context, cancellationToken) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message), cancellationToken: cancellationToken))
.AddHandler<ChatMessage>((message, context, cancellationToken) => context.SendMessageAsync(message, cancellationToken: cancellationToken))
.AddHandler<List<ChatMessage>>((messages, context, cancellationToken) => context.SendMessageAsync(messages, cancellationToken: cancellationToken))
.AddHandler<TurnToken>((turnToken, context, cancellationToken) => context.SendMessageAsync(turnToken, cancellationToken: cancellationToken));
public ValueTask ResetAsync() => default;
}
@@ -1282,6 +1282,312 @@ public sealed class AGUIAgentTests
// AG-UI requirement: full history on every turn (which happens when ConversationId is null for FunctionInvokingChatClient)
Assert.True(captureHandler.RequestWasMade);
}
[Fact]
public async Task GetStreamingResponseAsync_ExtractsStateFromDataContent_AndRemovesStateMessageAsync()
{
// Arrange
var stateData = new { counter = 42, status = "active" };
string stateJson = JsonSerializer.Serialize(stateData);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
var dataContent = new DataContent(stateBytes, "application/json");
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Response" },
new TextMessageEndEvent { MessageId = "msg1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.System, [dataContent])
];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.NotNull(captureHandler.CapturedState);
Assert.Equal(42, captureHandler.CapturedState.Value.GetProperty("counter").GetInt32());
Assert.Equal("active", captureHandler.CapturedState.Value.GetProperty("status").GetString());
// Verify state message was removed - only user message should be in the request
Assert.Equal(1, captureHandler.CapturedMessageCount);
}
[Fact]
public async Task GetStreamingResponseAsync_WithNoStateDataContent_SendsEmptyStateAsync()
{
// Arrange
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Response" },
new TextMessageEndEvent { MessageId = "msg1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.Null(captureHandler.CapturedState);
}
[Fact]
public async Task GetStreamingResponseAsync_WithMalformedStateJson_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
byte[] invalidJson = System.Text.Encoding.UTF8.GetBytes("{invalid json");
var dataContent = new DataContent(invalidJson, "application/json");
using HttpClient httpClient = this.CreateMockHttpClient([]);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.System, [dataContent])
];
// Act & Assert
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
});
Assert.Contains("Failed to deserialize state JSON", ex.Message);
}
[Fact]
public async Task GetStreamingResponseAsync_WithEmptyStateObject_SendsEmptyObjectAsync()
{
// Arrange
var emptyState = new { };
string stateJson = JsonSerializer.Serialize(emptyState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
var dataContent = new DataContent(stateBytes, "application/json");
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.System, [dataContent])
];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.NotNull(captureHandler.CapturedState);
Assert.Equal(JsonValueKind.Object, captureHandler.CapturedState.Value.ValueKind);
}
[Fact]
public async Task GetStreamingResponseAsync_OnlyProcessesDataContentFromLastMessage_IgnoresEarlierOnesAsync()
{
// Arrange
var oldState = new { counter = 10 };
string oldStateJson = JsonSerializer.Serialize(oldState);
byte[] oldStateBytes = System.Text.Encoding.UTF8.GetBytes(oldStateJson);
var oldDataContent = new DataContent(oldStateBytes, "application/json");
var newState = new { counter = 20 };
string newStateJson = JsonSerializer.Serialize(newState);
byte[] newStateBytes = System.Text.Encoding.UTF8.GetBytes(newStateJson);
var newDataContent = new DataContent(newStateBytes, "application/json");
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "First message"),
new ChatMessage(ChatRole.System, [oldDataContent]),
new ChatMessage(ChatRole.User, "Second message"),
new ChatMessage(ChatRole.System, [newDataContent])
];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.NotNull(captureHandler.CapturedState);
// Should use the new state from the last message
Assert.Equal(20, captureHandler.CapturedState.Value.GetProperty("counter").GetInt32());
// Should have removed only the last state message
Assert.Equal(3, captureHandler.CapturedMessageCount);
}
[Fact]
public async Task GetStreamingResponseAsync_WithNonJsonMediaType_IgnoresDataContentAsync()
{
// Arrange
byte[] imageData = System.Text.Encoding.UTF8.GetBytes("fake image data");
var dataContent = new DataContent(imageData, "image/png");
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, [new TextContent("Hello"), dataContent])
];
// Act
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert
Assert.True(captureHandler.RequestWasMade);
Assert.Null(captureHandler.CapturedState);
// Message should not be removed since it's not state
Assert.Equal(1, captureHandler.CapturedMessageCount);
}
[Fact]
public async Task GetStreamingResponseAsync_RoundTripState_PreservesJsonStructureAsync()
{
// Arrange - Server returns state snapshot
var returnedState = new { counter = 100, nested = new { value = "test" } };
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(returnedState);
var captureHandler = new StateCapturingTestDelegatingHandler();
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = stateSnapshot },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
captureHandler.AddResponse(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run2" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Done" },
new TextMessageEndEvent { MessageId = "msg1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run2" }
]);
using HttpClient httpClient = new(captureHandler);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
// Act - First turn: receive state
DataContent? receivedStateContent = null;
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null))
{
if (update.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"))
{
receivedStateContent = (DataContent)update.Contents.First(c => c is DataContent);
}
}
// Second turn: send the received state back
Assert.NotNull(receivedStateContent);
messages.Add(new ChatMessage(ChatRole.System, [receivedStateContent]));
await foreach (var _ in chatClient.GetStreamingResponseAsync(messages, null))
{
// Just consume the stream
}
// Assert - Verify the round-tripped state
Assert.NotNull(captureHandler.CapturedState);
Assert.Equal(100, captureHandler.CapturedState.Value.GetProperty("counter").GetInt32());
Assert.Equal("test", captureHandler.CapturedState.Value.GetProperty("nested").GetProperty("value").GetString());
}
[Fact]
public async Task GetStreamingResponseAsync_ReceivesStateSnapshot_AsDataContentWithAdditionalPropertiesAsync()
{
// Arrange
var state = new { sessionId = "abc123", step = 5 };
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(state);
using HttpClient httpClient = this.CreateMockHttpClient(
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = stateSnapshot },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
]);
var chatClient = new AGUIChatClient(httpClient, "http://localhost/agent", null, AGUIJsonSerializerContext.Default.Options);
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Test")];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (var update in chatClient.GetStreamingResponseAsync(messages, null))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent));
Assert.NotNull(stateUpdate.AdditionalProperties);
Assert.True((bool)stateUpdate.AdditionalProperties!["is_state_snapshot"]!);
DataContent dataContent = (DataContent)stateUpdate.Contents[0];
Assert.Equal("application/json", dataContent.MediaType);
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement deserializedState = JsonSerializer.Deserialize<JsonElement>(jsonText);
Assert.Equal("abc123", deserializedState.GetProperty("sessionId").GetString());
Assert.Equal(5, deserializedState.GetProperty("step").GetInt32());
}
}
internal sealed class TestDelegatingHandler : DelegatingHandler
@@ -1376,3 +1682,58 @@ internal sealed class CapturingTestDelegatingHandler : DelegatingHandler
};
}
}
internal sealed class StateCapturingTestDelegatingHandler : DelegatingHandler
{
private readonly Queue<Func<HttpRequestMessage, Task<HttpResponseMessage>>> _responseFactories = new();
public bool RequestWasMade { get; private set; }
public JsonElement? CapturedState { get; private set; }
public int CapturedMessageCount { get; private set; }
public void AddResponse(BaseEvent[] events)
{
this._responseFactories.Enqueue(_ => Task.FromResult(CreateResponse(events)));
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.RequestWasMade = true;
// Capture the state and message count from the request
#if NET472 || NETSTANDARD2_0
string requestBody = await request.Content!.ReadAsStringAsync().ConfigureAwait(false);
#else
string requestBody = await request.Content!.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#endif
RunAgentInput? input = JsonSerializer.Deserialize(requestBody, AGUIJsonSerializerContext.Default.RunAgentInput);
if (input != null)
{
if (input.State.ValueKind != JsonValueKind.Undefined && input.State.ValueKind != JsonValueKind.Null)
{
this.CapturedState = input.State;
}
this.CapturedMessageCount = input.Messages.Count();
}
if (this._responseFactories.Count == 0)
{
throw new InvalidOperationException("No more responses configured for StateCapturingTestDelegatingHandler.");
}
var factory = this._responseFactories.Dequeue();
return await factory(request);
}
private static HttpResponseMessage CreateResponse(BaseEvent[] events)
{
string sseContent = string.Join("", events.Select(e =>
$"data: {JsonSerializer.Serialize(e, AGUIJsonSerializerContext.Default.BaseEvent)}\n\n"));
return new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(sseContent)
};
}
}
@@ -369,4 +369,412 @@ public sealed class ChatResponseUpdateAGUIExtensionsTests
Assert.Equal("call_2", functionCalls[1].CallId);
Assert.Equal("Tool2", functionCalls[1].Name);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsStateSnapshotEvent_ToDataContentWithJsonAsync()
{
// Arrange
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(new { counter = 42, status = "active" });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = stateSnapshot },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent));
Assert.Equal(ChatRole.Assistant, stateUpdate.Role);
Assert.Equal("thread1", stateUpdate.ConversationId);
Assert.Equal("run1", stateUpdate.ResponseId);
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
Assert.Equal("application/json", dataContent.MediaType);
// Verify the JSON content
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement deserializedState = JsonSerializer.Deserialize<JsonElement>(jsonText);
Assert.Equal(42, deserializedState.GetProperty("counter").GetInt32());
Assert.Equal("active", deserializedState.GetProperty("status").GetString());
// Verify additional properties
Assert.NotNull(stateUpdate.AdditionalProperties);
Assert.True((bool)stateUpdate.AdditionalProperties["is_state_snapshot"]!);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithNullStateSnapshot_DoesNotEmitUpdateAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = null },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.DoesNotContain(updates, u => u.Contents.Any(c => c is DataContent));
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithEmptyObjectStateSnapshot_EmitsDataContentAsync()
{
// Arrange
JsonElement emptyState = JsonSerializer.SerializeToElement(new { });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateSnapshotEvent { Snapshot = emptyState },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First(u => u.Contents.Any(c => c is DataContent));
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
Assert.Equal("{}", jsonText);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithComplexStateSnapshot_PreservesJsonStructureAsync()
{
// Arrange
var complexState = new
{
user = new { name = "Alice", age = 30 },
items = new[] { "item1", "item2", "item3" },
metadata = new { timestamp = "2024-01-01T00:00:00Z", version = 2 }
};
JsonElement stateSnapshot = JsonSerializer.SerializeToElement(complexState);
List<BaseEvent> events =
[
new StateSnapshotEvent { Snapshot = stateSnapshot }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate stateUpdate = updates.First();
DataContent dataContent = Assert.IsType<DataContent>(stateUpdate.Contents[0]);
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement roundTrippedState = JsonSerializer.Deserialize<JsonElement>(jsonText);
Assert.Equal("Alice", roundTrippedState.GetProperty("user").GetProperty("name").GetString());
Assert.Equal(30, roundTrippedState.GetProperty("user").GetProperty("age").GetInt32());
Assert.Equal(3, roundTrippedState.GetProperty("items").GetArrayLength());
Assert.Equal("item1", roundTrippedState.GetProperty("items")[0].GetString());
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithStateSnapshotAndTextMessages_EmitsBothAsync()
{
// Arrange
JsonElement state = JsonSerializer.SerializeToElement(new { step = 1 });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new TextMessageStartEvent { MessageId = "msg1", Role = AGUIRoles.Assistant },
new TextMessageContentEvent { MessageId = "msg1", Delta = "Processing..." },
new TextMessageEndEvent { MessageId = "msg1" },
new StateSnapshotEvent { Snapshot = state },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Contains(updates, u => u.Contents.Any(c => c is TextContent));
Assert.Contains(updates, u => u.Contents.Any(c => c is DataContent));
}
#region State Delta Tests
[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsStateDeltaEvent_ToDataContentWithJsonPatchAsync()
{
// Arrange - Create JSON Patch operations (RFC 6902)
JsonElement stateDelta = JsonSerializer.SerializeToElement(new object[]
{
new { op = "replace", path = "/counter", value = 43 },
new { op = "add", path = "/newField", value = "test" }
});
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = stateDelta },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
ChatResponseUpdate deltaUpdate = updates.First(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json"));
Assert.Equal(ChatRole.Assistant, deltaUpdate.Role);
Assert.Equal("thread1", deltaUpdate.ConversationId);
Assert.Equal("run1", deltaUpdate.ResponseId);
DataContent dataContent = Assert.IsType<DataContent>(deltaUpdate.Contents[0]);
Assert.Equal("application/json-patch+json", dataContent.MediaType);
// Verify the JSON Patch content
string jsonText = System.Text.Encoding.UTF8.GetString(dataContent.Data.ToArray());
JsonElement deserializedDelta = JsonSerializer.Deserialize<JsonElement>(jsonText);
Assert.Equal(JsonValueKind.Array, deserializedDelta.ValueKind);
Assert.Equal(2, deserializedDelta.GetArrayLength());
// Verify first operation
JsonElement firstOp = deserializedDelta[0];
Assert.Equal("replace", firstOp.GetProperty("op").GetString());
Assert.Equal("/counter", firstOp.GetProperty("path").GetString());
Assert.Equal(43, firstOp.GetProperty("value").GetInt32());
// Verify second operation
JsonElement secondOp = deserializedDelta[1];
Assert.Equal("add", secondOp.GetProperty("op").GetString());
Assert.Equal("/newField", secondOp.GetProperty("path").GetString());
Assert.Equal("test", secondOp.GetProperty("value").GetString());
// Verify additional properties
Assert.NotNull(deltaUpdate.AdditionalProperties);
Assert.True((bool)deltaUpdate.AdditionalProperties["is_state_delta"]!);
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithNullStateDelta_DoesNotEmitUpdateAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = null },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert - Only run started and finished should be present
Assert.Equal(2, updates.Count);
Assert.IsType<ChatResponseUpdate>(updates[0]); // Run started
Assert.IsType<ChatResponseUpdate>(updates[1]); // Run finished
Assert.DoesNotContain(updates, u => u.Contents.Any(c => c is DataContent));
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithEmptyStateDelta_EmitsUpdateAsync()
{
// Arrange - Empty JSON Patch array is valid
JsonElement emptyDelta = JsonSerializer.SerializeToElement(Array.Empty<object>());
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = emptyDelta },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
Assert.Contains(updates, u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json"));
}
[Fact]
public async Task AsChatResponseUpdatesAsync_WithMultipleStateDeltaEvents_ConvertsAllAsync()
{
// Arrange
JsonElement delta1 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 1 } });
JsonElement delta2 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 2 } });
JsonElement delta3 = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 3 } });
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = delta1 },
new StateDeltaEvent { Delta = delta2 },
new StateDeltaEvent { Delta = delta3 },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
// Assert
var deltaUpdates = updates.Where(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json-patch+json")).ToList();
Assert.Equal(3, deltaUpdates.Count);
}
[Fact]
public async Task AsAGUIEventStreamAsync_ConvertsDataContentWithJsonPatch_ToStateDeltaEventAsync()
{
// Arrange - Create a ChatResponseUpdate with JSON Patch DataContent
JsonElement patchOps = JsonSerializer.SerializeToElement(new object[]
{
new { op = "remove", path = "/oldField" },
new { op = "add", path = "/newField", value = "newValue" }
});
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(patchOps);
DataContent dataContent = new(jsonBytes, "application/json-patch+json");
List<ChatResponseUpdate> updates =
[
new ChatResponseUpdate(ChatRole.Assistant, [dataContent])
{
MessageId = "msg1"
}
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
StateDeltaEvent? deltaEvent = outputEvents.OfType<StateDeltaEvent>().FirstOrDefault();
Assert.NotNull(deltaEvent);
Assert.NotNull(deltaEvent.Delta);
Assert.Equal(JsonValueKind.Array, deltaEvent.Delta.Value.ValueKind);
// Verify patch operations
JsonElement delta = deltaEvent.Delta.Value;
Assert.Equal(2, delta.GetArrayLength());
Assert.Equal("remove", delta[0].GetProperty("op").GetString());
Assert.Equal("/oldField", delta[0].GetProperty("path").GetString());
Assert.Equal("add", delta[1].GetProperty("op").GetString());
Assert.Equal("/newField", delta[1].GetProperty("path").GetString());
}
[Fact]
public async Task AsAGUIEventStreamAsync_WithBothSnapshotAndDelta_EmitsBothEventsAsync()
{
// Arrange
JsonElement snapshot = JsonSerializer.SerializeToElement(new { counter = 0 });
byte[] snapshotBytes = JsonSerializer.SerializeToUtf8Bytes(snapshot);
DataContent snapshotContent = new(snapshotBytes, "application/json");
JsonElement delta = JsonSerializer.SerializeToElement(new[] { new { op = "replace", path = "/counter", value = 1 } });
byte[] deltaBytes = JsonSerializer.SerializeToUtf8Bytes(delta);
DataContent deltaContent = new(deltaBytes, "application/json-patch+json");
List<ChatResponseUpdate> updates =
[
new ChatResponseUpdate(ChatRole.Assistant, [snapshotContent]) { MessageId = "msg1" },
new ChatResponseUpdate(ChatRole.Assistant, [deltaContent]) { MessageId = "msg2" }
];
// Act
List<BaseEvent> outputEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
outputEvents.Add(evt);
}
// Assert
Assert.Contains(outputEvents, e => e is StateSnapshotEvent);
Assert.Contains(outputEvents, e => e is StateDeltaEvent);
}
[Fact]
public async Task StateDeltaEvent_RoundTrip_PreservesJsonPatchOperationsAsync()
{
// Arrange - Create complex JSON Patch with various operations
JsonElement originalDelta = JsonSerializer.SerializeToElement(new object[]
{
new { op = "add", path = "/user/email", value = "test@example.com" },
new { op = "remove", path = "/user/tempData" },
new { op = "replace", path = "/user/lastLogin", value = "2025-11-09T12:00:00Z" },
new { op = "move", from = "/user/oldAddress", path = "/user/previousAddress" },
new { op = "copy", from = "/user/name", path = "/user/displayName" },
new { op = "test", path = "/user/version", value = 2 }
});
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new StateDeltaEvent { Delta = originalDelta },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1" }
];
// Act - Convert to ChatResponseUpdate and back to events
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}
List<BaseEvent> roundTripEvents = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync("thread1", "run1", AGUIJsonSerializerContext.Default.Options))
{
roundTripEvents.Add(evt);
}
// Assert
StateDeltaEvent? roundTripDelta = roundTripEvents.OfType<StateDeltaEvent>().FirstOrDefault();
Assert.NotNull(roundTripDelta);
Assert.NotNull(roundTripDelta.Delta);
JsonElement delta = roundTripDelta.Delta.Value;
Assert.Equal(6, delta.GetArrayLength());
// Verify each operation type
Assert.Equal("add", delta[0].GetProperty("op").GetString());
Assert.Equal("remove", delta[1].GetProperty("op").GetString());
Assert.Equal("replace", delta[2].GetProperty("op").GetString());
Assert.Equal("move", delta[3].GetProperty("op").GetString());
Assert.Equal("copy", delta[4].GetProperty("op").GetString());
Assert.Equal("test", delta[5].GetProperty("op").GetString());
}
#endregion State Delta Tests
}
@@ -18,7 +18,12 @@ public class AgentRunOptionsTests
var options = new AgentRunOptions
{
ContinuationToken = new object(),
AllowBackgroundResponses = true
AllowBackgroundResponses = true,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["key1"] = "value1",
["key2"] = 42
}
};
// Act
@@ -28,6 +33,10 @@ public class AgentRunOptionsTests
Assert.NotNull(clone);
Assert.Same(options.ContinuationToken, clone.ContinuationToken);
Assert.Equal(options.AllowBackgroundResponses, clone.AllowBackgroundResponses);
Assert.NotNull(clone.AdditionalProperties);
Assert.NotSame(options.AdditionalProperties, clone.AdditionalProperties);
Assert.Equal("value1", clone.AdditionalProperties["key1"]);
Assert.Equal(42, clone.AdditionalProperties["key2"]);
}
[Fact]
@@ -42,7 +51,12 @@ public class AgentRunOptionsTests
var options = new AgentRunOptions
{
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
AllowBackgroundResponses = true
AllowBackgroundResponses = true,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["key1"] = "value1",
["key2"] = 42
}
};
// Act
@@ -54,5 +68,13 @@ public class AgentRunOptionsTests
Assert.NotNull(deserialized);
Assert.Equivalent(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), deserialized!.ContinuationToken);
Assert.Equal(options.AllowBackgroundResponses, deserialized.AllowBackgroundResponses);
Assert.NotNull(deserialized.AdditionalProperties);
Assert.Equal(2, deserialized.AdditionalProperties.Count);
Assert.True(deserialized.AdditionalProperties.TryGetValue("key1", out object? value1));
Assert.IsType<JsonElement>(value1);
Assert.Equal("value1", ((JsonElement)value1!).GetString());
Assert.True(deserialized.AdditionalProperties.TryGetValue("key2", out object? value2));
Assert.IsType<JsonElement>(value2);
Assert.Equal(42, ((JsonElement)value2!).GetInt32());
}
}
@@ -0,0 +1,87 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading.Tasks;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
public sealed class A2AIntegrationTests
{
/// <summary>
/// Verifies that calling the A2A card endpoint with MapA2A returns an agent card with a URL populated.
/// </summary>
[Fact]
public async Task MapA2A_WithAgentCard_CardEndpointReturnsCardWithUrlAsync()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("test-agent", "Test instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
var agentCard = new AgentCard
{
Name = "Test Agent",
Description = "A test agent for A2A communication",
Version = "1.0"
};
// Map A2A with the agent card
app.MapA2A(agentBuilder, "/a2a/test-agent", agentCard);
await app.StartAsync();
try
{
// Get the test server client
TestServer testServer = app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
var httpClient = testServer.CreateClient();
// Act - Query the agent card endpoint
var requestUri = new Uri("/a2a/test-agent/v1/card", UriKind.Relative);
var response = await httpClient.GetAsync(requestUri);
// Assert
Assert.True(response.IsSuccessStatusCode, $"Expected successful response but got {response.StatusCode}");
var content = await response.Content.ReadAsStringAsync();
var jsonDoc = JsonDocument.Parse(content);
var root = jsonDoc.RootElement;
// Verify the card has expected properties
Assert.True(root.TryGetProperty("name", out var nameProperty));
Assert.Equal("Test Agent", nameProperty.GetString());
Assert.True(root.TryGetProperty("description", out var descProperty));
Assert.Equal("A test agent for A2A communication", descProperty.GetString());
// Verify the card has a URL property and it's not null/empty
Assert.True(root.TryGetProperty("url", out var urlProperty));
Assert.NotEqual(JsonValueKind.Null, urlProperty.ValueKind);
var url = urlProperty.GetString();
Assert.NotNull(url);
Assert.NotEmpty(url);
Assert.StartsWith("http", url, StringComparison.OrdinalIgnoreCase);
Assert.Equal($"{testServer.BaseAddress.ToString().TrimEnd('/')}/a2a/test-agent/v1/card", url);
}
finally
{
await app.StopAsync();
}
}
}
@@ -1,10 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
@@ -478,25 +476,4 @@ public sealed class EndpointRouteA2ABuilderExtensionsTests
var result = app.MapA2A(agentBuilder, "/a2a", agentCard);
Assert.NotNull(result);
}
private sealed class DummyChatClient : IChatClient
{
public void Dispose()
{
throw new NotImplementedException();
}
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
internal sealed class DummyChatClient : IChatClient
{
public void Dispose()
{
throw new NotImplementedException();
}
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
@@ -6,6 +6,9 @@
</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="System.Net.ServerSentEvents" VersionOverride="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-rc.2.25502.107" />
</ItemGroup>
@@ -0,0 +1,441 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.AGUI;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests;
public sealed class SharedStateTests : IAsyncDisposable
{
private WebApplication? _app;
private HttpClient? _client;
[Fact]
public async Task StateSnapshot_IsReturnedAsDataContent_WithCorrectMediaTypeAsync()
{
// Arrange
var initialState = new { counter = 42, status = "active" };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(initialState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "update state");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
updates.Should().NotBeEmpty();
// Should receive state snapshot as DataContent with application/json media type
AgentRunResponseUpdate? stateUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
stateUpdate.Should().NotBeNull("should receive state snapshot update");
DataContent? dataContent = stateUpdate!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
dataContent.Should().NotBeNull();
// Verify the state content
string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray());
JsonElement receivedState = JsonSerializer.Deserialize<JsonElement>(receivedJson);
receivedState.GetProperty("counter").GetInt32().Should().Be(43, "state should be incremented");
receivedState.GetProperty("status").GetString().Should().Be("active");
}
[Fact]
public async Task StateSnapshot_HasCorrectAdditionalPropertiesAsync()
{
// Arrange
var initialState = new { step = 1 };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(initialState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "process");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
AgentRunResponseUpdate? stateUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
stateUpdate.Should().NotBeNull();
ChatResponseUpdate chatUpdate = stateUpdate!.AsChatResponseUpdate();
chatUpdate.AdditionalProperties.Should().NotBeNull();
chatUpdate.AdditionalProperties.Should().ContainKey("is_state_snapshot");
((bool)chatUpdate.AdditionalProperties!["is_state_snapshot"]!).Should().BeTrue();
}
[Fact]
public async Task ComplexState_WithNestedObjectsAndArrays_RoundTripsCorrectlyAsync()
{
// Arrange
var complexState = new
{
sessionId = "test-123",
nested = new { value = "test", count = 10 },
array = new[] { 1, 2, 3 },
tags = new[] { "tag1", "tag2" }
};
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(complexState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "process complex state");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
AgentRunResponseUpdate? stateUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
stateUpdate.Should().NotBeNull();
DataContent? dataContent = stateUpdate!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray());
JsonElement receivedState = JsonSerializer.Deserialize<JsonElement>(receivedJson);
receivedState.GetProperty("sessionId").GetString().Should().Be("test-123");
receivedState.GetProperty("nested").GetProperty("count").GetInt32().Should().Be(10);
receivedState.GetProperty("array").GetArrayLength().Should().Be(3);
receivedState.GetProperty("tags").GetArrayLength().Should().Be(2);
}
[Fact]
public async Task StateSnapshot_CanBeUsedInSubsequentRequest_ForStateRoundTripAsync()
{
// Arrange
var initialState = new { counter = 1, sessionId = "round-trip-test" };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(initialState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "increment");
List<AgentRunResponseUpdate> firstRoundUpdates = [];
// Act - First round
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
firstRoundUpdates.Add(update);
}
// Extract state snapshot from first round
AgentRunResponseUpdate? firstStateUpdate = firstRoundUpdates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
firstStateUpdate.Should().NotBeNull();
DataContent? firstStateContent = firstStateUpdate!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
// Second round - use returned state
ChatMessage secondStateMessage = new(ChatRole.System, [firstStateContent!]);
ChatMessage secondUserMessage = new(ChatRole.User, "increment again");
List<AgentRunResponseUpdate> secondRoundUpdates = [];
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([secondUserMessage, secondStateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
secondRoundUpdates.Add(update);
}
// Assert - Second round should have incremented counter again
AgentRunResponseUpdate? secondStateUpdate = secondRoundUpdates.FirstOrDefault(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
secondStateUpdate.Should().NotBeNull();
DataContent? secondStateContent = secondStateUpdate!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
string secondStateJson = System.Text.Encoding.UTF8.GetString(secondStateContent!.Data.ToArray());
JsonElement secondState = JsonSerializer.Deserialize<JsonElement>(secondStateJson);
secondState.GetProperty("counter").GetInt32().Should().Be(3, "counter should be incremented twice: 1 -> 2 -> 3");
}
[Fact]
public async Task WithoutState_AgentBehavesNormally_NoStateSnapshotReturnedAsync()
{
// Arrange
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
ChatMessage userMessage = new(ChatRole.User, "hello");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
updates.Should().NotBeEmpty();
// Should NOT have state snapshot when no state is sent
bool hasStateSnapshot = updates.Any(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
hasStateSnapshot.Should().BeFalse("should not return state snapshot when no state is provided");
// Should have normal text response
updates.Should().Contain(u => u.Contents.Any(c => c is TextContent));
}
[Fact]
public async Task EmptyState_DoesNotTriggerStateHandlingAsync()
{
// Arrange
var emptyState = new { };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(emptyState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "hello");
List<AgentRunResponseUpdate> updates = [];
// Act
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
updates.Should().NotBeEmpty();
// Empty state {} should not trigger state snapshot mechanism
bool hasEmptyStateSnapshot = updates.Any(u => u.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
hasEmptyStateSnapshot.Should().BeFalse("empty state should be treated as no state");
// Should have normal response
updates.Should().Contain(u => u.Contents.Any(c => c is TextContent));
}
[Fact]
public async Task NonStreamingRunAsync_WithState_ReturnsStateInResponseAsync()
{
// Arrange
var initialState = new { counter = 5 };
var fakeAgent = new FakeStateAgent();
await this.SetupTestServerAsync(fakeAgent);
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.CreateAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentThread thread = (ChatClientAgentThread)agent.GetNewThread();
string stateJson = JsonSerializer.Serialize(initialState);
byte[] stateBytes = System.Text.Encoding.UTF8.GetBytes(stateJson);
DataContent stateContent = new(stateBytes, "application/json");
ChatMessage stateMessage = new(ChatRole.System, [stateContent]);
ChatMessage userMessage = new(ChatRole.User, "process");
// Act
AgentRunResponse response = await agent.RunAsync([userMessage, stateMessage], thread, new AgentRunOptions(), CancellationToken.None);
// Assert
response.Should().NotBeNull();
response.Messages.Should().NotBeEmpty();
// Should have message with DataContent containing state
bool hasStateMessage = response.Messages.Any(m => m.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
hasStateMessage.Should().BeTrue("response should contain state message");
ChatMessage? stateResponseMessage = response.Messages.FirstOrDefault(m => m.Contents.Any(c => c is DataContent dc && dc.MediaType == "application/json"));
stateResponseMessage.Should().NotBeNull();
DataContent? dataContent = stateResponseMessage!.Contents.OfType<DataContent>().FirstOrDefault(dc => dc.MediaType == "application/json");
string receivedJson = System.Text.Encoding.UTF8.GetString(dataContent!.Data.ToArray());
JsonElement receivedState = JsonSerializer.Deserialize<JsonElement>(receivedJson);
receivedState.GetProperty("counter").GetInt32().Should().Be(6);
}
private async Task SetupTestServerAsync(FakeStateAgent fakeAgent)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddAGUI();
builder.WebHost.UseTestServer();
this._app = builder.Build();
this._app.MapAGUI("/agent", fakeAgent);
await this._app.StartAsync();
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
this._client = testServer.CreateClient();
this._client.BaseAddress = new Uri("http://localhost/agent");
}
public async ValueTask DisposeAsync()
{
this._client?.Dispose();
if (this._app != null)
{
await this._app.DisposeAsync();
}
}
}
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated in tests")]
internal sealed class FakeStateAgent : AIAgent
{
public override string? Description => "Agent for state testing";
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Check for state in ChatOptions.AdditionalProperties (set by AG-UI hosting layer)
if (options is ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } &&
properties.TryGetValue("ag_ui_state", out object? stateObj) &&
stateObj is JsonElement state &&
state.ValueKind == JsonValueKind.Object)
{
// Check if state object has properties (not empty {})
bool hasProperties = false;
foreach (JsonProperty _ in state.EnumerateObject())
{
hasProperties = true;
break;
}
if (hasProperties)
{
// State is present and non-empty - modify it and return as DataContent
Dictionary<string, object?> modifiedState = [];
foreach (JsonProperty prop in state.EnumerateObject())
{
if (prop.Name == "counter" && prop.Value.ValueKind == JsonValueKind.Number)
{
modifiedState[prop.Name] = prop.Value.GetInt32() + 1;
}
else if (prop.Value.ValueKind == JsonValueKind.Number)
{
modifiedState[prop.Name] = prop.Value.GetInt32();
}
else if (prop.Value.ValueKind == JsonValueKind.String)
{
modifiedState[prop.Name] = prop.Value.GetString();
}
else if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array)
{
modifiedState[prop.Name] = prop.Value;
}
}
// Return modified state as DataContent
string modifiedStateJson = JsonSerializer.Serialize(modifiedState);
byte[] modifiedStateBytes = System.Text.Encoding.UTF8.GetBytes(modifiedStateJson);
DataContent modifiedStateContent = new(modifiedStateBytes, "application/json");
yield return new AgentRunResponseUpdate
{
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Assistant,
Contents = [modifiedStateContent]
};
}
}
// Always return a text response
string messageId = Guid.NewGuid().ToString("N");
yield return new AgentRunResponseUpdate
{
MessageId = messageId,
Role = ChatRole.Assistant,
Contents = [new TextContent("State processed")]
};
await Task.CompletedTask;
}
public override AgentThread GetNewThread() => new FakeInMemoryAgentThread();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
{
return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions);
}
private sealed class FakeInMemoryAgentThread : InMemoryAgentThread
{
public FakeInMemoryAgentThread()
: base()
{
}
public FakeInMemoryAgentThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThread, jsonSerializerOptions)
{
}
}
public override object? GetService(Type serviceType, object? serviceKey = null) => null;
}
@@ -190,6 +190,264 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
Assert.Equal("Second", capturedMessages[1].Text);
}
[Fact]
public async Task MapAGUIAgent_ProducesValidAGUIEventStream_WithRunStartAndFinishAsync()
{
// Arrange
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
JsonElement runStarted = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.RunStarted);
JsonElement runFinished = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.RunFinished);
Assert.Equal("thread1", runStarted.GetProperty("threadId").GetString());
Assert.Equal("run1", runStarted.GetProperty("runId").GetString());
Assert.Equal("thread1", runFinished.GetProperty("threadId").GetString());
Assert.Equal("run1", runFinished.GetProperty("runId").GetString());
}
[Fact]
public async Task MapAGUIAgent_ProducesTextMessageEvents_InCorrectOrderAsync()
{
// Arrange
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Hello" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
List<string?> eventTypes = new(events.Count);
foreach (JsonElement evt in events)
{
eventTypes.Add(evt.GetProperty("type").GetString());
}
Assert.Contains(AGUIEventTypes.RunStarted, eventTypes);
Assert.Contains(AGUIEventTypes.TextMessageContent, eventTypes);
Assert.Contains(AGUIEventTypes.RunFinished, eventTypes);
int runStartIndex = eventTypes.IndexOf(AGUIEventTypes.RunStarted);
int firstContentIndex = eventTypes.IndexOf(AGUIEventTypes.TextMessageContent);
int runFinishIndex = eventTypes.LastIndexOf(AGUIEventTypes.RunFinished);
Assert.True(runStartIndex < firstContentIndex, "Run start should precede text content.");
Assert.True(firstContentIndex < runFinishIndex, "Text content should precede run finish.");
}
[Fact]
public async Task MapAGUIAgent_EmitsTextMessageContent_WithCorrectDeltaAsync()
{
// Arrange
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
JsonElement textContentEvent = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.TextMessageContent);
Assert.Equal("Test response", textContentEvent.GetProperty("delta").GetString());
}
[Fact]
public async Task MapAGUIAgent_WithCustomAgent_ProducesExpectedStreamStructureAsync()
{
// Arrange
AIAgent customAgentFactory(IEnumerable<ChatMessage> messages, IEnumerable<AITool> tools, IEnumerable<KeyValuePair<string, string>> context, JsonElement props)
{
return new MultiResponseAgent();
}
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "custom_thread",
RunId = "custom_run",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Multi" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate(customAgentFactory);
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
List<JsonElement> contentEvents = new();
foreach (JsonElement evt in events)
{
if (evt.GetProperty("type").GetString() == AGUIEventTypes.TextMessageContent)
{
contentEvents.Add(evt);
}
}
Assert.True(contentEvents.Count >= 3, $"Expected at least 3 text_message.content events, got {contentEvents.Count}");
List<string?> deltas = new(contentEvents.Count);
foreach (JsonElement contentEvent in contentEvents)
{
deltas.Add(contentEvent.GetProperty("delta").GetString());
}
Assert.Contains("First", deltas);
Assert.Contains(" part", deltas);
Assert.Contains(" of response", deltas);
}
[Fact]
public async Task MapAGUIAgent_ProducesCorrectThreadAndRunIds_InAllEventsAsync()
{
// Arrange
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "test_thread_123",
RunId = "test_run_456",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
MemoryStream responseStream = new();
httpContext.Response.Body = responseStream;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act
await handler(httpContext);
// Assert
responseStream.Position = 0;
string responseContent = Encoding.UTF8.GetString(responseStream.ToArray());
List<JsonElement> events = ParseSseEvents(responseContent);
JsonElement runStarted = Assert.Single(events, static e => e.GetProperty("type").GetString() == AGUIEventTypes.RunStarted);
Assert.Equal("test_thread_123", runStarted.GetProperty("threadId").GetString());
Assert.Equal("test_run_456", runStarted.GetProperty("runId").GetString());
}
private static List<JsonElement> ParseSseEvents(string responseContent)
{
List<JsonElement> events = [];
using StringReader reader = new(responseContent);
StringBuilder dataBuilder = new();
string? line;
while ((line = reader.ReadLine()) != null)
{
if (line.StartsWith("data:", StringComparison.Ordinal))
{
string payload = line.Length > 5 && line[5] == ' '
? line.Substring(6)
: line.Substring(5);
dataBuilder.Append(payload);
}
else if (line.Length == 0 && dataBuilder.Length > 0)
{
using JsonDocument document = JsonDocument.Parse(dataBuilder.ToString());
events.Add(document.RootElement.Clone());
dataBuilder.Clear();
}
}
if (dataBuilder.Length > 0)
{
using JsonDocument document = JsonDocument.Parse(dataBuilder.ToString());
events.Add(document.RootElement.Clone());
}
return events;
}
private sealed class MultiResponseAgent : AIAgent
{
public override string Id => "multi-response-agent";
public override string? Description => "Agent that produces multiple text chunks";
public override AgentThread GetNewThread() => new TestInMemoryAgentThread();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) =>
new TestInMemoryAgentThread(serializedThread, jsonSerializerOptions);
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "First"));
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, " part"));
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, " of response"));
}
}
private RequestDelegate CreateRequestDelegate(
Func<IEnumerable<ChatMessage>, IEnumerable<AITool>, IEnumerable<KeyValuePair<string, string>>, JsonElement, AIAgent> factory)
{
@@ -0,0 +1,53 @@
{
"model": "gpt-4o-mini",
"messages": [
{
"role": "user",
"content": "What's the weather like in San Francisco?"
}
],
"max_completion_tokens": 256,
"temperature": 0.7,
"top_p": 1,
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA"
},
"unit": {
"type": "string",
"enum": [ "celsius", "fahrenheit" ],
"description": "Temperature unit"
}
},
"required": [ "location" ]
}
}
},
{
"type": "function",
"function": {
"name": "get_time",
"description": "Get the current time in a given timezone",
"parameters": {
"type": "object",
"properties": {
"timezone": {
"type": "string",
"description": "The IANA timezone, e.g. America/Los_Angeles"
}
},
"required": [ "timezone" ]
}
}
}
]
}
@@ -0,0 +1,42 @@
{
"id": "chatcmpl-tools-test-001",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\": \"San Francisco, CA\", \"unit\": \"fahrenheit\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": {
"prompt_tokens": 85,
"completion_tokens": 32,
"total_tokens": 117,
"prompt_tokens_details": {
"cached_tokens": 0,
"audio_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
},
"service_tier": "default"
}
@@ -27,16 +27,4 @@
</None>
</ItemGroup>
<ItemGroup>
<Content Remove="ConformanceTraces\ChatCompletions\function_calling\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\function_calling\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\json_mode\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\json_mode\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\multi_turn\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\multi_turn\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\streaming\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\system_message\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\system_message\response.json" />
</ItemGroup>
</Project>
@@ -456,6 +456,136 @@ public sealed class OpenAIChatCompletionsConformanceTests : ConformanceTestBase
Assert.Equal(JsonValueKind.String, jsonRoot.GetProperty("occupation").ValueKind);
}
[Fact]
public async Task ToolsSerializationDeserializationAsync()
{
// Arrange
string requestJson = LoadChatCompletionsTraceFile("tools/request.json");
using var expectedResponseDoc = LoadChatCompletionsTraceDocument("tools/response.json");
HttpClient client = await this.CreateTestServerAsync(
"tools-agent",
"You are a helpful assistant with access to weather and time tools.",
"tool-call",
(msg) => [new FunctionCallContent("call_abc123", "get_weather", new Dictionary<string, object?>() {
{ "location", "San Francisco, CA" },
{ "unit", "fahrenheit" }
})]
);
// Act
HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "tools-agent", requestJson);
using var responseDoc = await ParseResponseAsync(httpResponse);
var response = responseDoc.RootElement;
// Parse the request
using var requestDoc = JsonDocument.Parse(requestJson);
var request = requestDoc.RootElement;
// Assert - Request has tools array with proper structure
AssertJsonPropertyExists(request, "tools");
var tools = request.GetProperty("tools");
Assert.Equal(JsonValueKind.Array, tools.ValueKind);
Assert.Equal(2, tools.GetArrayLength());
// Assert - First tool (get_weather)
var weatherTool = tools[0];
AssertJsonPropertyEquals(weatherTool, "type", "function");
AssertJsonPropertyExists(weatherTool, "function");
var weatherFunction = weatherTool.GetProperty("function");
AssertJsonPropertyEquals(weatherFunction, "name", "get_weather");
AssertJsonPropertyExists(weatherFunction, "description");
AssertJsonPropertyExists(weatherFunction, "parameters");
var weatherParams = weatherFunction.GetProperty("parameters");
AssertJsonPropertyEquals(weatherParams, "type", "object");
AssertJsonPropertyExists(weatherParams, "properties");
AssertJsonPropertyExists(weatherParams, "required");
// Verify location property exists
var properties = weatherParams.GetProperty("properties");
AssertJsonPropertyExists(properties, "location");
AssertJsonPropertyExists(properties, "unit");
// Assert - Second tool (get_time)
var timeTool = tools[1];
AssertJsonPropertyEquals(timeTool, "type", "function");
var timeFunction = timeTool.GetProperty("function");
AssertJsonPropertyEquals(timeFunction, "name", "get_time");
AssertJsonPropertyExists(timeFunction, "description");
AssertJsonPropertyExists(timeFunction, "parameters");
// Assert - Response structure
AssertJsonPropertyExists(response, "id");
AssertJsonPropertyEquals(response, "object", "chat.completion");
AssertJsonPropertyExists(response, "created");
AssertJsonPropertyExists(response, "model");
// Assert - Response has tool_calls in choices
var choices = response.GetProperty("choices");
Assert.Equal(JsonValueKind.Array, choices.ValueKind);
Assert.True(choices.GetArrayLength() > 0);
var choice = choices[0];
AssertJsonPropertyExists(choice, "finish_reason");
AssertJsonPropertyEquals(choice, "finish_reason", anyOfValues: ["tool_calls", "stop"]);
AssertJsonPropertyExists(choice, "message");
var message = choice.GetProperty("message");
AssertJsonPropertyEquals(message, "role", "assistant");
AssertJsonPropertyExists(message, "tool_calls");
// Assert - Tool calls array structure
var toolCalls = message.GetProperty("tool_calls");
Assert.Equal(JsonValueKind.Array, toolCalls.ValueKind);
Assert.True(toolCalls.GetArrayLength() > 0);
var toolCall = toolCalls[0];
AssertJsonPropertyExists(toolCall, "id");
AssertJsonPropertyEquals(toolCall, "type", "function");
AssertJsonPropertyExists(toolCall, "function");
var callFunction = toolCall.GetProperty("function");
AssertJsonPropertyEquals(callFunction, "name", "get_weather");
AssertJsonPropertyExists(callFunction, "arguments");
// Assert - Tool call arguments are valid JSON
string arguments = callFunction.GetProperty("arguments").GetString()!;
using var argsDoc = JsonDocument.Parse(arguments);
var argsRoot = argsDoc.RootElement;
AssertJsonPropertyExists(argsRoot, "location");
AssertJsonPropertyEquals(argsRoot, "location", "San Francisco, CA");
AssertJsonPropertyEquals(argsRoot, "unit", "fahrenheit");
// Assert - Message content is null when tool_calls present
if (message.TryGetProperty("content", out var contentProp))
{
Assert.Equal(JsonValueKind.Null, contentProp.ValueKind);
}
// Assert - Usage statistics
AssertJsonPropertyExists(response, "usage");
var usage = response.GetProperty("usage");
AssertJsonPropertyExists(usage, "prompt_tokens");
AssertJsonPropertyExists(usage, "completion_tokens");
AssertJsonPropertyExists(usage, "total_tokens");
var promptTokens = usage.GetProperty("prompt_tokens").GetInt32();
var completionTokens = usage.GetProperty("completion_tokens").GetInt32();
var totalTokens = usage.GetProperty("total_tokens").GetInt32();
Assert.True(promptTokens > 0);
Assert.True(completionTokens > 0);
Assert.Equal(promptTokens + completionTokens, totalTokens);
// Assert - Service tier
AssertJsonPropertyExists(response, "service_tier");
var serviceTier = response.GetProperty("service_tier").GetString();
Assert.NotNull(serviceTier);
}
/// <summary>
/// Helper to parse chat completion chunks from SSE response.
/// </summary>
+17 -1
View File
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0b251111] - 2025-11-11
### Added
- **agent-framework-core**: Add OpenAI Responses Image Generation Stream Support with partial images and unit tests ([#1853](https://github.com/microsoft/agent-framework/pull/1853))
- **agent-framework-ag-ui**: Add concrete AGUIChatClient implementation ([#2072](https://github.com/microsoft/agent-framework/pull/2072))
### Fixed
- **agent-framework-a2a**: Use the last entry in the task history to avoid empty responses ([#2101](https://github.com/microsoft/agent-framework/pull/2101))
- **agent-framework-core**: Fix MCP Tool Parameter Descriptions not propagated to LLMs ([#1978](https://github.com/microsoft/agent-framework/pull/1978))
- **agent-framework-core**: Handle agent user input request in AgentExecutor ([#2022](https://github.com/microsoft/agent-framework/pull/2022))
- **agent-framework-core**: Fix Model ID attribute not showing up in `invoke_agent` span ([#2061](https://github.com/microsoft/agent-framework/pull/2061))
- **agent-framework-core**: Fix underlying tool choice bug and enable return to previous Handoff subagent ([#2037](https://github.com/microsoft/agent-framework/pull/2037))
## [1.0.0b251108] - 2025-11-08
### Added
@@ -189,7 +204,8 @@ 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.0b251108...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251111...HEAD
[1.0.0b251111]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251108...python-1.0.0b251111
[1.0.0b251108]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251106.post1...python-1.0.0b251108
[1.0.0b251106.post1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251106...python-1.0.0b251106.post1
[1.0.0b251106]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251105...python-1.0.0b251106
@@ -388,6 +388,17 @@ class A2AAgent(BaseAgent):
if task.artifacts is not None:
for artifact in task.artifacts:
messages.append(self._artifact_to_chat_message(artifact))
elif task.history is not None and len(task.history) > 0:
# Include the last history item as the agent response
history_item = task.history[-1]
contents = self._a2a_parts_to_contents(history_item.parts)
messages.append(
ChatMessage(
role=Role.ASSISTANT if history_item.role == A2ARole.agent else Role.USER,
contents=contents,
raw_representation=history_item,
)
)
return messages
+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.0b251108"
version = "1.0.0b251111"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+33 -1
View File
@@ -10,6 +10,8 @@ pip install agent-framework-ag-ui
## Quick Start
### Server (Host an AI Agent)
```python
from fastapi import FastAPI
from agent_framework import ChatAgent
@@ -23,6 +25,7 @@ agent = ChatAgent(
chat_client=AzureOpenAIChatClient(
endpoint="https://your-resource.openai.azure.com/",
deployment_name="gpt-4o-mini",
api_key="your-api-key",
),
)
@@ -33,9 +36,38 @@ add_agent_framework_fastapi_endpoint(app, agent, "/")
# Run with: uvicorn main:app --reload
```
### Client (Connect to an AG-UI Server)
```python
import asyncio
from agent_framework import TextContent
from agent_framework_ag_ui import AGUIChatClient
async def main():
async with AGUIChatClient(endpoint="http://localhost:8000/") as client:
# Stream responses
async for update in client.get_streaming_response("Hello!"):
for content in update.contents:
if isinstance(content, TextContent):
print(content.text, end="", flush=True)
print()
asyncio.run(main())
```
The `AGUIChatClient` supports:
- Streaming and non-streaming responses
- Hybrid tool execution (client-side + server-side tools)
- Automatic thread management for conversation continuity
- Integration with `ChatAgent` for client-side history management
## Documentation
- **[Getting Started Tutorial](getting_started/)** - Step-by-step guide to building your first AG-UI server and client
- **[Getting Started Tutorial](getting_started/)** - Step-by-step guide to building AG-UI servers and clients
- Server setup with FastAPI
- Client examples using `AGUIChatClient`
- Hybrid tool execution (client-side + server-side)
- Thread management and conversation continuity
- **[Examples](agent_framework_ag_ui_examples/)** - Complete examples for AG-UI features
## Features
@@ -5,6 +5,7 @@
import importlib.metadata
from ._agent import AgentFrameworkAgent
from ._client import AGUIChatClient
from ._confirmation_strategies import (
ConfirmationStrategy,
DefaultConfirmationStrategy,
@@ -13,6 +14,8 @@ from ._confirmation_strategies import (
TaskPlannerConfirmationStrategy,
)
from ._endpoint import add_agent_framework_fastapi_endpoint
from ._event_converters import AGUIEventConverter
from ._http_service import AGUIHttpService
try:
__version__ = importlib.metadata.version(__name__)
@@ -22,6 +25,9 @@ except importlib.metadata.PackageNotFoundError:
__all__ = [
"AgentFrameworkAgent",
"add_agent_framework_fastapi_endpoint",
"AGUIChatClient",
"AGUIEventConverter",
"AGUIHttpService",
"ConfirmationStrategy",
"DefaultConfirmationStrategy",
"TaskPlannerConfirmationStrategy",
@@ -0,0 +1,407 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI Chat Client implementation."""
import json
import logging
import uuid
from collections.abc import AsyncIterable, MutableSequence
from functools import wraps
from typing import Any, TypeVar, cast
import httpx
from agent_framework import (
AIFunction,
BaseChatClient,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
DataContent,
FunctionCallContent,
)
from agent_framework._middleware import use_chat_middleware
from agent_framework._tools import use_function_invocation
from agent_framework._types import BaseContent, Contents
from agent_framework.observability import use_observability
from ._event_converters import AGUIEventConverter
from ._http_service import AGUIHttpService
from ._message_adapters import agent_framework_messages_to_agui
from ._utils import convert_tools_to_agui_format
logger: logging.Logger = logging.getLogger(__name__)
class ServerFunctionCallContent(BaseContent):
"""Wrapper for server function calls to prevent client re-execution.
All function calls from the remote server are server-side executions.
This wrapper prevents @use_function_invocation from trying to execute them again.
"""
function_call_content: FunctionCallContent
def __init__(self, function_call_content: FunctionCallContent) -> None:
"""Initialize with the function call content."""
super().__init__(type="server_function_call")
self.function_call_content = function_call_content
def _unwrap_server_function_call_contents(contents: MutableSequence[Contents | dict[str, Any]]) -> None:
"""Replace ServerFunctionCallContent instances with their underlying call content."""
for idx, content in enumerate(contents):
if isinstance(content, ServerFunctionCallContent):
contents[idx] = content.function_call_content # type: ignore[assignment]
TBaseChatClient = TypeVar("TBaseChatClient", bound=type[BaseChatClient])
def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseChatClient:
"""Class decorator that unwraps server-side function calls after tool handling."""
original_get_streaming_response = chat_client.get_streaming_response
@wraps(original_get_streaming_response)
async def streaming_wrapper(self, *args: Any, **kwargs: Any) -> AsyncIterable[ChatResponseUpdate]:
async for update in original_get_streaming_response(self, *args, **kwargs):
_unwrap_server_function_call_contents(cast(MutableSequence[Contents | dict[str, Any]], update.contents))
yield update
chat_client.get_streaming_response = streaming_wrapper # type: ignore[assignment]
original_get_response = chat_client.get_response
@wraps(original_get_response)
async def response_wrapper(self, *args: Any, **kwargs: Any) -> ChatResponse:
response = await original_get_response(self, *args, **kwargs)
if response.messages:
for message in response.messages:
_unwrap_server_function_call_contents(
cast(MutableSequence[Contents | dict[str, Any]], message.contents)
)
return response
chat_client.get_response = response_wrapper # type: ignore[assignment]
return chat_client
@_apply_server_function_call_unwrap
@use_function_invocation
@use_observability
@use_chat_middleware
class AGUIChatClient(BaseChatClient):
"""Chat client for communicating with AG-UI compliant servers.
This client implements the BaseChatClient interface and automatically handles:
- Thread ID management for conversation continuity
- State synchronization between client and server
- Server-Sent Events (SSE) streaming
- Event conversion to Agent Framework types
Important: Message History Management
This client sends exactly the messages it receives to the server. It does NOT
automatically maintain conversation history. The server must handle history via thread_id.
For stateless servers: Use ChatAgent wrapper which will send full message history on each
request. However, even with ChatAgent, the server must echo back all context for the
agent to maintain history across turns.
Important: Tool Handling (Hybrid Execution - matches .NET)
1. Client tool metadata sent to server - LLM knows about both client and server tools
2. Server has its own tools that execute server-side
3. When LLM calls a client tool, @use_function_invocation executes it locally
4. Both client and server tools work together (hybrid pattern)
The wrapping ChatAgent's @use_function_invocation handles client tool execution
automatically when the server's LLM decides to call them.
Examples:
Direct usage (server manages thread history):
.. code-block:: python
from agent_framework.ag_ui import AGUIChatClient
client = AGUIChatClient(endpoint="http://localhost:8888/")
# First message - thread ID auto-generated
response = await client.get_response("Hello!")
thread_id = response.additional_properties.get("thread_id")
# Second message - server retrieves history using thread_id
response2 = await client.get_response(
"How are you?",
metadata={"thread_id": thread_id}
)
Recommended usage with ChatAgent (client manages history):
.. code-block:: python
from agent_framework import ChatAgent
from agent_framework.ag_ui import AGUIChatClient
client = AGUIChatClient(endpoint="http://localhost:8888/")
agent = ChatAgent(name="assistant", client=client)
thread = await agent.get_new_thread()
# ChatAgent automatically maintains history and sends full context
response = await agent.run("Hello!", thread=thread)
response2 = await agent.run("How are you?", thread=thread)
Streaming usage:
.. code-block:: python
async for update in client.get_streaming_response("Tell me a story"):
if update.contents:
for content in update.contents:
if hasattr(content, "text"):
print(content.text, end="", flush=True)
Context manager:
.. code-block:: python
async with AGUIChatClient(endpoint="http://localhost:8888/") as client:
response = await client.get_response("Hello!")
print(response.messages[0].text)
"""
OTEL_PROVIDER_NAME = "agui"
def __init__(
self,
*,
endpoint: str,
http_client: httpx.AsyncClient | None = None,
timeout: float = 60.0,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the AG-UI chat client.
Args:
endpoint: The AG-UI server endpoint URL (e.g., "http://localhost:8888/")
http_client: Optional httpx.AsyncClient instance. If None, one will be created.
timeout: Request timeout in seconds (default: 60.0)
additional_properties: Additional properties to store
**kwargs: Additional arguments passed to BaseChatClient
"""
super().__init__(additional_properties=additional_properties, **kwargs)
self._http_service = AGUIHttpService(
endpoint=endpoint,
http_client=http_client,
timeout=timeout,
)
async def close(self) -> None:
"""Close the HTTP client."""
await self._http_service.close()
async def __aenter__(self) -> "AGUIChatClient":
"""Enter async context manager."""
return self
async def __aexit__(self, *args: Any) -> None:
"""Exit async context manager."""
await self.close()
def _register_server_tool_placeholder(self, tool_name: str) -> None:
"""Register a declaration-only placeholder so function invocation skips execution."""
config = getattr(self, "function_invocation_configuration", None)
if not config:
return
if any(getattr(tool, "name", None) == tool_name for tool in config.additional_tools):
return
placeholder: AIFunction[Any, Any] = AIFunction(
name=tool_name,
description="Server-managed tool placeholder (AG-UI)",
func=None,
)
config.additional_tools = list(config.additional_tools) + [placeholder]
registered: set[str] = getattr(self, "_registered_server_tools", set())
registered.add(tool_name)
self._registered_server_tools = registered # type: ignore[attr-defined]
from agent_framework._logging import get_logger
logger = get_logger()
logger.debug(f"[AGUIChatClient] Registered server placeholder: {tool_name}")
def _extract_state_from_messages(
self, messages: MutableSequence[ChatMessage]
) -> tuple[list[ChatMessage], dict[str, Any] | None]:
"""Extract state from last message if present.
Args:
messages: List of chat messages
Returns:
Tuple of (messages_without_state, state_dict)
"""
if not messages:
return list(messages), None
last_message = messages[-1]
for content in last_message.contents:
if isinstance(content, DataContent) and content.media_type == "application/json":
try:
uri = content.uri
if uri.startswith("data:application/json;base64,"):
import base64
encoded_data = uri.split(",", 1)[1]
decoded_bytes = base64.b64decode(encoded_data)
state = json.loads(decoded_bytes.decode("utf-8"))
messages_without_state = list(messages[:-1]) if len(messages) > 1 else []
return messages_without_state, state
except (json.JSONDecodeError, ValueError, KeyError) as e:
from agent_framework._logging import get_logger
logger = get_logger()
logger.warning(f"Failed to extract state from message: {e}")
return list(messages), None
def _convert_messages_to_agui_format(self, messages: list[ChatMessage]) -> list[dict[str, Any]]:
"""Convert Agent Framework messages to AG-UI format.
Args:
messages: List of ChatMessage objects
Returns:
List of AG-UI formatted message dictionaries
"""
return agent_framework_messages_to_agui(messages)
def _get_thread_id(self, chat_options: ChatOptions) -> str:
"""Get or generate thread ID from chat options.
Args:
chat_options: Chat options containing metadata
Returns:
Thread ID string
"""
thread_id = None
if chat_options.metadata:
thread_id = chat_options.metadata.get("thread_id")
if not thread_id:
thread_id = f"thread_{uuid.uuid4().hex}"
return thread_id
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
"""Internal method to get non-streaming response.
Keyword Args:
messages: List of chat messages
chat_options: Chat options for the request
**kwargs: Additional keyword arguments
Returns:
ChatResponse object
"""
return await ChatResponse.from_chat_response_generator(
self._inner_get_streaming_response(
messages=messages,
chat_options=chat_options,
**kwargs,
)
)
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Internal method to get streaming response.
Keyword Args:
messages: List of chat messages
chat_options: Chat options for the request
**kwargs: Additional keyword arguments
Yields:
ChatResponseUpdate objects
"""
messages_to_send, state = self._extract_state_from_messages(messages)
thread_id = self._get_thread_id(chat_options)
run_id = f"run_{uuid.uuid4().hex}"
agui_messages = self._convert_messages_to_agui_format(messages_to_send)
# Send client tools to server so LLM knows about them
# Client tools execute via ChatAgent's @use_function_invocation wrapper
agui_tools = convert_tools_to_agui_format(chat_options.tools)
# Build set of client tool names (matches .NET clientToolSet)
# Used to distinguish client vs server tools in response stream
client_tool_set: set[str] = set()
if chat_options.tools:
for tool in chat_options.tools:
if hasattr(tool, "name"):
client_tool_set.add(tool.name) # type: ignore[arg-type]
self._last_client_tool_set = client_tool_set # type: ignore[attr-defined]
logger.debug(
"[AGUIChatClient] Preparing request",
extra={
"thread_id": thread_id,
"run_id": run_id,
"client_tools": list(client_tool_set),
"messages": [msg.text for msg in messages_to_send if msg.text],
},
)
logger.debug(f"[AGUIChatClient] Client tool set: {client_tool_set}")
converter = AGUIEventConverter()
async for event in self._http_service.post_run(
thread_id=thread_id,
run_id=run_id,
messages=agui_messages,
state=state,
tools=agui_tools,
):
logger.debug(f"[AGUIChatClient] Raw AG-UI event: {event}")
update = converter.convert_event(event)
if update is not None:
logger.debug(
"[AGUIChatClient] Converted update",
extra={"role": update.role, "contents": [type(c).__name__ for c in update.contents]},
)
# Distinguish client vs server tools
for i, content in enumerate(update.contents):
if isinstance(content, FunctionCallContent):
logger.debug(
f"[AGUIChatClient] Function call: {content.name}, in client_tool_set: {content.name in client_tool_set}"
)
if content.name in client_tool_set:
# Client tool - let @use_function_invocation execute it
if not content.additional_properties:
content.additional_properties = {}
content.additional_properties["agui_thread_id"] = thread_id
else:
# Server tool - wrap so @use_function_invocation ignores it
logger.debug(f"[AGUIChatClient] Wrapping server tool: {content.name}")
self._register_server_tool_placeholder(content.name)
update.contents[i] = ServerFunctionCallContent(content) # type: ignore
yield update
@@ -0,0 +1,209 @@
# Copyright (c) Microsoft. All rights reserved.
"""Event converter for AG-UI protocol events to Agent Framework types."""
from typing import Any
from agent_framework import (
ChatResponseUpdate,
ErrorContent,
FinishReason,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
)
class AGUIEventConverter:
"""Converter for AG-UI events to Agent Framework types.
Handles conversion of AG-UI protocol events to ChatResponseUpdate objects
while maintaining state, aggregating content, and tracking metadata.
"""
def __init__(self) -> None:
"""Initialize the converter with fresh state."""
self.current_message_id: str | None = None
self.current_tool_call_id: str | None = None
self.current_tool_name: str | None = None
self.accumulated_tool_args: str = ""
self.thread_id: str | None = None
self.run_id: str | None = None
def convert_event(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Convert a single AG-UI event to ChatResponseUpdate.
Args:
event: AG-UI event dictionary
Returns:
ChatResponseUpdate if event produces content, None otherwise
Examples:
RUN_STARTED event:
.. code-block:: python
converter = AGUIEventConverter()
event = {"type": "RUN_STARTED", "threadId": "t1", "runId": "r1"}
update = converter.convert_event(event)
assert update.additional_properties["thread_id"] == "t1"
TEXT_MESSAGE_CONTENT event:
.. code-block:: python
event = {"type": "TEXT_MESSAGE_CONTENT", "messageId": "m1", "delta": "Hello"}
update = converter.convert_event(event)
assert update.contents[0].text == "Hello"
"""
event_type = event.get("type", "")
if event_type == "RUN_STARTED":
return self._handle_run_started(event)
elif event_type == "TEXT_MESSAGE_START":
return self._handle_text_message_start(event)
elif event_type == "TEXT_MESSAGE_CONTENT":
return self._handle_text_message_content(event)
elif event_type == "TEXT_MESSAGE_END":
return self._handle_text_message_end(event)
elif event_type == "TOOL_CALL_START":
return self._handle_tool_call_start(event)
elif event_type == "TOOL_CALL_ARGS":
return self._handle_tool_call_args(event)
elif event_type == "TOOL_CALL_END":
return self._handle_tool_call_end(event)
elif event_type == "TOOL_CALL_RESULT":
return self._handle_tool_call_result(event)
elif event_type == "RUN_FINISHED":
return self._handle_run_finished(event)
elif event_type == "RUN_ERROR":
return self._handle_run_error(event)
return None
def _handle_run_started(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle RUN_STARTED event."""
self.thread_id = event.get("threadId")
self.run_id = event.get("runId")
return ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[],
additional_properties={
"thread_id": self.thread_id,
"run_id": self.run_id,
},
)
def _handle_text_message_start(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TEXT_MESSAGE_START event."""
self.current_message_id = event.get("messageId")
return ChatResponseUpdate(
role=Role.ASSISTANT,
message_id=self.current_message_id,
contents=[],
)
def _handle_text_message_content(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TEXT_MESSAGE_CONTENT event."""
message_id = event.get("messageId")
delta = event.get("delta", "")
if message_id != self.current_message_id:
self.current_message_id = message_id
return ChatResponseUpdate(
role=Role.ASSISTANT,
message_id=self.current_message_id,
contents=[TextContent(text=delta)],
)
def _handle_text_message_end(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TEXT_MESSAGE_END event."""
return None
def _handle_tool_call_start(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TOOL_CALL_START event."""
self.current_tool_call_id = event.get("toolCallId")
self.current_tool_name = event.get("toolName") or event.get("toolCallName") or event.get("tool_call_name")
self.accumulated_tool_args = ""
return ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
call_id=self.current_tool_call_id or "",
name=self.current_tool_name or "",
arguments="",
)
],
)
def _handle_tool_call_args(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TOOL_CALL_ARGS event."""
delta = event.get("delta", "")
self.accumulated_tool_args += delta
return ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
call_id=self.current_tool_call_id or "",
name=self.current_tool_name or "",
arguments=delta,
)
],
)
def _handle_tool_call_end(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
"""Handle TOOL_CALL_END event."""
self.accumulated_tool_args = ""
return None
def _handle_tool_call_result(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle TOOL_CALL_RESULT event."""
tool_call_id = event.get("toolCallId", "")
result = event.get("result") if event.get("result") is not None else event.get("content")
return ChatResponseUpdate(
role=Role.TOOL,
contents=[
FunctionResultContent(
call_id=tool_call_id,
result=result,
)
],
)
def _handle_run_finished(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle RUN_FINISHED event."""
return ChatResponseUpdate(
role=Role.ASSISTANT,
finish_reason=FinishReason.STOP,
contents=[],
additional_properties={
"thread_id": self.thread_id,
"run_id": self.run_id,
},
)
def _handle_run_error(self, event: dict[str, Any]) -> ChatResponseUpdate:
"""Handle RUN_ERROR event."""
error_message = event.get("message", "Unknown error")
return ChatResponseUpdate(
role=Role.ASSISTANT,
finish_reason=FinishReason.CONTENT_FILTER,
contents=[
ErrorContent(
message=error_message,
error_code="RUN_ERROR",
)
],
additional_properties={
"thread_id": self.thread_id,
"run_id": self.run_id,
},
)
@@ -107,7 +107,7 @@ class AgentFrameworkEventBridge:
# Skip text content if we're about to emit confirm_changes
# The summary should only appear after user confirms
if self.should_stop_after_confirm:
logger.debug(" >>> Skipping text content - waiting for confirm_changes response")
logger.debug("Skipping text content - waiting for confirm_changes response")
# Save the summary text to show after confirmation
self.suppressed_summary += content.text
continue
@@ -156,7 +156,7 @@ class AgentFrameworkEventBridge:
tool_call_name=content.name,
parent_message_id=self.current_message_id,
)
logger.info(f" >>> Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'")
logger.info(f"Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'")
events.append(tool_start_event)
# Track tool call for MessagesSnapshotEvent
@@ -186,7 +186,7 @@ class AgentFrameworkEventBridge:
# If it's a dict, convert to JSON
delta_str = json.dumps(content.arguments)
logger.info(f" >>> Emitting ToolCallArgsEvent with delta: {delta_str!r}..., id='{tool_call_id}'")
logger.info(f"Emitting ToolCallArgsEvent with delta: {delta_str!r}..., id='{tool_call_id}'")
args_event = ToolCallArgsEvent(
tool_call_id=tool_call_id,
delta=delta_str,
@@ -211,7 +211,7 @@ class AgentFrameworkEventBridge:
self.streaming_tool_args += json.dumps(content.arguments)
logger.debug(
f" >>> Predictive state: accumulated {len(self.streaming_tool_args)} chars for tool '{self.current_tool_call_name}'"
f"Predictive state: accumulated {len(self.streaming_tool_args)} chars for tool '{self.current_tool_call_name}'"
)
# Try to parse accumulated arguments (may be incomplete JSON)
@@ -262,11 +262,11 @@ class AgentFrameworkEventBridge:
else str(partial_value)
)
logger.info(
f" >>> StateDeltaEvent #{self.state_delta_count} for '{state_key}': "
f"StateDeltaEvent #{self.state_delta_count} for '{state_key}': "
f"op=replace, path=/{state_key}, value={value_preview}"
)
elif self.state_delta_count % 100 == 0:
logger.info(f" >>> StateDeltaEvent #{self.state_delta_count} emitted")
logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted")
events.append(state_delta_event)
self.last_emitted_state[state_key] = partial_value
@@ -312,11 +312,11 @@ class AgentFrameworkEventBridge:
else str(state_value)
)
logger.info(
f" >>> StateDeltaEvent #{self.state_delta_count} for '{state_key}': "
f"StateDeltaEvent #{self.state_delta_count} for '{state_key}': "
f"op=replace, path=/{state_key}, value={value_preview}"
)
elif self.state_delta_count % 100 == 0: # Also log every 100th
logger.info(f" >>> StateDeltaEvent #{self.state_delta_count} emitted")
logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted")
events.append(state_delta_event)
@@ -360,7 +360,7 @@ class AgentFrameworkEventBridge:
],
)
logger.info(
f" >>> Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}"
f"Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}"
)
events.append(state_delta_event)
@@ -376,13 +376,13 @@ class AgentFrameworkEventBridge:
end_event = ToolCallEndEvent(
tool_call_id=content.call_id,
)
logger.info(f" >>> Emitting ToolCallEndEvent for completed tool call '{content.call_id}'")
logger.info(f"Emitting ToolCallEndEvent for completed tool call '{content.call_id}'")
events.append(end_event)
# Log total StateDeltaEvent count for this tool call
if self.state_delta_count > 0:
logger.info(
f" >>> Tool call '{content.call_id}' complete: emitted {self.state_delta_count} StateDeltaEvents total"
f"Tool call '{content.call_id}' complete: emitted {self.state_delta_count} StateDeltaEvents total"
)
# Reset streaming accumulator and counter for next tool call
@@ -410,11 +410,13 @@ class AgentFrameworkEventBridge:
events.append(result_event)
# Track tool result for MessagesSnapshotEvent
# AG-UI protocol expects: { role: "tool", toolCallId: ..., content: ... }
# Use camelCase for Pydantic's alias_generator=to_camel
self.tool_results.append(
{
"id": result_message_id,
"role": "tool",
"tool_call_id": content.call_id,
"toolCallId": content.call_id,
"content": result_content,
}
)
@@ -422,6 +424,9 @@ class AgentFrameworkEventBridge:
# Emit MessagesSnapshotEvent with the complete conversation including tool calls and results
# This is required for CopilotKit's useCopilotAction to detect tool result
if self.pending_tool_calls and self.tool_results:
# Import message adapter
from ._message_adapters import agent_framework_messages_to_agui
# Build assistant message with tool_calls
assistant_message = {
"id": generate_event_id(),
@@ -429,14 +434,19 @@ class AgentFrameworkEventBridge:
"tool_calls": self.pending_tool_calls.copy(), # Copy the accumulated tool calls
}
# Convert Agent Framework messages to AG-UI format (adds required 'id' field)
converted_input_messages = agent_framework_messages_to_agui(self.input_messages)
# Build complete messages array: input messages + assistant message + tool results
all_messages = list(self.input_messages) + [assistant_message] + self.tool_results.copy()
all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy()
# Emit MessagesSnapshotEvent using the proper event type
# Note: messages are dict[str, Any] but Pydantic will validate them as Message types
messages_snapshot_event = MessagesSnapshotEvent(
type=EventType.MESSAGES_SNAPSHOT, messages=all_messages
type=EventType.MESSAGES_SNAPSHOT,
messages=all_messages, # type: ignore[arg-type]
)
logger.info(f" >>> Emitting MessagesSnapshotEvent with {len(all_messages)} messages")
logger.info(f"Emitting MessagesSnapshotEvent with {len(all_messages)} messages")
events.append(messages_snapshot_event)
# After tool execution, emit StateSnapshotEvent if we have pending state updates
@@ -466,7 +476,7 @@ class AgentFrameworkEventBridge:
# If so, emit a confirm_changes tool call for the UI modal
tool_was_predictive = False
logger.debug(
f" >>> Checking predictive state: current_tool='{self.current_tool_call_name}', "
f"Checking predictive state: current_tool='{self.current_tool_call_name}', "
f"predict_config={list(self.predict_state_config.keys()) if self.predict_state_config else 'None'}"
)
for state_key, config in self.predict_state_config.items():
@@ -474,7 +484,7 @@ class AgentFrameworkEventBridge:
# We need to match against self.current_tool_call_name
if self.current_tool_call_name and config["tool"] == self.current_tool_call_name:
logger.info(
f" >>> Tool '{self.current_tool_call_name}' matches predictive config for state key '{state_key}'"
f"Tool '{self.current_tool_call_name}' matches predictive config for state key '{state_key}'"
)
tool_was_predictive = True
break
@@ -483,7 +493,7 @@ class AgentFrameworkEventBridge:
# Emit confirm_changes tool call sequence
confirm_call_id = generate_event_id()
logger.info(" >>> Emitting confirm_changes tool call for predictive update")
logger.info("Emitting confirm_changes tool call for predictive update")
# Track confirm_changes tool call for MessagesSnapshotEvent (so it persists after RUN_FINISHED)
self.pending_tool_calls.append(
@@ -518,6 +528,9 @@ class AgentFrameworkEventBridge:
events.append(confirm_end)
# Emit MessagesSnapshotEvent so confirm_changes persists after RUN_FINISHED
# Import message adapter
from ._message_adapters import agent_framework_messages_to_agui
# Build assistant message with pending confirm_changes tool call
assistant_message = {
"id": generate_event_id(),
@@ -525,23 +538,28 @@ class AgentFrameworkEventBridge:
"tool_calls": self.pending_tool_calls.copy(), # Includes confirm_changes
}
# Convert Agent Framework messages to AG-UI format (adds required 'id' field)
converted_input_messages = agent_framework_messages_to_agui(self.input_messages)
# Build complete messages array: input messages + assistant message + any tool results
all_messages = list(self.input_messages) + [assistant_message] + self.tool_results.copy()
all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy()
# Emit MessagesSnapshotEvent
# Note: messages are dict[str, Any] but Pydantic will validate them as Message types
messages_snapshot_event = MessagesSnapshotEvent(
type=EventType.MESSAGES_SNAPSHOT, messages=all_messages
type=EventType.MESSAGES_SNAPSHOT,
messages=all_messages, # type: ignore[arg-type]
)
logger.info(
f" >>> Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages"
f"Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages"
)
events.append(messages_snapshot_event)
# Set flag to stop the run after this - we're waiting for user response
self.should_stop_after_confirm = True
logger.info(" >>> Set flag to stop run after confirm_changes")
logger.info("Set flag to stop run after confirm_changes")
elif tool_was_predictive:
logger.info(" >>> Skipping confirm_changes - require_confirmation is False")
logger.info("Skipping confirm_changes - require_confirmation is False")
# Clear pending updates and reset tool name tracker
self.pending_state_updates.clear()
@@ -580,7 +598,7 @@ class AgentFrameworkEventBridge:
# Update current state
self.current_state[state_key] = state_value
logger.info(
f" >>> Emitting StateSnapshotEvent for key '{state_key}', value type: {type(state_value)}"
f"Emitting StateSnapshotEvent for key '{state_key}', value type: {type(state_value)}"
)
# Emit state snapshot
@@ -596,7 +614,7 @@ class AgentFrameworkEventBridge:
tool_call_id=content.function_call.call_id,
)
logger.info(
f" >>> Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'"
f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'"
)
events.append(end_event)
@@ -615,7 +633,7 @@ class AgentFrameworkEventBridge:
},
},
)
logger.info(f" >>> Emitting function_approval_request custom event for '{content.function_call.name}'")
logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'")
events.append(approval_event)
return events
@@ -0,0 +1,157 @@
# Copyright (c) Microsoft. All rights reserved.
"""HTTP service for AG-UI protocol communication."""
import json
import logging
from collections.abc import AsyncIterable
from typing import Any
import httpx
logger = logging.getLogger(__name__)
class AGUIHttpService:
"""HTTP service for AG-UI protocol communication.
Handles HTTP POST requests and Server-Sent Events (SSE) stream parsing
for the AG-UI protocol.
Examples:
Basic usage:
.. code-block:: python
service = AGUIHttpService("http://localhost:8888/")
async for event in service.post_run(
thread_id="thread_123",
run_id="run_456",
messages=[{"role": "user", "content": "Hello"}]
):
print(event["type"])
With context manager:
.. code-block:: python
async with AGUIHttpService("http://localhost:8888/") as service:
async for event in service.post_run(...):
print(event)
"""
def __init__(
self,
endpoint: str,
http_client: httpx.AsyncClient | None = None,
timeout: float = 60.0,
) -> None:
"""Initialize the HTTP service.
Args:
endpoint: AG-UI server endpoint URL (e.g., "http://localhost:8888/")
http_client: Optional httpx AsyncClient. If None, creates a new one.
timeout: Request timeout in seconds (default: 60.0)
"""
self.endpoint = endpoint.rstrip("/")
self._owns_client = http_client is None
self.http_client = http_client or httpx.AsyncClient(timeout=timeout)
async def post_run(
self,
thread_id: str,
run_id: str,
messages: list[dict[str, Any]],
state: dict[str, Any] | None = None,
tools: list[dict[str, Any]] | None = None,
) -> AsyncIterable[dict[str, Any]]:
"""Post a run request and stream AG-UI events.
Args:
thread_id: Thread identifier for conversation continuity
run_id: Unique run identifier
messages: List of messages in AG-UI format
state: Optional state object to send to server
tools: Optional list of tools available to the agent
Yields:
AG-UI event dictionaries parsed from SSE stream
Raises:
httpx.HTTPStatusError: If the HTTP request fails
ValueError: If SSE parsing encounters invalid data
Examples:
.. code-block:: python
service = AGUIHttpService("http://localhost:8888/")
async for event in service.post_run(
thread_id="thread_abc",
run_id="run_123",
messages=[{"role": "user", "content": "Hello"}],
state={"user_context": {"name": "Alice"}}
):
if event["type"] == "TEXT_MESSAGE_CONTENT":
print(event["delta"])
"""
# Build request payload
request_data: dict[str, Any] = {
"thread_id": thread_id,
"run_id": run_id,
"messages": messages,
}
if state is not None:
request_data["state"] = state
if tools is not None:
request_data["tools"] = tools
logger.debug(
f"Posting run to {self.endpoint}: thread_id={thread_id}, run_id={run_id}, "
f"messages={len(messages)}, has_state={state is not None}, has_tools={tools is not None}"
)
# Stream the response using SSE
async with self.http_client.stream(
"POST",
self.endpoint,
json=request_data,
headers={"Accept": "text/event-stream"},
) as response:
try:
response.raise_for_status()
except httpx.HTTPStatusError as e:
logger.error(f"HTTP request failed: {e.response.status_code} - {e.response.text}")
raise
async for line in response.aiter_lines():
# Parse Server-Sent Events format
if line.startswith("data: "):
data = line[6:] # Remove "data: " prefix
try:
event = json.loads(data)
logger.debug(f"Received event: {event.get('type', 'UNKNOWN')}")
yield event
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse SSE data: {data}. Error: {e}")
# Continue processing other events instead of failing
continue
async def close(self) -> None:
"""Close the HTTP client if owned by this service.
Only closes the client if it was created by this service instance.
If an external client was provided, it remains the caller's
responsibility to close it.
"""
if self._owns_client and self.http_client:
await self.http_client.aclose()
async def __aenter__(self) -> "AGUIHttpService":
"""Enter async context manager."""
return self
async def __aexit__(self, *args: Any) -> None:
"""Exit async context manager and clean up resources."""
await self.close()
@@ -2,12 +2,13 @@
"""Message format conversion between AG-UI and Agent Framework."""
from typing import Any
from typing import Any, cast
from agent_framework import (
ChatMessage,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
)
@@ -46,7 +47,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
result_content = msg.get("result", msg.get("content", ""))
chat_msg = ChatMessage(
role=Role.ASSISTANT, # Tool results are assistant messages
role=Role.TOOL, # Tool results must be tool role
contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)],
)
@@ -56,6 +57,42 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
result.append(chat_msg)
continue
# If assistant message includes tool calls, convert to FunctionCallContent(s)
tool_calls = msg.get("tool_calls") or msg.get("toolCalls")
if tool_calls:
contents: list[Any] = []
# Include any assistant text content if present
content_text = msg.get("content")
if isinstance(content_text, str) and content_text:
contents.append(TextContent(text=content_text))
# Convert each tool call entry
for tc in tool_calls:
if not isinstance(tc, dict):
continue
# Cast to typed dict for proper type inference
tc_dict = cast(dict[str, Any], tc)
tc_type = tc_dict.get("type")
if tc_type == "function":
func_data = tc_dict.get("function", {})
func_dict = cast(dict[str, Any], func_data) if isinstance(func_data, dict) else {}
call_id = str(tc_dict.get("id", ""))
name = str(func_dict.get("name", ""))
arguments = func_dict.get("arguments")
contents.append(
FunctionCallContent(
call_id=call_id,
name=name,
arguments=arguments,
)
)
chat_msg = ChatMessage(role=Role.ASSISTANT, contents=contents)
if "id" in msg:
chat_msg.message_id = msg["id"]
result.append(chat_msg)
continue
role_str = msg.get("role", "user")
# Handle tool result messages (with role="tool")
@@ -78,11 +115,11 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# Backend tool results have non-empty content WITHOUT "accepted" field
if tool_call_id and result_content and not is_approval:
# Backend tool execution - convert to FunctionResultContent
# Tool execution result - convert to FunctionResultContent with correct role
from agent_framework import FunctionResultContent
chat_msg = ChatMessage(
role=Role.ASSISTANT, # Tool results are assistant messages
role=Role.TOOL,
contents=[FunctionResultContent(call_id=tool_call_id, result=result_content)],
)
@@ -97,9 +134,8 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
chat_msg = ChatMessage(
role=Role.USER, # Approval responses are user messages
contents=[TextContent(text=content)],
additional_properties={"is_tool_result": True, "tool_call_id": msg.get("toolCallId", "")},
)
# Mark this as a tool result so we can detect it later
chat_msg.metadata = {"is_tool_result": True, "tool_call_id": msg.get("toolCallId", "")} # type: ignore[attr-defined]
if "id" in msg:
chat_msg.message_id = msg["id"]
@@ -112,7 +148,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# Check if this message contains function approvals
if "function_approvals" in msg and msg["function_approvals"]:
# Convert function approvals to FunctionApprovalResponseContent
contents: list[Any] = []
approval_contents: list[Any] = []
for approval in msg["function_approvals"]:
# Create FunctionCallContent with the modified arguments
func_call = FunctionCallContent(
@@ -127,9 +163,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
id=approval.get("id", ""),
function_call=func_call,
)
contents.append(approval_response)
approval_contents.append(approval_response)
chat_msg = ChatMessage(role=role, contents=contents) # type: ignore[arg-type]
chat_msg = ChatMessage(role=role, contents=approval_contents) # type: ignore[arg-type]
else:
# Regular text message
content = msg.get("content", "")
@@ -146,21 +182,44 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
return result
def agent_framework_messages_to_agui(messages: list[ChatMessage]) -> list[dict[str, Any]]:
def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert Agent Framework messages to AG-UI format.
Args:
messages: List of Agent Framework ChatMessage objects
messages: List of Agent Framework ChatMessage objects or AG-UI dicts (already converted)
Returns:
List of AG-UI message dictionaries
"""
from ._utils import generate_event_id
result: list[dict[str, Any]] = []
for msg in messages:
# If already a dict (AG-UI format), ensure it has an ID and normalize keys for Pydantic
if isinstance(msg, dict):
# Always work on a copy to avoid mutating input
normalized_msg = msg.copy()
# Ensure ID exists
if "id" not in normalized_msg:
normalized_msg["id"] = generate_event_id()
# Normalize tool_call_id to toolCallId for Pydantic's alias_generator=to_camel
if normalized_msg.get("role") == "tool":
if "tool_call_id" in normalized_msg:
normalized_msg["toolCallId"] = normalized_msg["tool_call_id"]
del normalized_msg["tool_call_id"]
elif "toolCallId" not in normalized_msg:
# Tool message missing toolCallId - add empty string to satisfy schema
normalized_msg["toolCallId"] = ""
# Always append the normalized copy, not the original
result.append(normalized_msg)
continue
# Convert ChatMessage to AG-UI format
role = _FRAMEWORK_TO_AGUI_ROLE.get(msg.role, "user")
content_text = ""
tool_calls: list[dict[str, Any]] = []
tool_result_call_id: str | None = None
for content in msg.contents:
if isinstance(content, TextContent):
@@ -176,18 +235,32 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage]) -> list[dict[s
},
}
)
elif isinstance(content, FunctionResultContent):
# Tool result content - extract call_id and result
tool_result_call_id = content.call_id
# Serialize result to string
if isinstance(content.result, dict):
import json
content_text = json.dumps(content.result) # type: ignore
elif content.result is not None:
content_text = str(content.result)
agui_msg: dict[str, Any] = {
"id": msg.message_id if msg.message_id else generate_event_id(), # Always include id
"role": role,
"content": content_text,
}
if msg.message_id:
agui_msg["id"] = msg.message_id
if tool_calls:
agui_msg["tool_calls"] = tool_calls
# If this is a tool result message, add toolCallId (using camelCase for Pydantic)
if tool_result_call_id:
agui_msg["toolCallId"] = tool_result_call_id
# Tool result messages should have role="tool"
agui_msg["role"] = "tool"
result.append(agui_msg)
return result

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