diff --git a/.github/skills/run-device-tests/SKILL.md b/.github/skills/run-device-tests/SKILL.md index bd8d7b842ffa..5ad415535a11 100644 --- a/.github/skills/run-device-tests/SKILL.md +++ b/.github/skills/run-device-tests/SKILL.md @@ -54,6 +54,7 @@ These are automatically loaded by the Run-DeviceTests.ps1 script. | Essentials | `src/Essentials/test/DeviceTests/Essentials.DeviceTests.csproj` | | Graphics | `src/Graphics/tests/DeviceTests/Graphics.DeviceTests.csproj` | | BlazorWebView | `src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj` | +| AI | `src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj` | ## Scripts diff --git a/.github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 b/.github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 index f94b2d5b044e..3f5620b79684 100644 --- a/.github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 +++ b/.github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 @@ -12,7 +12,7 @@ - Windows: android, windows .PARAMETER Project - The device test project to run. Valid values: Controls, Core, Essentials, Graphics, BlazorWebView + The device test project to run. Valid values: Controls, Core, Essentials, Graphics, BlazorWebView, AI .PARAMETER Platform Target platform. Valid values depend on OS: @@ -65,7 +65,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] - [ValidateSet("Controls", "Core", "Essentials", "Graphics", "BlazorWebView")] + [ValidateSet("Controls", "Core", "Essentials", "Graphics", "BlazorWebView", "AI")] [string]$Project, [Parameter(Mandatory = $false)] @@ -128,6 +128,7 @@ $ProjectPaths = @{ "Essentials" = "src/Essentials/test/DeviceTests/Essentials.DeviceTests.csproj" "Graphics" = "src/Graphics/tests/DeviceTests/Graphics.DeviceTests.csproj" "BlazorWebView" = "src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj" + "AI" = "src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj" } $AppNames = @{ @@ -136,6 +137,7 @@ $AppNames = @{ "Essentials" = "Microsoft.Maui.Essentials.DeviceTests" "Graphics" = "Microsoft.Maui.Graphics.DeviceTests" "BlazorWebView" = "Microsoft.Maui.MauiBlazorWebView.DeviceTests" + "AI" = "Microsoft.Maui.Essentials.AI.DeviceTests" } # Android package names (lowercase) @@ -145,6 +147,7 @@ $AndroidPackageNames = @{ "Essentials" = "com.microsoft.maui.essentials.devicetests" "Graphics" = "com.microsoft.maui.graphics.devicetests" "BlazorWebView" = "com.microsoft.maui.mauiblazorwebview.devicetests" + "AI" = "com.microsoft.maui.ai.devicetests" } # Platform-specific configurations @@ -239,6 +242,8 @@ try { $projectPath = $ProjectPaths[$Project] $appName = $AppNames[$Project] + # Derive artifact folder name from the project file name (e.g., "Essentials.AI.DeviceTests" from the .csproj) + $artifactName = [System.IO.Path]::GetFileNameWithoutExtension($projectPath) Write-Host "" Write-Host "Project: $Project" -ForegroundColor Yellow @@ -316,11 +321,11 @@ try { # Construct app path based on platform switch ($Platform) { "ios" { - $appPath = "artifacts/bin/$Project.DeviceTests/$Configuration/$tfmFolder/$ridFolder/$appName.app" + $appPath = "artifacts/bin/$artifactName/$Configuration/$tfmFolder/$ridFolder/$appName.app" } "maccatalyst" { # MacCatalyst apps may have different names - search for .app bundle - $appSearchPath = "artifacts/bin/$Project.DeviceTests/$Configuration/$tfmFolder/$ridFolder" + $appSearchPath = "artifacts/bin/$artifactName/$Configuration/$tfmFolder/$ridFolder" $appBundle = Get-ChildItem -Path $appSearchPath -Filter "*.app" -Directory -ErrorAction SilentlyContinue | Select-Object -First 1 if ($appBundle) { $appPath = $appBundle.FullName @@ -330,7 +335,7 @@ try { } "android" { # Android APK path - look for signed APK - $apkSearchPath = "artifacts/bin/$Project.DeviceTests/$Configuration/$tfmFolder" + $apkSearchPath = "artifacts/bin/$artifactName/$Configuration/$tfmFolder" $apkFile = Get-ChildItem -Path $apkSearchPath -Filter "*-Signed.apk" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 if ($apkFile) { $appPath = $apkFile.FullName @@ -345,14 +350,14 @@ try { } } "windows" { - $appPath = "artifacts/bin/$Project.DeviceTests/$Configuration/$tfmFolder/$ridFolder/$appName.exe" + $appPath = "artifacts/bin/$artifactName/$Configuration/$tfmFolder/$ridFolder/$appName.exe" } } if (-not (Test-Path $appPath)) { Write-Error "Built app not found at: $appPath" Write-Info "Searching for app in artifacts..." - Get-ChildItem -Path "artifacts/bin/$Project.DeviceTests" -Recurse -ErrorAction SilentlyContinue | + Get-ChildItem -Path "artifacts/bin/$artifactName" -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -match "$appName" } | ForEach-Object { Write-Host " Found: $($_.FullName)" } exit 1 diff --git a/eng/Build.props b/eng/Build.props index 691d93a5904d..26f2232be3dd 100644 --- a/eng/Build.props +++ b/eng/Build.props @@ -31,5 +31,6 @@ CodesignRequireProvisioningProfile=false + diff --git a/eng/Versions.props b/eng/Versions.props index eb1d76746363..27b2954b2c4b 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -42,8 +42,8 @@ $(MicrosoftNETCoreAppRefPackageVersion) $(MicrosoftNETCoreAppRefPackageVersion) - 10.0.1 - 10.0.1 + 10.3.0 + 10.3.0 10.0.0 10.0.0 10.0.0 @@ -58,7 +58,10 @@ 10.0.0 10.0.0 - 1.0.0-preview.251204.1 + 1.0.0-rc2 + 1.0.0-rc2 + 1.0.0-rc2 + 1.0.0-preview.260225.1 36.1.2 35.0.105 diff --git a/eng/cake/dotnet.cake b/eng/cake/dotnet.cake index 918774f8fda3..09b7378ab229 100644 --- a/eng/cake/dotnet.cake +++ b/eng/cake/dotnet.cake @@ -270,6 +270,7 @@ Task("dotnet-test") "**/Controls.BindingSourceGen.UnitTests.csproj", "**/Core.UnitTests.csproj", "**/Essentials.UnitTests.csproj", + "**/Essentials.AI.UnitTests.csproj", "**/Resizetizer.UnitTests.csproj", "**/Graphics.Tests.csproj", "**/Compatibility.Core.UnitTests.csproj", diff --git a/eng/helix.proj b/eng/helix.proj index 5ba803fef9ba..565ce270a50c 100644 --- a/eng/helix.proj +++ b/eng/helix.proj @@ -35,6 +35,7 @@ + diff --git a/eng/helix_xharness.proj b/eng/helix_xharness.proj index c99d5a7526b5..ffba39148bb1 100644 --- a/eng/helix_xharness.proj +++ b/eng/helix_xharness.proj @@ -31,6 +31,11 @@ CollectionView;Shell;HybridWebView + + + + AppleIntelligenceChatClient + @@ -119,6 +124,15 @@ Microsoft.Maui.MauiBlazorWebView.DeviceTests src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj + + + Essentials.AI.DeviceTests + $(ScenariosDir)Essentials.AI.DeviceTests + Microsoft.Maui.Essentials.AI.DeviceTests + com.microsoft.maui.ai.devicetests + com.microsoft.maui.ai.devicetests + src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj + @@ -173,15 +187,33 @@ 02:00:00 01:00:00 + + ios-simulator-64 + 02:00:00 + 01:00:00 + xharness apple test --target "$target" --app "$app" --output-directory "$output_directory" --timeout "$timeout" --launch-timeout "$launch_timeout" --set-env="TestFilter=SkipCategories=$(AITestCategoriesToSkipOnCI)" + - + - + + <_MAUIScenarioSearchMacCatalyst Include="@(_MAUIScenarioSearch)" /> + <_MAUIScenarioSearchMacCatalyst Remove="EssentialsAI" /> + + + maccatalyst + 02:00:00 + 01:00:00 + %(_MAUIScenarioSearchMacCatalyst.ScenarioDirectoryName) + + + + maccatalyst 02:00:00 01:00:00 - %(_MAUIScenarioSearch.ScenarioDirectoryName) + xharness apple test --target "$target" --app "$app" --output-directory "$output_directory" --timeout "$timeout" --launch-timeout "$launch_timeout" --set-env="TestFilter=SkipCategories=$(AITestCategoriesToSkipOnCI)" diff --git a/eng/pipelines/arcade/stage-device-tests.yml b/eng/pipelines/arcade/stage-device-tests.yml index ac53318edca2..e4c84a19d9e0 100644 --- a/eng/pipelines/arcade/stage-device-tests.yml +++ b/eng/pipelines/arcade/stage-device-tests.yml @@ -79,6 +79,9 @@ parameters: - name: MauiBlazorWebView.DeviceTests path: src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj packageId: Microsoft.Maui.MauiBlazorWebView.DeviceTests + - name: Essentials.AI.DeviceTests + path: src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj + packageId: com.microsoft.maui.ai.devicetests stages: - stage: devicetests_build @@ -465,7 +468,7 @@ stages: # Save unpackaged publish output before packaged builds overwrite artifacts/bin - pwsh: | - $artifactNames = @("Controls.DeviceTests", "Core.DeviceTests", "Graphics.DeviceTests", "Essentials.DeviceTests", "MauiBlazorWebView.DeviceTests") + $artifactNames = @("Controls.DeviceTests", "Core.DeviceTests", "Graphics.DeviceTests", "Essentials.DeviceTests", "MauiBlazorWebView.DeviceTests", "Essentials.AI.DeviceTests") foreach ($name in $artifactNames) { $publishDir = Get-ChildItem -Path "$(Build.SourcesDirectory)/artifacts/bin/$name" -Filter "publish" -Recurse -Directory | Select-Object -First 1 if ($publishDir) { @@ -498,7 +501,8 @@ stages: @{ Name = "Core.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/Core/tests/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/Core.DeviceTests" }, @{ Name = "Graphics.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/Graphics/tests/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/Graphics.DeviceTests" }, @{ Name = "Essentials.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/Essentials/test/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/Essentials.DeviceTests" }, - @{ Name = "MauiBlazorWebView.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/BlazorWebView/tests/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/MauiBlazorWebView.DeviceTests" } + @{ Name = "MauiBlazorWebView.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/BlazorWebView/tests/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/MauiBlazorWebView.DeviceTests" }, + @{ Name = "Essentials.AI.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/AI/tests/Essentials.AI.DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/Essentials.AI.DeviceTests" } ) foreach ($project in $projects) { diff --git a/eng/pipelines/device-tests.yml b/eng/pipelines/device-tests.yml index af6f271db91a..fd954605eecc 100644 --- a/eng/pipelines/device-tests.yml +++ b/eng/pipelines/device-tests.yml @@ -216,3 +216,15 @@ stages: ios: $(System.DefaultWorkingDirectory)/src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj catalyst: $(System.DefaultWorkingDirectory)/src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj windows: $(System.DefaultWorkingDirectory)/src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj + - name: essentialsai + desc: Essentials.AI + androidApiLevelsExclude: [ 25, 27 ] + androidApiLevelsCoreClrExclude: [ 27, 25, 23] + androidConfiguration: 'Release' + iOSConfiguration: 'Debug' + windowsConfiguration: 'Debug' + windowsPackageId: 'com.microsoft.maui.ai.devicetests' + android: $(System.DefaultWorkingDirectory)/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj + ios: $(System.DefaultWorkingDirectory)/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj + catalyst: $(System.DefaultWorkingDirectory)/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj + windows: $(System.DefaultWorkingDirectory)/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj diff --git a/src/AI/samples/Essentials.AI.Sample/AI/1_TravelPlannerExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/1_TravelPlannerExecutor.cs index b8522927d006..8f20bca343c0 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/1_TravelPlannerExecutor.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/1_TravelPlannerExecutor.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -9,30 +8,17 @@ namespace Maui.Controls.Sample.AI; /// /// Agent 1: Travel Planner - Parses natural language to extract intent. /// No tools - just NLP to extract destinationName, dayCount, language. -/// Extends ChatProtocolExecutor to support the chat protocol for workflow-as-agent. /// -internal sealed class TravelPlannerExecutor(AIAgent agent, JsonSerializerOptions jsonOptions, ILogger logger) - : ChatProtocolExecutor("TravelPlannerExecutor") +internal sealed class TravelPlannerExecutor(AIAgent agent, ILogger logger) + : ChatProtocolExecutor("TravelPlannerExecutor", new ChatProtocolExecutorOptions { AutoSendTurnToken = false }) { - public const string Instructions = """ - You are a simple text parser. - - Extract ONLY these 3 values from the user's request: - 1. destinationName: The place/location name mentioned (extract it exactly as written) - 2. dayCount: The number of days mentioned (default: 3 if not specified) - 3. language: The language mentioned for the output (default: English if not specified) - - Rules: - 1. ALWAYS extract the raw values. - 2. NEVER make up values or interpret the user's intent. - - Examples: - - "5-day trip to Maui in French" → destinationName: "Maui", dayCount: 5, language: "French" - - "Visit the Great Wall" → destinationName: "Great Wall", dayCount: 3, language: "English" - - "Itinerary for Tokyo" → destinationName: "Tokyo", dayCount: 3, language: "English" - - "Give me a Maui itinerary" → destinationName: "Maui", dayCount: 3, language: "English" - - "Plan a 7 day Japan trip in Spanish" → destinationName: "Japan", dayCount: 7, language: "Spanish" - """; + /// + /// Declares TravelPlanResult as a sent message type so the edge router can map it to downstream executors. + /// Without this, ChatProtocolExecutor only declares List<ChatMessage> and TurnToken, causing + /// TravelPlanResult to be silently dropped with DroppedTypeMismatch. + /// + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => base.ConfigureProtocol(protocolBuilder).SendsMessage(); protected override async ValueTask TakeTurnAsync( List messages, @@ -42,18 +28,13 @@ protected override async ValueTask TakeTurnAsync( { logger.LogDebug("[TravelPlannerExecutor] Starting - parsing user intent"); - await context.AddEventAsync(new ExecutorStatusEvent("Analyzing your request...")); + await context.AddEventAsync(new ExecutorStatusEvent("Analyzing your request..."), cancellationToken); - var runOptions = new ChatClientAgentRunOptions(new ChatOptions - { - ResponseFormat = ChatResponseFormat.ForJsonSchema(jsonOptions) - }); - - var response = await agent.RunAsync(messages, options: runOptions, cancellationToken: cancellationToken); + var response = await agent.RunAsync(messages, cancellationToken: cancellationToken); logger.LogTrace("[TravelPlannerExecutor] Raw response: {Response}", response.Text); - var result = JsonSerializer.Deserialize(response.Text, jsonOptions)!; + var result = response.Result; logger.LogDebug("[TravelPlannerExecutor] Completed - extracted: destination={Destination}, days={Days}, language={Language}", result.DestinationName, result.DayCount, result.Language); @@ -61,7 +42,7 @@ protected override async ValueTask TakeTurnAsync( var summary = result.Language != "English" ? $"Planning {result.DayCount}-day trip to {result.DestinationName} in {result.Language}" : $"Planning {result.DayCount}-day trip to {result.DestinationName}"; - await context.AddEventAsync(new ExecutorStatusEvent(summary)); + await context.AddEventAsync(new ExecutorStatusEvent(summary), cancellationToken); await context.SendMessageAsync(result, cancellationToken); } diff --git a/src/AI/samples/Essentials.AI.Sample/AI/2_ResearcherExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/2_ResearcherExecutor.cs index 409968a245d7..ddc9cad04547 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/2_ResearcherExecutor.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/2_ResearcherExecutor.cs @@ -1,40 +1,20 @@ -using System.ComponentModel; -using System.Text.Json; -using Maui.Controls.Sample.Models; -using Maui.Controls.Sample.Services; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; namespace Maui.Controls.Sample.AI; /// -/// Agent 2: Researcher - Uses RAG to find candidate destinations, then AI selects the best match. -/// Uses semantic search (embeddings) to pre-filter destinations, then LLM picks the best one. +/// Agent 2: Researcher - Uses TextSearchProvider (RAG) to automatically inject matching destinations +/// into the AI context before each invocation, then the AI selects the best match. +/// The TextSearchProvider is configured in with BeforeAIInvoke mode, so candidate destinations are +/// automatically searched and injected. /// -internal sealed class ResearcherExecutor(AIAgent agent, DataService dataService, JsonSerializerOptions jsonOptions, ILogger logger) - : Executor("ResearcherExecutor") +internal sealed partial class ResearcherExecutor(AIAgent agent, ILogger logger) + : Executor("ResearcherExecutor") { - /// - /// Maximum number of RAG candidates to return from semantic search. - /// - private const int MaxRagCandidates = 5; - - public const string Instructions = """ - You are a travel researcher. - Your job is to select the best matching destination from a list of candidates. - - Rules: - 1. You will be given a list of candidate destinations that semantically match the user's request. - 2. Select the ONE destination that best matches what the user asked for. - 3. NEVER make up destinations - only choose from the provided candidates. - 4. If none of the candidates match well, pick the closest one. - - Return the exact name of the best matching destination from the candidates. - """; - - public override async ValueTask HandleAsync( + [MessageHandler] + private async ValueTask HandleAsync( TravelPlanResult input, IWorkflowContext context, CancellationToken cancellationToken = default) @@ -42,72 +22,34 @@ public override async ValueTask HandleAsync( logger.LogDebug("[ResearcherExecutor] Starting - finding best matching destination for '{DestinationName}'", input.DestinationName); logger.LogTrace("[ResearcherExecutor] Input: {@Input}", input); - await context.AddEventAsync(new ExecutorStatusEvent("Searching destinations...")); - - // Step 1: Use RAG to find semantically similar destinations - var candidates = await dataService.SearchLandmarksAsync(input.DestinationName, MaxRagCandidates); - - logger.LogDebug("[ResearcherExecutor] RAG returned {Count} candidates: {Names}", - candidates.Count, string.Join(", ", candidates.Select(c => c.Name))); - - if (candidates.Count == 0) - { - logger.LogDebug("[ResearcherExecutor] No candidates found"); - await context.AddEventAsync(new ExecutorStatusEvent("No matching destinations found")); - return new ResearchResult(null, input.DayCount, input.Language); - } + await context.AddEventAsync(new ExecutorStatusEvent("Searching destinations..."), cancellationToken); - // If only one candidate, use it directly without LLM call - if (candidates.Count == 1) - { - var singleMatch = candidates[0]; - logger.LogDebug("[ResearcherExecutor] Single candidate found: {Name}", singleMatch.Name); - await context.AddEventAsync(new ExecutorStatusEvent($"Found destination: {singleMatch.Name}")); - return new ResearchResult(singleMatch, input.DayCount, input.Language); - } - - await context.AddEventAsync(new ExecutorStatusEvent($"Evaluating {candidates.Count} candidates...")); - - // Step 2: Ask LLM to pick the best match from RAG candidates - var candidateDescriptions = string.Join("\n", candidates.Select(c => - $"- {c.Name}: {c.ShortDescription}")); - - var prompt = $""" - The user wants to visit: "{input.DestinationName}" - - Here are the available destinations that might match: - {candidateDescriptions} - - Which destination best matches what the user is looking for? - """; + // TextSearchProvider (configured via CreateAgent) automatically searches + // DataService.SearchLandmarksAsync and injects results as context before + // the AI call. We just need to ask the AI to pick the best match. + var prompt = input.DestinationName; logger.LogTrace("[ResearcherExecutor] Prompt: {Prompt}", prompt); - var runOptions = new ChatClientAgentRunOptions(new ChatOptions - { - ResponseFormat = ChatResponseFormat.ForJsonSchema(jsonOptions) - }); - - var response = await agent.RunAsync(prompt, options: runOptions, cancellationToken: cancellationToken); + var response = await agent.RunAsync(prompt, cancellationToken: cancellationToken); logger.LogTrace("[ResearcherExecutor] Raw response: {Response}", response.Text); - // Parse the AI's response to get the matched destination name - var matchResult = JsonSerializer.Deserialize(response.Text, jsonOptions); - var matchedName = matchResult?.MatchedDestinationName ?? input.DestinationName; - - logger.LogDebug("[ResearcherExecutor] AI selected '{MatchedName}' from candidates", matchedName); + // Parse the AI's response — both name and description come from RAG context + var matchResult = response.Result; - // Find the landmark from candidates (prefer exact match from candidates) - var landmark = candidates.FirstOrDefault(l => l.Name.Equals(matchedName, StringComparison.OrdinalIgnoreCase)) - ?? candidates[0]; // Fallback to top RAG result if LLM returned unexpected name + logger.LogDebug("[ResearcherExecutor] AI selected '{MatchedName}'", matchResult.MatchedDestinationName); - var result = new ResearchResult(landmark, input.DayCount, input.Language); + var result = new ResearchResult( + matchResult.MatchedDestinationName, + matchResult.MatchedDestinationDescription, + input.DayCount, + input.Language); - logger.LogDebug("[ResearcherExecutor] Completed - selected destination: {Name}", landmark.Name); + logger.LogDebug("[ResearcherExecutor] Completed - selected destination: {Name}", matchResult.MatchedDestinationName); logger.LogTrace("[ResearcherExecutor] Output: {@Result}", result); - await context.AddEventAsync(new ExecutorStatusEvent($"Found destination: {landmark.Name}")); + await context.AddEventAsync(new ExecutorStatusEvent($"Found destination: {matchResult.MatchedDestinationName}"), cancellationToken); return result; } diff --git a/src/AI/samples/Essentials.AI.Sample/AI/3_ItineraryPlannerExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/3_ItineraryPlannerExecutor.cs index 52c60c46c301..c70a0e32c0b5 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/3_ItineraryPlannerExecutor.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/3_ItineraryPlannerExecutor.cs @@ -1,7 +1,4 @@ -using System.ComponentModel; using System.Text; -using System.Text.Json; -using Maui.Controls.Sample.Models; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -11,71 +8,42 @@ namespace Maui.Controls.Sample.AI; /// /// Agent 3: Itinerary Planner - Builds the travel itinerary with streaming output. -/// Tools: findPointsOfInterest(destinationName, category, query) +/// Tools are used to assist in generating the itinerary. /// Uses RunStreamingAsync to emit partial JSON as it's generated. /// -internal sealed class ItineraryPlannerExecutor(AIAgent agent, JsonSerializerOptions jsonOptions, ILogger logger) - : Executor("ItineraryPlannerExecutor") +internal sealed partial class ItineraryPlannerExecutor(AIAgent agent, ILogger logger) + : Executor("ItineraryPlannerExecutor") { - private IWorkflowContext? _context; - - public const string Instructions = $""" - You create detailed travel itineraries. - - For each day include these places: - 1. An activity or attraction - 2. A hotel recommendation - 3. A restaurant recommendation - - Rules: - 1. ALWAYS use the `{FindPointsOfInterestToolName}` tool to discover real places near the destination. - 2. NEVER make up places or use your own knowledge. - 3. ONLY use places returned by the `{FindPointsOfInterestToolName}` tool. - 4. PREFER the places returned by the `{FindPointsOfInterestToolName}` tool instead of the destination description. - - Give the itinerary a fun, creative title and engaging description. - - Include a rationale explaining why you chose these activities for the traveler. - """; - - public const string FindPointsOfInterestToolName = "findPointsOfInterest"; - - public override async ValueTask HandleAsync( + [MessageHandler] + private async ValueTask HandleAsync( ResearchResult input, IWorkflowContext context, CancellationToken cancellationToken = default) { - _context = context; - - logger.LogDebug("[ItineraryPlannerExecutor] Starting - building {Days}-day itinerary for '{Landmark}'", - input.DayCount, input.Landmark?.Name ?? "unknown"); + logger.LogDebug("[ItineraryPlannerExecutor] Starting - building {Days}-day itinerary for '{Destination}'", + input.DayCount, input.DestinationName ?? "unknown"); logger.LogTrace("[ItineraryPlannerExecutor] Input: {@Input}", input); - await context.AddEventAsync(new ExecutorStatusEvent("Building your itinerary...")); + await context.AddEventAsync(new ExecutorStatusEvent("Building your itinerary..."), cancellationToken); - if (input.Landmark is null) + if (input.DestinationName is null) { - logger.LogDebug("[ItineraryPlannerExecutor] No landmark found - returning error"); - await context.AddEventAsync(new ExecutorStatusEvent("Error: No destination found")); - return new ItineraryResult(JsonSerializer.Serialize(new { error = "Landmark not found" }), input.Language); + logger.LogDebug("[ItineraryPlannerExecutor] No destination found - returning error"); + await context.AddEventAsync(new ExecutorStatusEvent("Error: No destination found"), cancellationToken); + return new ItineraryResult(System.Text.Json.JsonSerializer.Serialize(new { error = "Destination not found" }), input.Language); } var prompt = $""" - Generate a {input.DayCount}-day itinerary to {input.Landmark.Name}. - Destination description: {input.Landmark.Description} + Generate a {input.DayCount}-day itinerary to {input.DestinationName}. + Destination description: {input.DestinationDescription} """; logger.LogTrace("[ItineraryPlannerExecutor] Prompt: {Prompt}", prompt); - var runOptions = new ChatClientAgentRunOptions(new ChatOptions - { - Tools = [AIFunctionFactory.Create(FindPointsOfInterestAsync, name: FindPointsOfInterestToolName)], - ResponseFormat = ChatResponseFormat.ForJsonSchema(jsonOptions) - }); - // Use streaming to emit partial JSON as it's generated + // Tools and ResponseFormat are configured at agent level in ItineraryWorkflowExtensions var fullResponse = new StringBuilder(); - await foreach (var update in agent.RunStreamingAsync(prompt, options: runOptions, cancellationToken: cancellationToken)) + await foreach (var update in agent.RunStreamingAsync(prompt, cancellationToken: cancellationToken)) { foreach (var content in update.Contents) { @@ -92,53 +60,8 @@ Generate a {input.DayCount}-day itinerary to {input.Landmark.Name}. logger.LogTrace("[ItineraryPlannerExecutor] Raw response: {Response}", responseText); logger.LogDebug("[ItineraryPlannerExecutor] Completed - itinerary generated, language: {Language}", input.Language); - await context.AddEventAsync(new ExecutorStatusEvent($"Created {input.DayCount}-day itinerary for {input.Landmark.Name}")); + await context.AddEventAsync(new ExecutorStatusEvent($"Created {input.DayCount}-day itinerary for {input.DestinationName}"), cancellationToken); return new ItineraryResult(responseText, input.Language); } - - [Description("Finds points of interest (hotels, restaurants, activities) near a destination.")] - private async Task FindPointsOfInterestAsync( - [Description("The name of the destination to search near.")] - string destinationName, - [Description("The category of place to find (Hotel, Restaurant, Cafe, Museum, etc.).")] - PointOfInterestCategory category, - [Description("A natural language query to refine the search.")] - string additionalSearchQuery) - { - if (_context is not null) - { - await _context.AddEventAsync(new ExecutorStatusEvent($"Finding {category}s near {destinationName}...")); - } - - var suggestions = GetSuggestions(category); - var result = $""" - These {category} options are available near {destinationName}: - - - {string.Join(Environment.NewLine + "- ", suggestions)} - """; - - logger.LogTrace("[ItineraryPlannerExecutor] findPointsOfInterest tool called - destination={Destination}, category={Category}, query={Query}, result={Result}", - destinationName, category, additionalSearchQuery ?? "(none)", result); - - if (_context is not null) - { - await _context.AddEventAsync(new ExecutorStatusEvent($"Found {suggestions.Length} {category} options")); - } - - return result; - } - - private static string[] GetSuggestions(PointOfInterestCategory category) => - category switch - { - PointOfInterestCategory.Cafe => ["Cafe 1", "Cafe 2", "Cafe 3"], - PointOfInterestCategory.Campground => ["Campground 1", "Campground 2", "Campground 3"], - PointOfInterestCategory.Hotel => ["Hotel 1", "Hotel 2", "Hotel 3"], - PointOfInterestCategory.Marina => ["Marina 1", "Marina 2", "Marina 3"], - PointOfInterestCategory.Museum => ["Museum 1", "Museum 2", "Museum 3"], - PointOfInterestCategory.NationalMonument => ["The National Rock 1", "The National Rock 2", "The National Rock 3"], - PointOfInterestCategory.Restaurant => ["Restaurant 1", "Restaurant 2", "Restaurant 3"], - _ => [] - }; } diff --git a/src/AI/samples/Essentials.AI.Sample/AI/4_TranslatorExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/4_TranslatorExecutor.cs index 4e582c7b8a28..122494ece73e 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/4_TranslatorExecutor.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/4_TranslatorExecutor.cs @@ -1,6 +1,4 @@ using System.Text; -using System.Text.Json; -using Maui.Controls.Sample.Models; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -10,22 +8,14 @@ namespace Maui.Controls.Sample.AI; /// /// Agent 4: Translator - Translates the itinerary to target language (conditional) with streaming. -/// No tools - just translation. Uses RunStreamingAsync to emit partial translated JSON. +/// No tools - just translation. +/// Uses RunStreamingAsync to emit partial translated JSON. /// -internal sealed class TranslatorExecutor(AIAgent agent, JsonSerializerOptions jsonOptions, ILogger logger) - : Executor("TranslatorExecutor") +internal sealed partial class TranslatorExecutor(AIAgent agent, ILogger logger) + : Executor("TranslatorExecutor") { - public const string Instructions = """ - You are a professional translator. - Translate the provided JSON content to the target language. - - Rules: - 1. ALWAYS preserve the JSON format exactly. - 2. ONLY translate the text values within the JSON. - 3. NEVER add explanations or commentary. - """; - - public override async ValueTask HandleAsync( + [MessageHandler] + private async ValueTask HandleAsync( ItineraryResult input, IWorkflowContext context, CancellationToken cancellationToken = default) @@ -33,12 +23,7 @@ public override async ValueTask HandleAsync( logger.LogDebug("[TranslatorExecutor] Starting - translating to '{Language}'", input.TargetLanguage); logger.LogTrace("[TranslatorExecutor] Input JSON: {Json}", input.ItineraryJson); - await context.AddEventAsync(new ExecutorStatusEvent($"Translating to {input.TargetLanguage}...")); - - var runOptions = new ChatClientAgentRunOptions(new ChatOptions - { - ResponseFormat = ChatResponseFormat.ForJsonSchema(jsonOptions) - }); + await context.AddEventAsync(new ExecutorStatusEvent($"Translating to {input.TargetLanguage}..."), cancellationToken); var prompt = $""" Translate to {input.TargetLanguage}: @@ -49,8 +34,9 @@ public override async ValueTask HandleAsync( logger.LogTrace("[TranslatorExecutor] Prompt: {Prompt}", prompt); // Use streaming to emit partial JSON as it's generated + // ResponseFormat is set at agent creation time in ItineraryWorkflowExtensions var fullResponse = new StringBuilder(); - await foreach (var update in agent.RunStreamingAsync(prompt, options: runOptions, cancellationToken: cancellationToken)) + await foreach (var update in agent.RunStreamingAsync(prompt, cancellationToken: cancellationToken)) { foreach (var content in update.Contents) { @@ -67,7 +53,7 @@ public override async ValueTask HandleAsync( logger.LogTrace("[TranslatorExecutor] Raw response: {Response}", responseText); logger.LogDebug("[TranslatorExecutor] Completed - translation to '{Language}' finished", input.TargetLanguage); - await context.AddEventAsync(new ExecutorStatusEvent($"Translated to {input.TargetLanguage}")); + await context.AddEventAsync(new ExecutorStatusEvent($"Translated to {input.TargetLanguage}"), cancellationToken); return new ItineraryResult(responseText, input.TargetLanguage); } diff --git a/src/AI/samples/Essentials.AI.Sample/AI/5_OutputExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/5_OutputExecutor.cs index 8b046e0f2a56..85610945ad9e 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/5_OutputExecutor.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/5_OutputExecutor.cs @@ -7,10 +7,11 @@ namespace Maui.Controls.Sample.AI; /// Final executor that marks the workflow as complete. /// The itinerary JSON has already been streamed by ItineraryPlannerExecutor or TranslatorExecutor. /// -internal sealed class OutputExecutor(ILogger logger) - : Executor("OutputExecutor") +internal sealed partial class OutputExecutor(ILogger logger) + : Executor("OutputExecutor") { - public override async ValueTask HandleAsync( + [MessageHandler] + private async ValueTask HandleAsync( ItineraryResult input, IWorkflowContext context, CancellationToken cancellationToken = default) @@ -19,7 +20,7 @@ public override async ValueTask HandleAsync( logger.LogTrace("[OutputExecutor] Final JSON: {Json}", input.ItineraryJson); // Don't re-emit the JSON - it was already streamed by ItineraryPlannerExecutor or TranslatorExecutor - await context.AddEventAsync(new ExecutorStatusEvent("Your itinerary is ready!")); + await context.AddEventAsync(new ExecutorStatusEvent("Your itinerary is ready!"), cancellationToken); logger.LogDebug("[OutputExecutor] Completed - workflow finished"); } diff --git a/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowExtensions.cs b/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowExtensions.cs index a69c36f711b2..0efbe8a04ec2 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowExtensions.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowExtensions.cs @@ -1,9 +1,11 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Maui.Controls.Sample.Models; using Maui.Controls.Sample.Services; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -11,6 +13,8 @@ namespace Maui.Controls.Sample.AI; /// /// Extension methods to register the 4-agent itinerary workflow. +/// All agent configuration (instructions, tools, response formats, content providers) is +/// defined here. Executors contain only execution logic (streaming, status events, prompt assembly). /// public static class ItineraryWorkflowExtensions { @@ -30,29 +34,131 @@ public static class ItineraryWorkflowExtensions /// public static IHostApplicationBuilder AddItineraryWorkflow(this IHostApplicationBuilder builder) { + // Tool: findPointsOfInterest - used by Agent 3 + var findPoiTool = AIFunctionFactory.Create( + ItineraryWorkflowTools.FindPointsOfInterestAsync, + name: ItineraryWorkflowTools.FindPointsOfInterestToolName); + // Agent 1: Travel Planner - parses natural language, extracts intent builder.AddAIAgent( name: "travel-planner-agent", - instructions: TravelPlannerExecutor.Instructions, + instructions: """ + You are a simple text parser. + + Extract ONLY these 3 values from the user's request: + 1. destinationName: The place/location name mentioned (extract it exactly as written) + 2. dayCount: The number of days mentioned (default: 3 if not specified) + 3. language: The language mentioned for the output (default: English if not specified) + + Rules: + 1. ALWAYS extract the raw values. + 2. NEVER make up values or interpret the user's intent. + + Examples: + - "5-day trip to Maui in French" → destinationName: "Maui", dayCount: 5, language: "French" + - "Visit the Great Wall" → destinationName: "Great Wall", dayCount: 3, language: "English" + - "Itinerary for Tokyo" → destinationName: "Tokyo", dayCount: 3, language: "English" + - "Give me a Maui itinerary" → destinationName: "Maui", dayCount: 3, language: "English" + - "Plan a 7 day Japan trip in Spanish" → destinationName: "Japan", dayCount: 7, language: "Spanish" + """, chatClientServiceKey: "local-model"); - // Agent 2: Researcher - finds best matching destination - builder.AddAIAgent( - name: "researcher-agent", - instructions: ResearcherExecutor.Instructions, - chatClientServiceKey: "local-model"); + // Agent 2: Researcher - finds best matching destination using RAG via TextSearchProvider + builder.AddAIAgent("researcher-agent", (sp, name) => + { + var chatClient = sp.GetRequiredKeyedService("local-model"); + var dataService = sp.GetRequiredService(); + var loggerFactory = sp.GetRequiredService(); - // Agent 3: Itinerary Planner - builds detailed itineraries - builder.AddAIAgent( - name: "itinerary-planner-agent", - instructions: ItineraryPlannerExecutor.Instructions, - chatClientServiceKey: "local-model"); + var searchProvider = ItineraryWorkflowTools.CreateLandmarkSearchProvider(dataService, loggerFactory); - // Agent 4: Translator - translates content - builder.AddAIAgent( - name: "translator-agent", - instructions: TranslatorExecutor.Instructions, - chatClientServiceKey: "cloud-model"); + return chatClient.AsAIAgent( + new ChatClientAgentOptions + { + Name = name, + ChatOptions = new ChatOptions + { + Instructions = """ + You are a travel researcher. + Your job is to select the best matching destination from the additional context provided. + + Rules: + 1. You will be given additional context containing candidate destinations that match the user's request. + 2. Select the ONE destination that best matches what the user asked for. + 3. NEVER make up destinations - only choose from the provided candidates. + 4. If none of the candidates match well, pick the closest one. + 5. Include the destination's description from the context in your response. + + Return the exact name of the best matching destination from the candidates. + """ + }, + AIContextProviders = [searchProvider], + }, + loggerFactory); + }); + + // Agent 3: Itinerary Planner - builds detailed itineraries with tool calling + builder.AddAIAgent("itinerary-planner-agent", (sp, name) => + { + var chatClient = sp.GetRequiredKeyedService("local-model"); + var loggerFactory = sp.GetRequiredService(); + return chatClient.AsAIAgent( + new ChatClientAgentOptions + { + Name = name, + ChatOptions = new ChatOptions + { + Instructions = $""" + You create detailed travel itineraries. + + For each day include these places: + 1. An activity or attraction + 2. A hotel recommendation + 3. A restaurant recommendation + + Rules: + 1. ALWAYS use the `{ItineraryWorkflowTools.FindPointsOfInterestToolName}` tool to discover real places near the destination. + 2. NEVER make up places or use your own knowledge. + 3. ONLY use places returned by the `{ItineraryWorkflowTools.FindPointsOfInterestToolName}` tool. + 4. PREFER the places returned by the `{ItineraryWorkflowTools.FindPointsOfInterestToolName}` tool instead of the destination description. + + Give the itinerary a fun, creative title and engaging description. + + Include a rationale explaining why you chose these activities for the traveler. + """, + ResponseFormat = ChatResponseFormat.ForJsonSchema(JsonOptions), + Tools = [findPoiTool], + }, + }, + loggerFactory, + services: sp); + }); + + // Agent 4: Translator - translates content with streaming + builder.AddAIAgent("translator-agent", (sp, name) => + { + var chatClient = sp.GetRequiredKeyedService("cloud-model"); + var loggerFactory = sp.GetRequiredService(); + return chatClient.AsAIAgent( + new ChatClientAgentOptions + { + Name = name, + ChatOptions = new ChatOptions + { + Instructions = """ + You are a professional translator. + Translate the provided JSON content to the target language. + + Rules: + 1. ALWAYS preserve the JSON format exactly. + 2. ONLY translate the text values within the JSON. + 3. NEVER add explanations or commentary. + """, + ResponseFormat = ChatResponseFormat.ForJsonSchema(JsonOptions), + }, + }, + loggerFactory); + }); // Register the workflow var workflow = builder.AddWorkflow("itinerary-workflow", (sp, key) => @@ -61,14 +167,13 @@ public static IHostApplicationBuilder AddItineraryWorkflow(this IHostApplication var researcherAgent = sp.GetRequiredKeyedService("researcher-agent"); var itineraryPlannerAgent = sp.GetRequiredKeyedService("itinerary-planner-agent"); var translatorAgent = sp.GetRequiredKeyedService("translator-agent"); - var landmarkService = sp.GetRequiredService(); var logger = sp.GetRequiredService().CreateLogger("ItineraryWorkflow"); - // Create executors for each agent with logging - var travelPlannerExecutor = new TravelPlannerExecutor(travelPlannerAgent, JsonOptions, logger); - var researcherExecutor = new ResearcherExecutor(researcherAgent, landmarkService, JsonOptions, logger); - var itineraryPlannerExecutor = new ItineraryPlannerExecutor(itineraryPlannerAgent, JsonOptions, logger); - var translatorExecutor = new TranslatorExecutor(translatorAgent, JsonOptions, logger); + // Create executors — thin wrappers with just execution logic + var travelPlannerExecutor = new TravelPlannerExecutor(travelPlannerAgent, logger); + var researcherExecutor = new ResearcherExecutor(researcherAgent, logger); + var itineraryPlannerExecutor = new ItineraryPlannerExecutor(itineraryPlannerAgent, logger); + var translatorExecutor = new TranslatorExecutor(translatorAgent, logger); var outputExecutor = new OutputExecutor(logger); // Build the 4-agent workflow with conditional translation: @@ -77,10 +182,9 @@ public static IHostApplicationBuilder AddItineraryWorkflow(this IHostApplication .WithName(key) .AddEdge(travelPlannerExecutor, researcherExecutor) .AddEdge(researcherExecutor, itineraryPlannerExecutor) - // English path: skip translation - .AddEdge(itineraryPlannerExecutor, outputExecutor, condition: IsEnglish) - // Non-English path: translate first - .AddEdge(itineraryPlannerExecutor, translatorExecutor, condition: NeedsTranslation) + .AddSwitch(itineraryPlannerExecutor, switch_ => switch_ + .AddCase(r => r is not null && !string.Equals(r.TargetLanguage, "English", StringComparison.OrdinalIgnoreCase), translatorExecutor) + .WithDefault(outputExecutor)) .AddEdge(translatorExecutor, outputExecutor) .WithOutputFrom(outputExecutor) .Build(); @@ -93,10 +197,4 @@ public static IHostApplicationBuilder AddItineraryWorkflow(this IHostApplication return builder; } - - private static bool IsEnglish(ItineraryResult? result) => - result is not null && string.Equals(result.TargetLanguage, "English", StringComparison.OrdinalIgnoreCase); - - private static bool NeedsTranslation(ItineraryResult? result) => - result is not null && !string.Equals(result.TargetLanguage, "English", StringComparison.OrdinalIgnoreCase); } diff --git a/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowTools.cs b/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowTools.cs new file mode 100644 index 000000000000..c84c6b3e85e9 --- /dev/null +++ b/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowTools.cs @@ -0,0 +1,84 @@ +using System.ComponentModel; +using Maui.Controls.Sample.Models; +using Maui.Controls.Sample.Services; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Maui.Controls.Sample.AI; + +/// +/// Static tool functions and content providers used by the itinerary workflow. +/// Defined here so they can be registered centrally in . +/// +internal static class ItineraryWorkflowTools +{ + public const string FindPointsOfInterestToolName = "findPointsOfInterest"; + + /// + /// Creates a that performs RAG via . + /// The provider runs in BeforeAIInvoke mode, automatically searching for matching landmarks + /// and injecting them as context before each AI call. + /// + public static TextSearchProvider CreateLandmarkSearchProvider(DataService dataService, ILoggerFactory loggerFactory) + { + var ragLogger = loggerFactory.CreateLogger(); + + return new TextSearchProvider( + async (query, ct) => + { + ragLogger.LogDebug("[RAG] Searching landmarks for query: '{Query}'", query); + var results = await dataService.SearchLandmarksAsync(query, maxResults: 5); + ragLogger.LogDebug("[RAG] Found {Count} landmarks: {Names}", + results.Count, string.Join(", ", results.Select(r => r.Name))); + return results.Select(r => new TextSearchProvider.TextSearchResult + { + Text = $"{r.Name}: {r.ShortDescription}", + SourceName = r.Name, + }); + }, + new TextSearchProviderOptions + { + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, + }, + loggerFactory); + } + + [Description("Finds points of interest (hotels, restaurants, activities) near a destination.")] + public static Task FindPointsOfInterestAsync( + [Description("The name of the destination to search near.")] + string destinationName, + [Description("The category of place to find (Hotel, Restaurant, Cafe, Museum, etc.).")] + PointOfInterestCategory category, + [Description("A natural language query to refine the search.")] + string additionalSearchQuery, + IServiceProvider services) + { + var logger = services.GetService()?.CreateLogger("ItineraryWorkflowTools"); + + var suggestions = GetSuggestions(category); + var result = $""" + These {category} options are available near {destinationName}: + + - {string.Join(Environment.NewLine + "- ", suggestions)} + """; + + logger?.LogTrace("[ItineraryWorkflowTools] findPointsOfInterest - destination={Destination}, category={Category}, query={Query}, results={Count}", + destinationName, category, additionalSearchQuery ?? "(none)", suggestions.Length); + + return Task.FromResult(result); + } + + private static string[] GetSuggestions(PointOfInterestCategory category) => + category switch + { + PointOfInterestCategory.Cafe => ["Cafe 1", "Cafe 2", "Cafe 3"], + PointOfInterestCategory.Campground => ["Campground 1", "Campground 2", "Campground 3"], + PointOfInterestCategory.Hotel => ["Hotel 1", "Hotel 2", "Hotel 3"], + PointOfInterestCategory.Marina => ["Marina 1", "Marina 2", "Marina 3"], + PointOfInterestCategory.Museum => ["Museum 1", "Museum 2", "Museum 3"], + PointOfInterestCategory.NationalMonument => ["The National Rock 1", "The National Rock 2", "The National Rock 3"], + PointOfInterestCategory.Restaurant => ["Restaurant 1", "Restaurant 2", "Restaurant 3"], + _ => [] + }; +} diff --git a/src/AI/samples/Essentials.AI.Sample/AI/NonFunctionInvokingChatClient.cs b/src/AI/samples/Essentials.AI.Sample/AI/NonFunctionInvokingChatClient.cs deleted file mode 100644 index d2cd6b29d93e..000000000000 --- a/src/AI/samples/Essentials.AI.Sample/AI/NonFunctionInvokingChatClient.cs +++ /dev/null @@ -1,230 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Text.Json; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Maui.Controls.Sample.AI; - -/// -/// A chat client wrapper that prevents Agent Framework from adding its own function invocation layer. -/// -/// -/// -/// Some chat clients handle tool invocation internally - when tools are registered, the underlying -/// service invokes them automatically and returns the results. However, Agent Framework's -/// ChatClientAgent also tries to invoke tools when it sees -/// in the response, causing double invocation. -/// -/// -/// This wrapper solves the problem by: -/// -/// The inner handler converts and -/// to internal marker types that doesn't recognize -/// We wrap the handler with a real , satisfying -/// Agent Framework's GetService<FunctionInvokingChatClient>() check so it won't create another -/// The outer layer unwraps the marker types back to the original content types for the caller -/// -/// -/// -/// When the employed enables , the contents of -/// function calls and results are logged. These may contain sensitive application data. -/// is disabled by default and should never be enabled in a production environment. -/// -/// -/// Use this wrapper for any that handles its own tool invocation, such as -/// on-device models (Apple Intelligence, etc.) or remote services that invoke tools server-side. -/// -/// -public sealed partial class NonFunctionInvokingChatClient : DelegatingChatClient -{ - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The to wrap. - /// Optional logger factory for logging function invocations. - /// Optional service provider for dependency resolution. - public NonFunctionInvokingChatClient( - IChatClient innerClient, - ILoggerFactory? loggerFactory = null, - IServiceProvider? serviceProvider = null) - : base(CreateInnerClient(innerClient, loggerFactory, serviceProvider)) - { - _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; - } - - private static FunctionInvokingChatClient CreateInnerClient( - IChatClient innerClient, - ILoggerFactory? loggerFactory, - IServiceProvider? serviceProvider) - { - ArgumentNullException.ThrowIfNull(innerClient); - var handler = new ToolCallPassThroughHandler(innerClient); - return new FunctionInvokingChatClient(handler, loggerFactory, serviceProvider); - } - - /// - public override async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); - foreach (var message in response.Messages) - { - message.Contents.Unwrap(this); - } - return response; - } - - /// - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) - { - update.Contents.Unwrap(this); - yield return update; - } - } - - internal void LogFunctionInvoking(string functionName, string callId, IDictionary? arguments) - { - if (_logger.IsEnabled(LogLevel.Trace) && arguments is not null) - { - var argsJson = JsonSerializer.Serialize(arguments, AIJsonUtilities.DefaultOptions); - LogToolInvokedSensitive(functionName, callId, argsJson); - } - else if (_logger.IsEnabled(LogLevel.Debug)) - { - LogToolInvoked(functionName, callId); - } - } - - internal void LogFunctionInvocationCompleted(string callId, object? result) - { - if (_logger.IsEnabled(LogLevel.Trace) && result is not null) - { - var resultJson = result is string s ? s : JsonSerializer.Serialize(result, AIJsonUtilities.DefaultOptions); - LogToolInvocationCompletedSensitive(callId, resultJson); - } - else if (_logger.IsEnabled(LogLevel.Debug)) - { - LogToolInvocationCompleted(callId); - } - } - - [LoggerMessage(LogLevel.Debug, "Received tool call: {ToolName} (ID: {ToolCallId})")] - private partial void LogToolInvoked(string toolName, string toolCallId); - - [LoggerMessage(LogLevel.Trace, "Received tool call: {ToolName} (ID: {ToolCallId}) with arguments: {Arguments}")] - private partial void LogToolInvokedSensitive(string toolName, string toolCallId, string arguments); - - [LoggerMessage(LogLevel.Debug, "Received tool result for call ID: {ToolCallId}")] - private partial void LogToolInvocationCompleted(string toolCallId); - - [LoggerMessage(LogLevel.Trace, "Received tool result for call ID: {ToolCallId}: {Result}")] - private partial void LogToolInvocationCompletedSensitive(string toolCallId, string result); - - /// - /// Handler that wraps the inner client and converts tool call/result content to server-handled types. - /// - private sealed class ToolCallPassThroughHandler(IChatClient innerClient) : DelegatingChatClient(innerClient) - { - public override async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); - foreach (var message in response.Messages) - { - message.Contents.Wrap(); - } - return response; - } - - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) - { - update.Contents.Wrap(); - yield return update; - } - } - } -} - -file static class Extensions -{ - /// - /// Wraps any or in the contents list. - /// - /// The list of contents to wrap. - public static void Wrap(this IList contents) - { - for (var i = 0; i < contents.Count; i++) - { - if (contents[i] is FunctionCallContent fcc) - { - // The inner client already handled this tool call - wrap it so FICC ignores it - contents[i] = new ServerFunctionCallContent(fcc); - } - else if (contents[i] is FunctionResultContent frc) - { - // The inner client already produced this result - wrap it so FICC ignores it - contents[i] = new ServerFunctionResultContent(frc); - } - } - } - - /// - /// Unwraps any or in the contents list - /// and logs the function invocations. - /// - /// The list of contents to unwrap. - /// The client to use for logging. - public static void Unwrap(this IList contents, NonFunctionInvokingChatClient client) - { - for (var i = 0; i < contents.Count; i++) - { - if (contents[i] is ServerFunctionCallContent serverFcc) - { - var fcc = serverFcc.FunctionCallContent; - client.LogFunctionInvoking(fcc.Name, fcc.CallId, fcc.Arguments); - contents[i] = fcc; - } - else if (contents[i] is ServerFunctionResultContent serverFrc) - { - var frc = serverFrc.FunctionResultContent; - client.LogFunctionInvocationCompleted(frc.CallId, frc.Result); - contents[i] = frc; - } - } - } - - /// - /// Marker type for function calls that were already handled by the inner client. - /// only looks for , - /// so this type passes through without triggering function invocation. - /// - private sealed class ServerFunctionCallContent(FunctionCallContent functionCallContent) : AIContent - { - public FunctionCallContent FunctionCallContent { get; } = functionCallContent; - } - - /// - /// Marker type for function results that were already produced by the inner client. - /// - private sealed class ServerFunctionResultContent(FunctionResultContent functionResultContent) : AIContent - { - public FunctionResultContent FunctionResultContent { get; } = functionResultContent; - } -} diff --git a/src/AI/samples/Essentials.AI.Sample/AI/WorkflowModels.cs b/src/AI/samples/Essentials.AI.Sample/AI/WorkflowModels.cs index 7f9d982ff403..dea298dd0ee6 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/WorkflowModels.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/WorkflowModels.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using Maui.Controls.Sample.Models; namespace Maui.Controls.Sample.AI; @@ -19,18 +18,22 @@ public record TravelPlanResult( string Language); /// -/// Result from the Researcher Agent - the best matching destination name (for JSON schema). +/// Result from the Researcher Agent - the best matching destination (for JSON schema). /// internal record DestinationMatchResult( [property: DisplayName("matchedDestinationName")] [property: Description("The exact name of the best matching destination from the available list.")] - string MatchedDestinationName); + string MatchedDestinationName, + [property: DisplayName("matchedDestinationDescription")] + [property: Description("A brief description of the matched destination, based on the information provided in the additional context.")] + string MatchedDestinationDescription); /// -/// Result from the Researcher Agent - includes full landmark details. +/// Result from the Researcher Agent - includes destination name and description from RAG context. /// public record ResearchResult( - Landmark? Landmark, + string? DestinationName, + string? DestinationDescription, int DayCount, string Language); diff --git a/src/AI/samples/Essentials.AI.Sample/Essentials.AI.Sample.csproj b/src/AI/samples/Essentials.AI.Sample/Essentials.AI.Sample.csproj index 44e76794dac9..dd675e590bbf 100644 --- a/src/AI/samples/Essentials.AI.Sample/Essentials.AI.Sample.csproj +++ b/src/AI/samples/Essentials.AI.Sample/Essentials.AI.Sample.csproj @@ -32,14 +32,16 @@ - - + + + + diff --git a/src/AI/samples/Essentials.AI.Sample/MauiProgram.cs b/src/AI/samples/Essentials.AI.Sample/MauiProgram.cs index 2346fb53c0ab..2d95fd1146f5 100644 --- a/src/AI/samples/Essentials.AI.Sample/MauiProgram.cs +++ b/src/AI/samples/Essentials.AI.Sample/MauiProgram.cs @@ -52,6 +52,7 @@ public static MauiApp CreateMauiApp() // Register ViewModels builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddSingleton(); // Register Services builder.Services.AddSingleton(); @@ -59,6 +60,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddHttpClient(); + builder.Services.AddSingleton(); // Configure Logging builder.Services.AddLogging(); @@ -110,9 +112,6 @@ private static MauiAppBuilder AddAppleIntelligenceServices(this MauiAppBuilder b return appleClient .AsBuilder() .UseLogging(loggerFactory) - // This prevents double tool invocation when using Microsoft Agent Framework - // TODO: workaround for https://github.com/dotnet/extensions/issues/7204 - .Use(cc => new NonFunctionInvokingChatClient(cc, loggerFactory, sp)) .Build(); }); @@ -183,7 +182,6 @@ private static MauiAppBuilder AddOpenAIServices(this MauiAppBuilder builder) }); // Add chat client for local model with function calling - // TODO: Replace with actual local model client when available builder.Services.AddKeyedSingleton("local-model", (provider, _) => { var lf = provider.GetRequiredService(); diff --git a/src/AI/samples/Essentials.AI.Sample/Models/Landmark.cs b/src/AI/samples/Essentials.AI.Sample/Models/Landmark.cs index 94b5457614c1..1edf16df6fa6 100644 --- a/src/AI/samples/Essentials.AI.Sample/Models/Landmark.cs +++ b/src/AI/samples/Essentials.AI.Sample/Models/Landmark.cs @@ -33,8 +33,9 @@ public record Landmark public Location Location => new(Latitude, Longitude); /// - /// Embedding vector generated from Name and ShortDescription for RAG search. + /// Embedding vectors generated from the name, short description, and individual + /// sentences of the full description for multi-granularity semantic search. /// [JsonIgnore] - public Embedding? Embedding { get; set; } + public IReadOnlyList>? Embeddings { get; set; } } diff --git a/src/AI/samples/Essentials.AI.Sample/Models/PointOfInterest.cs b/src/AI/samples/Essentials.AI.Sample/Models/PointOfInterest.cs index 1f17752b7425..7d95e45d47d9 100644 --- a/src/AI/samples/Essentials.AI.Sample/Models/PointOfInterest.cs +++ b/src/AI/samples/Essentials.AI.Sample/Models/PointOfInterest.cs @@ -11,8 +11,11 @@ public class PointOfInterest public string Description { get; set; } = string.Empty; + /// + /// Embedding vectors generated from the name and description for semantic search. + /// [JsonIgnore] - public Embedding? Embedding { get; set; } + public IReadOnlyList>? Embeddings { get; set; } } public enum PointOfInterestCategory diff --git a/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml b/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml index 8dabc74f7b16..d8e36611f2b9 100644 --- a/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml +++ b/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml @@ -69,6 +69,53 @@ FontSize="24" Background="{AppThemeBinding Light=#80FFFFFF, Dark=#80000000}" TextColor="{AppThemeBinding Light={StaticResource Gray900}, Dark=White}" /> + + +