diff --git a/.azure/modules/fw-azurefirewall.bicep b/.azure/modules/afw-azurefirewall.bicep similarity index 100% rename from .azure/modules/fw-azurefirewall.bicep rename to .azure/modules/afw-azurefirewall.bicep diff --git a/.azure/modules/cog-cognitiveservices.bicep b/.azure/modules/ais-cognitiveservices.bicep similarity index 100% rename from .azure/modules/cog-cognitiveservices.bicep rename to .azure/modules/ais-cognitiveservices.bicep diff --git a/.azure/modules/appi-applicationinsights.bicep b/.azure/modules/appi-applicationinsights.bicep index c3e884b..08adfa1 100644 --- a/.azure/modules/appi-applicationinsights.bicep +++ b/.azure/modules/appi-applicationinsights.bicep @@ -20,7 +20,7 @@ param name string @description('The resource ID of the Log Analytics workspace to link to Application Insights. Must be a valid resourceId string.') @minLength(1) -param workResourceId string +param logResourceId string @description('Controls whether Application Insights accepts public telemetry ingestion.') @allowed([ @@ -44,7 +44,7 @@ resource appiResource 'Microsoft.Insights/components@2020-02-02' = { properties: { Application_Type: 'web' Flow_Type: 'Bluefield' - WorkspaceResourceId: workResourceId + WorkspaceResourceId: logResourceId publicNetworkAccessForIngestion: publicNetworkAccessForIngestion publicNetworkAccessForQuery: publicNetworkAccessForQuery } diff --git a/.azure/modules/plan-appserviceplan.bicep b/.azure/modules/asp-appserviceplan.bicep similarity index 100% rename from .azure/modules/plan-appserviceplan.bicep rename to .azure/modules/asp-appserviceplan.bicep diff --git a/.azure/modules/aca-ollama.bicep b/.azure/modules/ca-ollama.bicep similarity index 99% rename from .azure/modules/aca-ollama.bicep rename to .azure/modules/ca-ollama.bicep index 3cbeaf3..f3295e4 100644 --- a/.azure/modules/aca-ollama.bicep +++ b/.azure/modules/ca-ollama.bicep @@ -22,7 +22,7 @@ param stAccountName string @minLength(3) @maxLength(63) @description('Name of the Azure Files share mounted into Ollama for persistent model data.') -param storageShareName string = 'ollama-models' +param storageShareName string = 'share-ollamamodels' @description('Ollama model to pull and serve on startup.') @allowed([ diff --git a/.azure/modules/cdn-contentdeliverynetwork.bicep b/.azure/modules/cdnp-contentdeliverynetwork.bicep similarity index 100% rename from .azure/modules/cdn-contentdeliverynetwork.bicep rename to .azure/modules/cdnp-contentdeliverynetwork.bicep diff --git a/.azure/modules/fabric-capacity.bicep b/.azure/modules/fc-fabriccapacity.bicep similarity index 100% rename from .azure/modules/fabric-capacity.bicep rename to .azure/modules/fc-fabriccapacity.bicep diff --git a/.azure/modules/afdwaf-frontdoorwafpolicy.bicep b/.azure/modules/fdfp-frontdoorwafpolicy.bicep similarity index 100% rename from .azure/modules/afdwaf-frontdoorwafpolicy.bicep rename to .azure/modules/fdfp-frontdoorwafpolicy.bicep diff --git a/.azure/modules/cogtext-textanalytics.bicep b/.azure/modules/lang-textanalytics.bicep similarity index 100% rename from .azure/modules/cogtext-textanalytics.bicep rename to .azure/modules/lang-textanalytics.bicep diff --git a/.azure/modules/law-loganalyticsworkspace.bicep b/.azure/modules/log-loganalyticsworkspace.bicep similarity index 88% rename from .azure/modules/law-loganalyticsworkspace.bicep rename to .azure/modules/log-loganalyticsworkspace.bicep index 1416e38..6736276 100644 --- a/.azure/modules/law-loganalyticsworkspace.bicep +++ b/.azure/modules/log-loganalyticsworkspace.bicep @@ -22,7 +22,7 @@ param tags object = {} @minValue(30) param retentionInDays int = 30 -resource workResource 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { +resource logResource 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { name: name location: location tags: empty(tags) ? null : tags @@ -34,4 +34,4 @@ resource workResource 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { } } -output id string = workResource.id +output id string = logResource.id diff --git a/.azure/modules/sent-loganalyticsworkspace.bicep b/.azure/modules/log-sentinelworkspace.bicep similarity index 81% rename from .azure/modules/sent-loganalyticsworkspace.bicep rename to .azure/modules/log-sentinelworkspace.bicep index 8ef1d6b..5366f85 100644 --- a/.azure/modules/sent-loganalyticsworkspace.bicep +++ b/.azure/modules/log-sentinelworkspace.bicep @@ -22,9 +22,9 @@ param tags object = {} @minValue(30) param retentionInDays int = 30 -// Sentinel = the plain workspace (work-) plus onboarding -module workspace 'law-loganalyticsworkspace.bicep' = { - name: 'sent-${name}' +// Sentinel = the plain Log Analytics workspace (log-loganalyticsworkspace) plus onboarding +module workspace 'log-loganalyticsworkspace.bicep' = { + name: 'log-sentinel-${name}' params: { name: name location: location @@ -34,7 +34,7 @@ module workspace 'law-loganalyticsworkspace.bicep' = { } } -resource workResource 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = { +resource logResource 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = { name: name dependsOn: [ workspace @@ -43,7 +43,7 @@ resource workResource 'Microsoft.OperationalInsights/workspaces@2023-09-01' exis resource onboarding 'Microsoft.SecurityInsights/onboardingStates@2021-03-01-preview' = { name: 'default' - scope: workResource + scope: logResource properties: {} } diff --git a/.azure/modules/nsgrule-networksecuritygrouprule.bicep b/.azure/modules/nsgsr-networksecuritygrouprule.bicep similarity index 100% rename from .azure/modules/nsgrule-networksecuritygrouprule.bicep rename to .azure/modules/nsgsr-networksecuritygrouprule.bicep diff --git a/.azure/modules/aoai-azureopenai.bicep b/.azure/modules/oai-azureopenai.bicep similarity index 100% rename from .azure/modules/aoai-azureopenai.bicep rename to .azure/modules/oai-azureopenai.bicep diff --git a/.azure/modules/vnetpeer-virtualnetworkpeering.bicep b/.azure/modules/peer-virtualnetworkpeering.bicep similarity index 100% rename from .azure/modules/vnetpeer-virtualnetworkpeering.bicep rename to .azure/modules/peer-virtualnetworkpeering.bicep diff --git a/.azure/modules/pe-privateendpoint.bicep b/.azure/modules/pep-privateendpoint.bicep similarity index 100% rename from .azure/modules/pe-privateendpoint.bicep rename to .azure/modules/pep-privateendpoint.bicep diff --git a/.azure/modules/sb-servicebus.bicep b/.azure/modules/sbns-servicebus.bicep similarity index 100% rename from .azure/modules/sb-servicebus.bicep rename to .azure/modules/sbns-servicebus.bicep diff --git a/.azure/modules/agwwaf-applicationgatewaywafpolicy.bicep b/.azure/modules/waf-applicationgatewaywafpolicy.bicep similarity index 100% rename from .azure/modules/agwwaf-applicationgatewaywafpolicy.bicep rename to .azure/modules/waf-applicationgatewaywafpolicy.bicep diff --git a/.azure/templates/workload-digitalproduct.bicep b/.azure/templates/workload-digitalproduct.bicep index 26e7231..136b677 100644 --- a/.azure/templates/workload-digitalproduct.bicep +++ b/.azure/templates/workload-digitalproduct.bicep @@ -59,10 +59,10 @@ var sharedSubscriptionId = shared.?subscriptionId ?? subscription().subscription var sharedResourceGroupName = shared.?resourceGroupName ?? resourceGroup().name var vnetSubscriptionId = vnet.?subscriptionId ?? subscription().subscriptionId -module lawModule '../modules/law-loganalyticsworkspace.bicep' = if (isStandalone) { - name: 'digitalproduct-law' +module logModule '../modules/log-loganalyticsworkspace.bicep' = if (isStandalone) { + name: 'digitalproduct-log' params: { - name: hosting.?workspaceName ?? '${hosting.appiName}-work' + name: hosting.workspaceName! location: location tags: tags } @@ -74,12 +74,12 @@ module appiModule '../modules/appi-applicationinsights.bicep' = if (isStandalone name: hosting.appiName location: location tags: tags - workResourceId: lawModule!.outputs.id + logResourceId: logModule!.outputs.id } } -module planModule '../modules/plan-appserviceplan.bicep' = if (isStandalone) { - name: 'digitalproduct-plan' +module planModule '../modules/asp-appserviceplan.bicep' = if (isStandalone) { + name: 'digitalproduct-asp' params: { name: hosting.planName location: location diff --git a/.azure/types/digitalproduct-types.bicep b/.azure/types/digitalproduct-types.bicep index 235e591..82503f4 100644 --- a/.azure/types/digitalproduct-types.bicep +++ b/.azure/types/digitalproduct-types.bicep @@ -20,7 +20,7 @@ type hostingConfig = { @minLength(4) @maxLength(63) - @description('Standalone only. Log Analytics workspace name backing Application Insights.') + @description('Required in standalone mode. Log Analytics workspace name backing Application Insights.') workspaceName: string? } diff --git a/.azure/variables/workload-digitalproduct-standalone-dev.bicepparam b/.azure/variables/workload-digitalproduct-standalone-dev.bicepparam index 2694b31..70f7962 100644 --- a/.azure/variables/workload-digitalproduct-standalone-dev.bicepparam +++ b/.azure/variables/workload-digitalproduct-standalone-dev.bicepparam @@ -2,12 +2,12 @@ using '../templates/workload-digitalproduct.bicep' // Phase 1 (mode 1, standalone): plan, Log Analytics, App Insights, web, API and SQL all live in this RG. // RG: {product}-{env}-{region}-{instance}-rg. Secrets: set SQL_ADMIN_USER and SQL_ADMIN_PASSWORD in the environment before running az deployment group. -// Deploy: az deployment group create -g agent-dev-wus2-001-rg -f .azure/templates/workload-digitalproduct.bicep -p .azure/variables/workload-digitalproduct-standalone-dev.bicepparam +// Deploy: az deployment group create -g agent-dev-wus2-100-rg -f .azure/templates/workload-digitalproduct.bicep -p .azure/variables/workload-digitalproduct-standalone-dev.bicepparam var productIac = 'agent' var environmentIac = 'dev' var regionIac = 'wus2' -var instanceIac = '001' +var instanceIac = '100' var prefix = '${productIac}-${environmentIac}-${regionIac}-${instanceIac}' param mode = 'standalone' @@ -17,9 +17,9 @@ param tags = { Environment: environmentIac, CostCenter: '0000' } param hosting = { appiName: '${prefix}-appi' - planName: '${prefix}-F1-plan' + planName: '${prefix}-F1-asp' planSku: 'F1' - workspaceName: '${prefix}-work' + workspaceName: '${prefix}-log' } // Components: delete a line to skip that component. diff --git a/.github/workflows/agent-app.yml b/.github/workflows/agent-app.yml index 6965256..adc2626 100644 --- a/.github/workflows/agent-app.yml +++ b/.github/workflows/agent-app.yml @@ -42,7 +42,7 @@ env: # ===== EDIT PER INSTALL: naming slots (names: {product}-{env}-{region}-{instance}-{type}) ===== PRODUCT: "agent" REGION: "wus2" - INSTANCE: "001" + INSTANCE: "100" # Same value as MODE in the IaC workflow. standalone: App Insights is in the product RG; any other mode: in the shared management RG. MODE: "standalone" # Shared management RG and App Insights from platform-shared-mgmt; product is normally 'mgmt'. diff --git a/.github/workflows/agent-iac.yml b/.github/workflows/agent-iac.yml index f727f29..7ba8fef 100644 --- a/.github/workflows/agent-iac.yml +++ b/.github/workflows/agent-iac.yml @@ -39,7 +39,7 @@ env: # ===== EDIT PER INSTALL: naming slots (RG: {product}-{env}-{region}-{instance}-rg) ===== PRODUCT: "agent" REGION: "wus2" - INSTANCE: "001" + INSTANCE: "100" LOCATION: "westus2" # standalone | shared | network | zerotrust MODE: "standalone" diff --git a/Initialize-Local.ps1 b/Initialize-Local.ps1 index c4bd44d..b2aa5c3 100644 --- a/Initialize-Local.ps1 +++ b/Initialize-Local.ps1 @@ -9,7 +9,7 @@ Run from any folder in PowerShell (not 'pwsh -File', so a splatted hashtable wor 3. Database: applies the EF migrations to the local SQL Server (LocalDB by default). Local only: it does not touch GitHub, Azure Pipelines or Azure resources. CI/CD secrets and federation are set up separately. Safe to re-run: existing registrations are reused, and the Web client secret is only regenerated with -RotateSecrets. -Leave -ApplicationInsightsConnectionString empty until the infrastructure is deployed; the apps start without it, with no Azure telemetry. Deployment names are only stored when you pass them, because the appsettings files already carry the template defaults (chat-fast, chat-balanced, embedding-fast). +Leave -ApplicationInsightsConnectionString empty until the infrastructure is deployed; the apps start without it, with no Azure telemetry. Deployment names are only stored when you pass them, because the appsettings files already carry the template defaults (chat-fast, tool-standard, tool-reasoning, embedding-standard). .EXAMPLE # Omit -AzureOpenAIApiKey to be prompted without echo: @@ -31,6 +31,8 @@ param( [Parameter(Mandatory)][string]$AzureOpenAIEndpoint, [string]$AzureOpenAIApiKey, [string]$ChatDeploymentName, + [string]$ToolDeploymentName, + [string]$ToolReasoningDeploymentName, [string]$EmbeddingDeploymentName, [string]$ApplicationInsightsConnectionString = "", @@ -108,6 +110,8 @@ $openAi = @{ "AzureOpenAI:ApiKey" = $AzureOpenAIApiKey } if ($ChatDeploymentName) { $openAi["AzureOpenAI:ChatDeploymentName"] = $ChatDeploymentName } +if ($ToolDeploymentName) { $openAi["AzureOpenAI:ToolDeploymentName"] = $ToolDeploymentName } +if ($ToolReasoningDeploymentName) { $openAi["AzureOpenAI:ToolReasoningDeploymentName"] = $ToolReasoningDeploymentName } if ($EmbeddingDeploymentName) { $openAi["AzureOpenAI:EmbeddingDeploymentName"] = $EmbeddingDeploymentName } $appInsights = @{ "ApplicationInsights:ConnectionString" = $ApplicationInsightsConnectionString } diff --git a/README.md b/README.md index 0754c70..19ca366 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ dotnet user-secrets set "ApplicationInsights:ConnectionString" "" --project dotnet user-secrets set "AzureOpenAI:Endpoint" "https://.openai.azure.com/openai/v1" --project .\src\Tests.Integration\Tests.Integration.csproj dotnet user-secrets set "AzureOpenAI:ApiKey" "" --project .\src\Tests.Integration\Tests.Integration.csproj ``` -The deployment names come from `AzureOpenAI:ChatDeploymentName` and `AzureOpenAI:EmbeddingDeploymentName` in `src/Presentation.Api/appsettings.*.json` (`chat-fast`, `chat-balanced`, `embedding-fast`). Replace them with your own deployment names, or override them with `dotnet user-secrets set` using the same keys. +Deployment names come from `AzureOpenAI:ChatDeploymentName`, `AzureOpenAI:ToolDeploymentName`, `AzureOpenAI:ToolReasoningDeploymentName`, and `AzureOpenAI:EmbeddingDeploymentName` in `src/Presentation.Api/appsettings.*.json`. Override these same keys with `dotnet user-secrets set` when using different purpose-compatible deployments. Alternately you can set in Environment variables ``` @@ -441,7 +441,7 @@ doc's **UI Changes** and **API Changes** sections for details. # Github Actions for Azure IaC and CI/CD ## GitHub Actions (.github folder) -The `.github/workflows` folder contains the GitHub Actions pipelines for CI/CD. Workflows follow the [github-actions](https://github.com/goodtocode/github-actions) conventions and are named `{product}-{capability}.yml`. The naming slots (`PRODUCT`, `REGION`, `INSTANCE`, `MODE`) at the top of each workflow produce the Azure names `{product}-{env}-{region}-{instance}-{type}`, for example `agent-dev-wus2-001-rg`. Below is a summary of the workflow files, their purposes, and triggers: +The `.github/workflows` folder contains the GitHub Actions pipelines for CI/CD. Workflows follow the [github-actions](https://github.com/goodtocode/github-actions) conventions and are named `{product}-{capability}.yml`. The naming slots (`PRODUCT`, `REGION`, `INSTANCE`, `MODE`) at the top of each workflow produce the Azure names `{product}-{env}-{region}-{instance}-{type}`, for example `agent-dev-wus2-100-rg`. Below is a summary of the workflow files, their purposes, and triggers: ### Triggers All workflow YAML files in this repo are designed to: @@ -567,4 +567,11 @@ This setup ensures your GitHub Actions workflows can securely deploy to Azure us | 1.0.0 | 2026-Feb-02 | Initial Release | | 1.1.0 | 2026-Jul-27 | AI-ize md/yml, AgentProvider | -This project is licensed with the [MIT license](https://mit-license.org/). \ No newline at end of file +This project is licensed with the [MIT license](https://mit-license.org/). + +### Shared Foundry deployment configuration + +Use `AzureOpenAI:ChatDeploymentName=chat-fast` for text-only Phi chat, `AzureOpenAI:ToolDeploymentName=tool-standard` for Azure-direct Mistral-Large-3 function calls, and `AzureOpenAI:ToolReasoningDeploymentName=tool-reasoning` for MAI playbook inference. Azure-direct Cohere embed-v-4-0 intent embeddings use `AzureOpenAI:EmbeddingDeploymentName=embedding-standard` and `EmbeddingDimension=1536`. +These names match the `platform-azure` shared Foundry definitions; provision them and verify regional model access/quota before running live AI scenarios. MAI-Thinking-1 remains preview. Existing embedding vectors must be regenerated after switching models. +All three purpose deployments are required for the selected provider. Old user-secret/environment overrides may need migration; a chat-only override does not select a tool deployment. +See [semantic intent classification](docs/product/features/feature-semantic-intent-classification.md#purpose-specific-foundry-inference) for routing, governance, and migration details. \ No newline at end of file diff --git a/docs/product/features/feature-semantic-intent-classification.md b/docs/product/features/feature-semantic-intent-classification.md index 935e51b..2e4cf49 100644 --- a/docs/product/features/feature-semantic-intent-classification.md +++ b/docs/product/features/feature-semantic-intent-classification.md @@ -97,7 +97,7 @@ The API configuration exposes the following section in every environment file: The section is bound through `IOptions`. `EnableSemantic` must remain `false` until evaluation and rollout approval are complete. -Azure embedding settings are bound through `AzureOpenAIOptions`, including `EmbeddingDeploymentName` with the default deployment name `embedding-fast`. +Azure embedding settings are bound through `AzureOpenAIOptions`, including `EmbeddingDeploymentName` with the default deployment name `embedding-standard`. ## Shared Implementation @@ -190,7 +190,7 @@ Both repositories require: - Reqnroll scenarios covering the disabled switch and an enabled semantic match using deterministic embeddings and in-memory storage. - API configuration parsing tests or equivalent validation confirming the switch is present and disabled by default. -Live Azure-backed seeding is a separate environment validation. It requires a configured `embedding-fast` deployment, SQL database, credentials, and an applied migration. +Live Azure-backed seeding is a separate environment validation. It requires a configured `embedding-standard` deployment, SQL database, credentials, and an applied migration. ## Rollout @@ -221,3 +221,34 @@ Live Azure-backed seeding is a separate environment validation. It requires a co - [ ] Production enablement is approved and reversible. - [ ] Governance documentation contains durable principles only. - [ ] Tactical implementation details remain in this product feature document. + + +## Purpose-specific Foundry inference + +These applications consume the shared `platform-azure` Foundry deployments: + +| Purpose | Configuration | Deployment | Model | +| --- | --- | --- | --- | +| Text-only chat | `AzureOpenAI:ChatDeploymentName` | `chat-fast` | Phi-4-mini-instruct | +| Interactive functions | `AzureOpenAI:ToolDeploymentName` | `tool-standard` | Mistral-Large-3 (Azure-direct) | +| Playbook reasoning | `AzureOpenAI:ToolReasoningDeploymentName` | `tool-reasoning` | MAI-Thinking-1 | +| Intent embeddings | `AzureOpenAI:EmbeddingDeploymentName` | `embedding-standard` | Cohere embed-v-4-0 (Azure-direct, 1536 dimensions) | + +`embedding-standard` is shared by intent classification and retrieval consumers, using Cohere embed-v-4-0 at 1536 dimensions. +Callable function names must fit the OpenAI-compatible API's 64-character limit. Names within the limit +remain unchanged; longer tool-qualified names use a readable 47-character prefix and a deterministic +16-hex-character SHA-256 suffix to avoid truncation collisions and provider HTTP 422 errors. +MAI-Thinking-1 remains a preview model despite the deployment name having no preview suffix. +Verify model availability, access, and quota before provisioning the shared deployments; the names alone do not establish capabilities. + +The keyed `InferencePurpose.Chat` agent advertises no application functions. Direct mode and reply-wording correction use only this agent. Correction receives the original reply, retains the governed policy context, and cannot repeat application operations. +The default agent is `InferencePurpose.Tool`: described public tool methods become real `AIFunction` instances with typed schemas and qualified names. +After classification misses, general requests use one automatic-function turn. Recognized chat-history requests require a function call; missing calls, provider failures, and cancellation propagate without a second open-agent attempt. +Agentic Essay and Taxonomy stages explicitly consume `InferencePurpose.ToolReasoning`; these stages do not expose chat administration functions. +Existing durable chat governance and hosted catalog/embedding initialization remain enabled. + +AzureAIFoundry uses the corresponding `ChatCompletionModelId`, `ToolCompletionModelId`, and `ToolReasoningModelId` deployment settings. OpenAI, GitHubCopilotSDK, and Ollama also require explicit purpose models; unused providers remain unvalidated. Ollama defaults retain text-only phi4 and use OpenAI gpt-oss:20b for function/reasoning purposes, requiring a local model pull before use. + +Embedding calls use the official OpenAI SDK and the normalized `/openai/v1/embeddings` endpoint, passing the deployment name and configured dimension. Root endpoints and existing `/openai/v1/` endpoints are both supported. +Before enabling semantic classification, regenerate persisted intent embeddings when changing the embedding model, even if the dimension is unchanged. Reconcile SQL vector storage when changing dimensions; do not compare embeddings from different model spaces. +No timeout increases, automatic model fallback, or Azure deployment are part of this change. \ No newline at end of file diff --git a/src/Core.Application/Abstractions/ChatRoutingMode.cs b/src/Core.Application/Abstractions/ChatRoutingMode.cs index 03db1fc..e390f6b 100644 --- a/src/Core.Application/Abstractions/ChatRoutingMode.cs +++ b/src/Core.Application/Abstractions/ChatRoutingMode.cs @@ -5,9 +5,9 @@ namespace Goodtocode.AgentFramework.Core.Application.Abstractions; /// public enum ChatRoutingMode { - /// Uses deterministic routing, forced-tool inference, then an open agent turn. + /// Uses deterministic routing, then purpose-specific function inference without open-agent retries. Routed = 0, - /// Bypasses deterministic and forced-tool routing for diagnostics or explicit direct-agent requests. + /// Uses text-only inference without classification or application functions. Direct = 1 } \ No newline at end of file diff --git a/src/Core.Application/Common/Behaviors/CustomUnhandledExceptionBehavior.cs b/src/Core.Application/Common/Behaviors/CustomUnhandledExceptionBehavior.cs index 37611dc..63cabb2 100644 --- a/src/Core.Application/Common/Behaviors/CustomUnhandledExceptionBehavior.cs +++ b/src/Core.Application/Common/Behaviors/CustomUnhandledExceptionBehavior.cs @@ -18,16 +18,15 @@ public async Task Handle(TRequest request, RequestDelegateInvoker logger.LogError(ex, + logger.LogError(ex, "Request: ClientResultException for {Name}. Details: {Details}", requestName, - clientResultDetails), cancellationToken); + clientResultDetails); throw; } - catch (Exception ex) + catch (Exception ex) when (ex is not (CustomNotFoundException or CustomValidationException or CustomConflictException or CustomForbiddenAccessException)) { - var requestName = typeof(TRequest).Name; - await Task.Run(() => logger.LogUnhandledException(ex, requestName), cancellationToken); + logger.LogUnhandledException(ex, typeof(TRequest).Name); throw; } } @@ -47,16 +46,15 @@ public async Task Handle(TRequest request, RequestDelegateInvoker nextInvoker, C { var requestName = typeof(TRequest).Name; var clientResultDetails = ClientResultExceptionLogHelper.FormatProperties(ex); - await Task.Run(() => logger.LogError(ex, + logger.LogError(ex, "Request: ClientResultException for {Name}. Details: {Details}", requestName, - clientResultDetails), cancellationToken); + clientResultDetails); throw; } - catch (Exception ex) + catch (Exception ex) when (ex is not (CustomNotFoundException or CustomValidationException or CustomConflictException or CustomForbiddenAccessException)) { - var requestName = typeof(TRequest).Name; - await Task.Run(() => logger.LogUnhandledException(ex, requestName), cancellationToken); + logger.LogUnhandledException(ex, typeof(TRequest).Name); throw; } } diff --git a/src/Infrastructure.AgentFramework/ChatMessageIntentRouter.cs b/src/Infrastructure.AgentFramework/ChatMessageIntentRouter.cs index 46c62f3..86b483d 100644 --- a/src/Infrastructure.AgentFramework/ChatMessageIntentRouter.cs +++ b/src/Infrastructure.AgentFramework/ChatMessageIntentRouter.cs @@ -11,17 +11,19 @@ using Goodtocode.Mediator; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework; /// -/// Resolves chat replies through deterministic intent routing, forced-tool inference, and finally -/// the normal open agent turn. All AI and chat-presentation behavior stays here so application +/// Resolves chat replies through deterministic intent routing and purpose-specific inference. +/// All AI and chat-presentation behavior stays here so application /// handlers depend only on . /// public sealed class ChatMessageIntentRouter( AIAgent agent, + [FromKeyedServices(InferencePurpose.Chat)] AIAgent chatAgent, ISender sender, IAgentFrameworkContext context, ChatGovernanceGate governanceGate, @@ -38,6 +40,12 @@ public sealed class ChatMessageIntentRouter( private readonly ChatGovernanceScope _governanceScope = governanceScope; private readonly IAgentInstructionsComposer _instructionsComposer = instructionsComposer; private readonly AIAgent _agent = agent; + private readonly AIAgent _chatAgent = chatAgent; + private const string TextOnlyInstructions = + "You are a text-only assistant. You have no application tools or access to live product data. " + + "Answer from the conversation and general knowledge; do not claim to retrieve, change, or search application data."; + private static readonly string[] ChatHistoryIntentPhrases = + ["chat sessions", "chat session history", "chat history", "chat messages", "my conversations", "previous conversations"]; private readonly ISender _sender = sender; private readonly IAgentFrameworkContext _context = context; private readonly ChatGovernanceGate _governanceGate = governanceGate; @@ -46,10 +54,6 @@ public sealed class ChatMessageIntentRouter( private readonly IIntentClassifier _intentClassifier = intentClassifier; private readonly Tools.IAgentChatContextAccessor _chatContextAccessor = chatContextAccessor; private readonly ILogger _logger = logger; - private static readonly Action LogForcedToolInferenceFailure = LoggerMessage.Define( - LogLevel.Warning, - new EventId(1, nameof(LogForcedToolInferenceFailure)), - "Level 3 forced-tool inference failed; falling back to the Level 3 open agent turn."); /// public async Task ResolveReplyAsync( @@ -78,25 +82,36 @@ public async Task ResolveReplyAsync( { var governed = await GovernTurnAsync(chatSessionId, message, cancellationToken); var instruction = AgentInstruction.Create( - $"{governed.PromptContext.SystemInstruction}\n\n{_instructionsComposer.Compose()}", + $"{governed.PromptContext.SystemInstruction}\n\n{(mode == ChatRoutingMode.Direct ? TextOnlyInstructions : _instructionsComposer.Compose())}", InstructionScope.Session); using var governedScope = _governanceScope.Begin(_rlsContext.TenantId, instruction); var chatHistory = await BuildChatHistoryAsync(chatSessionId, message, cancellationToken); - if (mode == ChatRoutingMode.Routed) + if (mode == ChatRoutingMode.Direct) { - var forcedToolReply = await TryResolveViaForcedToolInferenceAsync(chatHistory, cancellationToken); - if (!string.IsNullOrWhiteSpace(forcedToolReply)) - { - return await GuardReplyAsync(forcedToolReply, chatHistory, cancellationToken); - } + chatHistory.Add(new(ChatRole.System, TextOnlyInstructions)); + } + + var requiresTool = mode == ChatRoutingMode.Routed + && ChatHistoryIntentPhrases.Any(phrase => message.Contains(phrase, StringComparison.OrdinalIgnoreCase)); + var inferenceAgent = mode == ChatRoutingMode.Direct ? _chatAgent : _agent; + var runOptions = requiresTool + ? new ChatClientAgentRunOptions(new ChatOptions { ToolMode = ChatToolMode.RequireAny }) + : null; + var agentResponse = await inferenceAgent.RunAsync(chatHistory, options: runOptions, cancellationToken: cancellationToken); + if (requiresTool && !agentResponse.Messages.Any(item => item.Contents.OfType().Any())) + { + throw new InvalidOperationException("Required-tool inference completed without a function call."); } - var agentResponse = await _agent.RunAsync(chatHistory, cancellationToken: cancellationToken); var response = agentResponse.Messages.LastOrDefault(); ChatGuard.GuardAgainstNullAgentResponse(response); var reply = response!.Contents.LastOrDefault()?.ToString() ?? string.Empty; - return await GuardReplyAsync(reply, chatHistory, cancellationToken); + if (requiresTool && string.IsNullOrWhiteSpace(reply)) + { + throw new InvalidOperationException("Required-tool inference completed without a reply."); + } + return await GuardReplyAsync(reply, chatHistory, governed.PromptContext.SystemInstruction, cancellationToken); } finally { @@ -112,6 +127,7 @@ public async Task ResolveReplyAsync( private async Task GuardReplyAsync( string reply, List chatHistory, + string governedInstruction, CancellationToken cancellationToken) { if (!FutureTenseGuard.ContainsFutureTense(reply)) @@ -124,10 +140,14 @@ private async Task GuardReplyAsync( { var retryHistory = new List(chatHistory) { + new(ChatRole.Assistant, reply), + new(ChatRole.System, TextOnlyInstructions), new(ChatRole.System, ToolRoutingInstructions.NoFutureTensePreamble), new(ChatRole.System, "The previous reply violated the rule above. Produce the final answer now, using only past or present tense and containing results rather than announcements.") }; - var retryResponse = await _agent.RunAsync(retryHistory, cancellationToken: cancellationToken); + var retryInstruction = AgentInstruction.Create($"{governedInstruction}\n\n{TextOnlyInstructions}", InstructionScope.Session); + using var retryScope = _governanceScope.Begin(_rlsContext.TenantId, retryInstruction); + var retryResponse = await _chatAgent.RunAsync(retryHistory, cancellationToken: cancellationToken); var retryReply = retryResponse.Messages.LastOrDefault()?.Contents.LastOrDefault()?.ToString(); if (!string.IsNullOrWhiteSpace(retryReply)) { @@ -142,29 +162,6 @@ private async Task GuardReplyAsync( return FutureTenseGuard.Enforce(reply); } - private async Task TryResolveViaForcedToolInferenceAsync( - List chatHistory, - CancellationToken cancellationToken) - { - try - { - var runOptions = new ChatClientAgentRunOptions(new ChatOptions { ToolMode = ChatToolMode.RequireAny }); - var agentResponse = await _agent.RunAsync(chatHistory, options: runOptions, cancellationToken: cancellationToken); - if (!agentResponse.Messages.Any(message => message.Contents.OfType().Any())) - { - _logger.LogWarning("Forced-tool inference did not invoke a tool; falling back to an open agent turn."); - return null; - } - - return agentResponse.Messages.LastOrDefault()?.Contents.LastOrDefault()?.ToString(); - } - catch (Exception exception) - { - LogForcedToolInferenceFailure(_logger, exception); - return null; - } - } - private async Task GovernTurnAsync( Guid chatSessionId, string userMessage, diff --git a/src/Infrastructure.AgentFramework/ConfigureServices.cs b/src/Infrastructure.AgentFramework/ConfigureServices.cs index 47189cb..087f5b6 100644 --- a/src/Infrastructure.AgentFramework/ConfigureServices.cs +++ b/src/Infrastructure.AgentFramework/ConfigureServices.cs @@ -128,122 +128,139 @@ public static IServiceCollection AddAgentFrameworkOpenAIServices(this IServiceCo services.AddEssayPlaybookTools(); services.AddScoped(); - services.AddSingleton(provider => + foreach (var purpose in Enum.GetValues()) { - var providerOptions = provider.GetRequiredService>().Value; - var logger = provider.GetRequiredService().CreateLogger("AgentProviderConfiguration"); - - if (providerOptions.Kind.Equals("GitHubCopilotSDK", StringComparison.OrdinalIgnoreCase)) + services.AddKeyedSingleton(purpose, (provider, _) => { - var options = provider.GetRequiredService>().Value; - logger.LogInformation("Agent provider resolved to {Provider}. Endpoint: {Endpoint}. ChatCompletionModelId: {ChatCompletionModelId}", - providerOptions.Kind, - options.Endpoint, - options.ChatCompletionModelId); + var providerOptions = provider.GetRequiredService>().Value; + var logger = provider.GetRequiredService().CreateLogger("AgentProviderConfiguration"); + logger.LogInformation("Resolving {Provider} inference purpose {Purpose}", providerOptions.Kind, purpose); - var client = new OpenAIClient( - new System.ClientModel.ApiKeyCredential(options.ApiKey), - CreateClientOptions(options.Endpoint)); - return client.GetChatClient(options.ChatCompletionModelId); - } + if (providerOptions.Kind.Equals("GitHubCopilotSDK", StringComparison.OrdinalIgnoreCase)) + { + var options = provider.GetRequiredService>().Value; + logger.LogInformation("Agent provider resolved to {Provider}. Endpoint: {Endpoint}. Model: {Model}", + providerOptions.Kind, + options.Endpoint, + SelectModel(purpose, options.ChatCompletionModelId, options.ToolCompletionModelId, options.ToolReasoningModelId)); + + var client = new OpenAIClient( + new System.ClientModel.ApiKeyCredential(options.ApiKey), + CreateClientOptions(options.Endpoint)); + return client.GetChatClient(SelectModel(purpose, options.ChatCompletionModelId, options.ToolCompletionModelId, options.ToolReasoningModelId)); + } - if (providerOptions.Kind.Equals("AzureOpenAI", StringComparison.OrdinalIgnoreCase)) - { - var options = provider.GetRequiredService>().Value; - var normalizedEndpoint = NormalizeAzureOpenAIEndpoint(options.Endpoint); - logger.LogInformation("Agent provider resolved to {Provider}. Endpoint: {Endpoint}. NormalizedEndpoint: {NormalizedEndpoint}. ChatDeploymentName: {ChatDeploymentName}", - providerOptions.Kind, - options.Endpoint, - normalizedEndpoint, - options.ChatDeploymentName); - - var client = new OpenAIClient( - new System.ClientModel.ApiKeyCredential(options.ApiKey), - CreateClientOptions(normalizedEndpoint)); - return client.GetChatClient(options.ChatDeploymentName); - } + if (providerOptions.Kind.Equals("AzureOpenAI", StringComparison.OrdinalIgnoreCase)) + { + var options = provider.GetRequiredService>().Value; + var normalizedEndpoint = NormalizeAzureOpenAIEndpoint(options.Endpoint); + logger.LogInformation("Agent provider resolved to {Provider}. Endpoint: {Endpoint}. NormalizedEndpoint: {NormalizedEndpoint}. Model: {Model}", + providerOptions.Kind, + options.Endpoint, + normalizedEndpoint, + SelectModel(purpose, options.ChatDeploymentName, options.ToolDeploymentName, options.ToolReasoningDeploymentName)); + + var client = new OpenAIClient( + new System.ClientModel.ApiKeyCredential(options.ApiKey), + CreateClientOptions(normalizedEndpoint)); + return client.GetChatClient(SelectModel(purpose, options.ChatDeploymentName, options.ToolDeploymentName, options.ToolReasoningDeploymentName)); + } - if (providerOptions.Kind.Equals("Ollama", StringComparison.OrdinalIgnoreCase)) - { - var options = provider.GetRequiredService>().Value; - logger.LogInformation("Agent provider resolved to {Provider}. Endpoint: {Endpoint}. ChatCompletionModelId: {ChatCompletionModelId}", - providerOptions.Kind, - options.Endpoint, - options.ChatCompletionModelId); + if (providerOptions.Kind.Equals("Ollama", StringComparison.OrdinalIgnoreCase)) + { + var options = provider.GetRequiredService>().Value; + logger.LogInformation("Agent provider resolved to {Provider}. Endpoint: {Endpoint}. Model: {Model}", + providerOptions.Kind, + options.Endpoint, + SelectModel(purpose, options.ChatCompletionModelId, options.ToolCompletionModelId, options.ToolReasoningModelId)); + + var client = new OpenAIClient( + new System.ClientModel.ApiKeyCredential(string.IsNullOrWhiteSpace(options.ApiKey) ? "ollama" : options.ApiKey), + CreateClientOptions(options.Endpoint)); + return client.GetChatClient(SelectModel(purpose, options.ChatCompletionModelId, options.ToolCompletionModelId, options.ToolReasoningModelId)); + } - var client = new OpenAIClient( - new System.ClientModel.ApiKeyCredential(string.IsNullOrWhiteSpace(options.ApiKey) ? "ollama" : options.ApiKey), - CreateClientOptions(options.Endpoint)); - return client.GetChatClient(options.ChatCompletionModelId); - } + if (providerOptions.Kind.Equals("AzureAIFoundry", StringComparison.OrdinalIgnoreCase)) + { + var options = provider.GetRequiredService>().Value; + logger.LogInformation("Agent provider resolved to {Provider}. Endpoint: {Endpoint}. Model: {Model}", + providerOptions.Kind, + options.Endpoint, + SelectModel(purpose, options.ChatCompletionModelId, options.ToolCompletionModelId, options.ToolReasoningModelId)); + + var client = new OpenAIClient( + new System.ClientModel.ApiKeyCredential(options.ApiKey), + CreateClientOptions(options.Endpoint)); + return client.GetChatClient(SelectModel(purpose, options.ChatCompletionModelId, options.ToolCompletionModelId, options.ToolReasoningModelId)); + } - if (providerOptions.Kind.Equals("AzureAIFoundry", StringComparison.OrdinalIgnoreCase)) - { - var options = provider.GetRequiredService>().Value; - logger.LogInformation("Agent provider resolved to {Provider}. Endpoint: {Endpoint}. ChatCompletionModelId: {ChatCompletionModelId}", + var openAIOptions = provider.GetRequiredService>().Value; + logger.LogInformation("Agent provider resolved to {Provider}. Model: {Model}", providerOptions.Kind, - options.Endpoint, - options.ChatCompletionModelId); - - var client = new OpenAIClient( - new System.ClientModel.ApiKeyCredential(options.ApiKey), - CreateClientOptions(options.Endpoint)); - return client.GetChatClient(options.ChatCompletionModelId); - } - - var openAIOptions = provider.GetRequiredService>().Value; - logger.LogInformation("Agent provider resolved to {Provider}. ChatCompletionModelId: {ChatCompletionModelId}", - providerOptions.Kind, - openAIOptions.ChatCompletionModelId); - - return new ChatClient(openAIOptions.ChatCompletionModelId, openAIOptions.ApiKey); - }); - - services.AddSingleton(provider => - { - var chatClient = provider.GetRequiredService(); - var loggerFactory = provider.GetRequiredService(); - var governedOptions = provider.GetRequiredService>().Value; - var searchOptions = provider.GetRequiredService>().Value; - var tools = new List - { - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService() - }; + SelectModel(purpose, openAIOptions.ChatCompletionModelId, openAIOptions.ToolCompletionModelId, openAIOptions.ToolReasoningModelId)); - if (searchOptions.IsAvailable) - { - tools.Add(provider.GetRequiredService()); - } + return new ChatClient(SelectModel(purpose, openAIOptions.ChatCompletionModelId, openAIOptions.ToolCompletionModelId, openAIOptions.ToolReasoningModelId), openAIOptions.ApiKey); + }); - var agentOptions = new ChatClientAgentOptions + services.AddKeyedSingleton(purpose, (provider, _) => { - Name = "CopilotAgent", - Description = "GoodToCode AgentFramework Copilot", - ChatOptions = new Microsoft.Extensions.AI.ChatOptions + var chatClient = provider.GetRequiredKeyedService(purpose); + var loggerFactory = provider.GetRequiredService(); + var governedOptions = provider.GetRequiredService>().Value; + var searchOptions = provider.GetRequiredService>().Value; + var tools = new List(); + if (purpose == InferencePurpose.Tool) { - Tools = tools + tools.AddRange(provider.GetRequiredService().CreateFunctions()); + tools.AddRange(provider.GetRequiredService().CreateFunctions()); + tools.AddRange(provider.GetRequiredService().CreateFunctions()); + + if (searchOptions.IsAvailable) + { + tools.AddRange(provider.GetRequiredService().CreateFunctions()); + } } - }; - return chatClient.AsAIAgent( - options: agentOptions, - clientFactory: inner => new ChatClientBuilder(inner) - .UseGovernedIntent(new GovernedChatClientOptions + var agentOptions = new ChatClientAgentOptions + { + Name = $"CopilotAgent{purpose}", + Description = "GoodToCode AgentFramework Copilot", + ChatOptions = new Microsoft.Extensions.AI.ChatOptions { - IntentRequired = governedOptions.IntentRequired, - PreambleMode = governedOptions.PreambleMode - }) - .Build(provider), - loggerFactory: loggerFactory, - services: provider - ); - }); + Tools = tools + } + }; + + return chatClient.AsAIAgent( + options: agentOptions, + clientFactory: inner => new ChatClientBuilder(inner) + .UseGovernedIntent(new GovernedChatClientOptions + { + IntentRequired = governedOptions.IntentRequired, + PreambleMode = governedOptions.PreambleMode + }) + .Build(provider), + loggerFactory: loggerFactory, + services: provider + ); + }); + } + + services.AddSingleton(provider => provider.GetRequiredKeyedService(InferencePurpose.Chat)); + services.AddSingleton(provider => provider.GetRequiredKeyedService(InferencePurpose.Tool)); return services; } + private static string SelectModel(InferencePurpose purpose, string chat, string tool, string reasoning) + => purpose switch + { + InferencePurpose.Chat => chat, + InferencePurpose.Tool => tool, + InferencePurpose.ToolReasoning => reasoning, + _ => throw new ArgumentOutOfRangeException(nameof(purpose)) + }; + public static IServiceCollection AddWebSearchProviders(this IServiceCollection services, IConfiguration configuration) { services.AddOptions() @@ -295,6 +312,8 @@ private static bool IsSelectedProvider(AgentProviderOptions options, string prov private static bool HasRequiredOpenAI(OpenAIOptions options) => HasValue(options.ChatCompletionModelId) + && HasValue(options.ToolCompletionModelId) + && HasValue(options.ToolReasoningModelId) && HasValue(options.TextGenerationModelId) && HasValue(options.TextEmbeddingModelId) && HasValue(options.TextModerationModelId) @@ -304,6 +323,8 @@ private static bool HasRequiredOpenAI(OpenAIOptions options) private static bool HasRequiredGitHubCopilotSdk(GitHubCopilotSdkOptions options) => HasValue(options.ChatCompletionModelId) + && HasValue(options.ToolCompletionModelId) + && HasValue(options.ToolReasoningModelId) && HasValue(options.TextGenerationModelId) && HasValue(options.TextEmbeddingModelId) && HasValue(options.TextModerationModelId) @@ -313,11 +334,14 @@ private static bool HasRequiredGitHubCopilotSdk(GitHubCopilotSdkOptions options) && HasUri(options.Endpoint); private static bool HasRequiredAzureOpenAI(AzureOpenAIOptions options) - => HasValue(options.ChatDeploymentName) + => HasValidAnnotations(options) + && HasValue(options.ChatDeploymentName) + && HasValue(options.ToolDeploymentName) + && HasValue(options.ToolReasoningDeploymentName) && HasValue(options.ApiKey) && HasUri(options.Endpoint); - private static string NormalizeAzureOpenAIEndpoint(string endpoint) + internal static string NormalizeAzureOpenAIEndpoint(string endpoint) { var trimmed = endpoint.Trim(); @@ -343,10 +367,15 @@ private static string NormalizeAzureOpenAIEndpoint(string endpoint) private static bool HasRequiredOllama(OllamaOptions options) => HasValue(options.ChatCompletionModelId) + && HasValue(options.ToolCompletionModelId) + && HasValue(options.ToolReasoningModelId) && HasUri(options.Endpoint); private static bool HasRequiredAzureAIFoundry(AzureAIFoundryOptions options) - => HasValue(options.ChatCompletionModelId) + => HasValidAnnotations(options) + && HasValue(options.ChatCompletionModelId) + && HasValue(options.ToolCompletionModelId) + && HasValue(options.ToolReasoningModelId) && HasValue(options.TextGenerationModelId) && HasValue(options.TextEmbeddingModelId) && HasValue(options.TextModerationModelId) @@ -378,6 +407,10 @@ private static bool HasRequiredSearchProviderOptions(SearchProviderOptions optio private static bool HasValue(string value) => !string.IsNullOrWhiteSpace(value); + private static bool HasValidAnnotations(object options) + => System.ComponentModel.DataAnnotations.Validator.TryValidateObject( + options, new System.ComponentModel.DataAnnotations.ValidationContext(options), null, validateAllProperties: true); + private static bool HasUri(string value) => Uri.TryCreate(value, UriKind.Absolute, out _); } diff --git a/src/Infrastructure.AgentFramework/Embeddings/AzureOpenAiEmbeddingGenerator.cs b/src/Infrastructure.AgentFramework/Embeddings/AzureOpenAiEmbeddingGenerator.cs index 4d3356b..c7d9a54 100644 --- a/src/Infrastructure.AgentFramework/Embeddings/AzureOpenAiEmbeddingGenerator.cs +++ b/src/Infrastructure.AgentFramework/Embeddings/AzureOpenAiEmbeddingGenerator.cs @@ -1,5 +1,7 @@ -using System.Text; -using System.Text.Json; +using System.ClientModel; +using System.ClientModel.Primitives; +using OpenAI; +using OpenAI.Embeddings; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options; @@ -8,29 +10,29 @@ namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Embeddings; /// /// Implementation of IEmbeddingGenerator using Azure OpenAI (or Azure AI Foundry). -/// Generates 1536-dimensional vectors via text-embedding-3-small deployment. +/// Generates configured-dimensional vectors, defaulting to text-embedding-3-small at 1536 dimensions. /// Thread-safe; can be registered as scoped or singleton. /// public sealed class AzureOpenAiEmbeddingGenerator : IEmbeddingGenerator { - private readonly string _apiKey; - private readonly string _endpoint; + private readonly EmbeddingClient _client; private readonly string _deploymentName; private readonly ILogger _logger; - private readonly HttpClient _httpClient; /// - public int Dimension => 1536; // text-embedding-3-small standard dimension + public int Dimension { get; } /// /// Creates a new instance of the Azure OpenAI embedding generator. /// Configuration is read from appsettings: /// - AzureOpenAI:ApiKey (required) /// - AzureOpenAI:Endpoint (required) - /// - AzureOpenAI:EmbeddingDeploymentName (optional, default: "embedding-fast") + /// - AzureOpenAI:EmbeddingDeploymentName (optional, default: "embedding-standard") + /// - AzureOpenAI:EmbeddingDimension (optional, default: 1536) /// - /// Configuration provider. + /// Configuration provider. /// Structured logger. + /// HTTP transport. /// If required configuration is missing. public AzureOpenAiEmbeddingGenerator( IOptions options, @@ -38,13 +40,19 @@ public AzureOpenAiEmbeddingGenerator( HttpClient httpClient) { var config = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _apiKey = config.ApiKey; - _endpoint = config.Endpoint; - _deploymentName = config.EmbeddingDeploymentName; - if (string.IsNullOrWhiteSpace(_apiKey) || string.IsNullOrWhiteSpace(_endpoint)) + if (string.IsNullOrWhiteSpace(config.ApiKey) || string.IsNullOrWhiteSpace(config.Endpoint)) throw new InvalidOperationException("AzureOpenAI ApiKey and Endpoint are required for embedding generation."); + if (config.EmbeddingDimension <= 0 || string.IsNullOrWhiteSpace(config.EmbeddingDeploymentName)) + throw new InvalidOperationException("AzureOpenAI embedding deployment and positive dimension are required."); + Dimension = config.EmbeddingDimension; + _deploymentName = config.EmbeddingDeploymentName; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + ArgumentNullException.ThrowIfNull(httpClient); + _client = new OpenAIClient(new ApiKeyCredential(config.ApiKey), new OpenAIClientOptions + { + Endpoint = new Uri(ConfigureServices.NormalizeAzureOpenAIEndpoint(config.Endpoint)), + Transport = new HttpClientPipelineTransport(httpClient) + }).GetEmbeddingClient(config.EmbeddingDeploymentName); } /// @@ -71,7 +79,7 @@ public async Task> GenerateBatchAsync( IEnumerable texts, CancellationToken cancellationToken) { - var textList = texts.ToList(); + var textList = texts.Distinct(StringComparer.Ordinal).ToList(); if (textList.Count == 0) { _logger.LogDebug("Empty text batch provided to GenerateBatchAsync; returning empty dictionary"); @@ -85,69 +93,24 @@ public async Task> GenerateBatchAsync( textList.Count, _deploymentName); - // Build request - var requestBody = new - { - input = textList - }; - var jsonRequest = JsonSerializer.Serialize(requestBody); - var httpContent = new StringContent(jsonRequest, Encoding.UTF8, "application/json"); - - // Build URL - var url = $"{_endpoint.TrimEnd('/')}/openai/deployments/{_deploymentName}/embeddings?api-version=2024-02-15-preview"; - - // Create request with auth header - var request = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = httpContent - }; - request.Headers.Add("api-key", _apiKey); - - // Call Azure OpenAI var startTime = DateTime.UtcNow; - using var response = await _httpClient.SendAsync(request, cancellationToken); + var response = await _client.GenerateEmbeddingsAsync(textList, + new EmbeddingGenerationOptions { Dimensions = Dimension }, cancellationToken); var elapsed = DateTime.UtcNow - startTime; - - if (!response.IsSuccessStatusCode) + var result = new Dictionary(StringComparer.Ordinal); + if (response.Value.Count != textList.Count) { - var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogError( - "Azure OpenAI embedding request failed: {StatusCode} {Reason}\nDetails: {ErrorContent}", - response.StatusCode, - response.ReasonPhrase, - errorContent); - throw new HttpRequestException( - $"Azure OpenAI embedding failed: {response.StatusCode} {response.ReasonPhrase}"); + throw new InvalidOperationException("Embedding response count does not match the input count."); } - - // Parse response - var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); - using var jsonDoc = JsonDocument.Parse(responseContent); - var root = jsonDoc.RootElement; - - var result = new Dictionary(StringComparer.Ordinal); - - if (root.TryGetProperty("data", out var dataArray)) + foreach (var embedding in response.Value) { - var embeddingIndex = 0; - foreach (var item in dataArray.EnumerateArray()) + var vector = embedding.ToFloats().ToArray(); + if (embedding.Index < 0 || embedding.Index >= textList.Count || vector.Length != Dimension + || result.ContainsKey(textList[embedding.Index])) { - if (item.TryGetProperty("embedding", out var embeddingProp) && - item.TryGetProperty("index", out var indexProp)) - { - var index = indexProp.GetInt32(); - if (index >= 0 && index < textList.Count) - { - var vector = embeddingProp - .EnumerateArray() - .Select(e => e.GetSingle()) - .ToArray(); - - result[textList[index]] = vector; - embeddingIndex++; - } - } + throw new InvalidOperationException("Embedding response contains an invalid index or vector dimension."); } + result.Add(textList[embedding.Index], vector); } _logger.LogInformation( diff --git a/src/Infrastructure.AgentFramework/InferencePurpose.cs b/src/Infrastructure.AgentFramework/InferencePurpose.cs new file mode 100644 index 0000000..d07a90c --- /dev/null +++ b/src/Infrastructure.AgentFramework/InferencePurpose.cs @@ -0,0 +1,12 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework; + +/// Identifies the deployment contract required by an inference consumer. +public enum InferencePurpose +{ + /// Text-only conversation without application functions. + Chat, + /// Interactive application function selection and execution. + Tool, + /// Reasoning-intensive playbook inference without chat administration functions. + ToolReasoning +} diff --git a/src/Infrastructure.AgentFramework/Options/AzureAIFoundryOptions.cs b/src/Infrastructure.AgentFramework/Options/AzureAIFoundryOptions.cs index 3af25e0..98a2487 100644 --- a/src/Infrastructure.AgentFramework/Options/AzureAIFoundryOptions.cs +++ b/src/Infrastructure.AgentFramework/Options/AzureAIFoundryOptions.cs @@ -10,8 +10,19 @@ public sealed class AzureAIFoundryOptions public const string SectionName = "AzureAIFoundry"; [Required] + [RegularExpression("^chat-[a-z0-9-]+$")] public string ChatCompletionModelId { get; set; } = string.Empty; + /// Deployment for interactive function execution. + [Required] + [RegularExpression("^tool-[a-z0-9-]+$")] + public string ToolCompletionModelId { get; set; } = string.Empty; + + /// Deployment for reasoning-intensive playbook inference. + [Required] + [RegularExpression("^tool-[a-z0-9-]+$")] + public string ToolReasoningModelId { get; set; } = string.Empty; + [Required] public string TextGenerationModelId { get; set; } = string.Empty; diff --git a/src/Infrastructure.AgentFramework/Options/AzureOpenAIOptions.cs b/src/Infrastructure.AgentFramework/Options/AzureOpenAIOptions.cs index c0e9ae6..e94cf78 100644 --- a/src/Infrastructure.AgentFramework/Options/AzureOpenAIOptions.cs +++ b/src/Infrastructure.AgentFramework/Options/AzureOpenAIOptions.cs @@ -10,10 +10,27 @@ public sealed class AzureOpenAIOptions public const string SectionName = "AzureOpenAI"; [Required] + [RegularExpression("^chat-[a-z0-9-]+$")] public string ChatDeploymentName { get; set; } = string.Empty; + /// Function-capable deployment for the interactive agent. + [Required] + [RegularExpression("^tool-[a-z0-9-]+$")] + public string ToolDeploymentName { get; set; } = string.Empty; + + /// Reasoning deployment for playbook inference. + [Required] + [RegularExpression("^tool-[a-z0-9-]+$")] + public string ToolReasoningDeploymentName { get; set; } = string.Empty; + /// Azure OpenAI deployment used to generate semantic intent embeddings. - public string EmbeddingDeploymentName { get; set; } = "embedding-fast"; + [Required] + [RegularExpression("^embedding-[a-z0-9-]+$")] + public string EmbeddingDeploymentName { get; set; } = "embedding-standard"; + + /// Vector length returned by the intent embedding deployment. + [Range(1, 16384)] + public int EmbeddingDimension { get; set; } = 1536; [Required] public string Endpoint { get; set; } = string.Empty; diff --git a/src/Infrastructure.AgentFramework/Options/GitHubCopilotSdkOptions.cs b/src/Infrastructure.AgentFramework/Options/GitHubCopilotSdkOptions.cs index bacb0e8..9cc8c1c 100644 --- a/src/Infrastructure.AgentFramework/Options/GitHubCopilotSdkOptions.cs +++ b/src/Infrastructure.AgentFramework/Options/GitHubCopilotSdkOptions.cs @@ -9,6 +9,14 @@ public sealed class GitHubCopilotSdkOptions [Required] public string ChatCompletionModelId { get; set; } = string.Empty; + /// Model for interactive function execution. + [Required] + public string ToolCompletionModelId { get; set; } = string.Empty; + + /// Model for reasoning-intensive playbook inference. + [Required] + public string ToolReasoningModelId { get; set; } = string.Empty; + [Required] public string TextGenerationModelId { get; set; } = string.Empty; diff --git a/src/Infrastructure.AgentFramework/Options/OllamaOptions.cs b/src/Infrastructure.AgentFramework/Options/OllamaOptions.cs index eb013db..522accb 100644 --- a/src/Infrastructure.AgentFramework/Options/OllamaOptions.cs +++ b/src/Infrastructure.AgentFramework/Options/OllamaOptions.cs @@ -12,6 +12,14 @@ public sealed class OllamaOptions [Required] public string ChatCompletionModelId { get; set; } = string.Empty; + /// Function-capable model for interactive application tools. + [Required] + public string ToolCompletionModelId { get; set; } = string.Empty; + + /// Model for reasoning-intensive playbook inference. + [Required] + public string ToolReasoningModelId { get; set; } = string.Empty; + [Required] public string Endpoint { get; set; } = string.Empty; diff --git a/src/Infrastructure.AgentFramework/Options/OpenAIOptions.cs b/src/Infrastructure.AgentFramework/Options/OpenAIOptions.cs index 752acd2..caecc51 100644 --- a/src/Infrastructure.AgentFramework/Options/OpenAIOptions.cs +++ b/src/Infrastructure.AgentFramework/Options/OpenAIOptions.cs @@ -12,6 +12,14 @@ public sealed class OpenAIOptions [Required] public string ChatCompletionModelId { get; set; } = string.Empty; + /// Model for interactive function execution. + [Required] + public string ToolCompletionModelId { get; set; } = string.Empty; + + /// Model for reasoning-intensive playbook inference. + [Required] + public string ToolReasoningModelId { get; set; } = string.Empty; + [Required] public string TextGenerationModelId { get; set; } = string.Empty; diff --git a/src/Infrastructure.AgentFramework/Playbooks/Essay/EssayEvaluateAgentTool.cs b/src/Infrastructure.AgentFramework/Playbooks/Essay/EssayEvaluateAgentTool.cs index 93e11e6..8bc8117 100644 --- a/src/Infrastructure.AgentFramework/Playbooks/Essay/EssayEvaluateAgentTool.cs +++ b/src/Infrastructure.AgentFramework/Playbooks/Essay/EssayEvaluateAgentTool.cs @@ -6,6 +6,7 @@ using Goodtocode.Agents.Playbook.Tools; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Playbooks.Essay; @@ -17,7 +18,7 @@ namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Playbooks.Essa /// overall score from the model's per-criterion scores. /// [PlaybookTool("essay.agentic.evaluate")] -public sealed class EssayEvaluateAgentTool(AIAgent agent, EssayKnowledgeHolder knowledgeHolder) : IEvaluateStepTool +public sealed class EssayEvaluateAgentTool([FromKeyedServices(InferencePurpose.ToolReasoning)] AIAgent agent, EssayKnowledgeHolder knowledgeHolder) : IEvaluateStepTool { private readonly AIAgent _agent = agent; private readonly PlaybookKnowledge _knowledge = knowledgeHolder.Knowledge; diff --git a/src/Infrastructure.AgentFramework/Playbooks/Taxonomy/TaxonomyCollectAgentTool.cs b/src/Infrastructure.AgentFramework/Playbooks/Taxonomy/TaxonomyCollectAgentTool.cs index 3159c11..124e91c 100644 --- a/src/Infrastructure.AgentFramework/Playbooks/Taxonomy/TaxonomyCollectAgentTool.cs +++ b/src/Infrastructure.AgentFramework/Playbooks/Taxonomy/TaxonomyCollectAgentTool.cs @@ -4,6 +4,7 @@ using Goodtocode.Agents.Playbook.Tools; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Playbooks.Taxonomy; @@ -14,7 +15,7 @@ namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Playbooks.Taxo /// boundary. /// [PlaybookTool("taxonomy.agentic.collect")] -public sealed class TaxonomyCollectAgentTool(AIAgent agent) : ICollectStepTool +public sealed class TaxonomyCollectAgentTool([FromKeyedServices(InferencePurpose.ToolReasoning)] AIAgent agent) : ICollectStepTool { private readonly AIAgent _agent = agent; diff --git a/src/Infrastructure.AgentFramework/Playbooks/Taxonomy/TaxonomyEvaluateAgentTool.cs b/src/Infrastructure.AgentFramework/Playbooks/Taxonomy/TaxonomyEvaluateAgentTool.cs index 02fffa8..246217a 100644 --- a/src/Infrastructure.AgentFramework/Playbooks/Taxonomy/TaxonomyEvaluateAgentTool.cs +++ b/src/Infrastructure.AgentFramework/Playbooks/Taxonomy/TaxonomyEvaluateAgentTool.cs @@ -5,6 +5,7 @@ using Goodtocode.Agents.Playbook.Tools; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Playbooks.Taxonomy; @@ -16,7 +17,7 @@ namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Playbooks.Taxo /// contract. /// [PlaybookTool("taxonomy.agentic.evaluate")] -public sealed class TaxonomyEvaluateAgentTool(AIAgent agent, TaxonomyKnowledgeHolder knowledgeHolder) : IEvaluateStepTool +public sealed class TaxonomyEvaluateAgentTool([FromKeyedServices(InferencePurpose.ToolReasoning)] AIAgent agent, TaxonomyKnowledgeHolder knowledgeHolder) : IEvaluateStepTool { private readonly AIAgent _agent = agent; private readonly PlaybookKnowledge _knowledge = knowledgeHolder.Knowledge; diff --git a/src/Infrastructure.AgentFramework/Playbooks/Taxonomy/TaxonomyRecordAgentTool.cs b/src/Infrastructure.AgentFramework/Playbooks/Taxonomy/TaxonomyRecordAgentTool.cs index fa35609..9113fbb 100644 --- a/src/Infrastructure.AgentFramework/Playbooks/Taxonomy/TaxonomyRecordAgentTool.cs +++ b/src/Infrastructure.AgentFramework/Playbooks/Taxonomy/TaxonomyRecordAgentTool.cs @@ -4,6 +4,7 @@ using Goodtocode.Agents.Playbook.Tools; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Playbooks.Taxonomy; @@ -13,7 +14,7 @@ namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Playbooks.Taxo /// the raw response into the typed contract. /// [PlaybookTool("taxonomy.agentic.record")] -public sealed class TaxonomyRecordAgentTool(AIAgent agent) : IRecordStepTool +public sealed class TaxonomyRecordAgentTool([FromKeyedServices(InferencePurpose.ToolReasoning)] AIAgent agent) : IRecordStepTool { private readonly AIAgent _agent = agent; diff --git a/src/Infrastructure.AgentFramework/Tools/ScopedAgentTool.cs b/src/Infrastructure.AgentFramework/Tools/ScopedAgentTool.cs index efa9370..ca24427 100644 --- a/src/Infrastructure.AgentFramework/Tools/ScopedAgentTool.cs +++ b/src/Infrastructure.AgentFramework/Tools/ScopedAgentTool.cs @@ -1,3 +1,7 @@ +using System.ComponentModel; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Execution; using Goodtocode.Mediator; using Microsoft.Extensions.AI; @@ -8,10 +12,44 @@ namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools; /// /// Shared base class for AI tools that execute application requests in an isolated scope. /// -public abstract class ScopedAgentTool(IServiceProvider serviceProvider) : AITool +public abstract class ScopedAgentTool(IServiceProvider serviceProvider) { private readonly IServiceProvider _serviceProvider = serviceProvider; + /// Creates functions from explicitly described methods, retaining typed schemas and scoped execution. + /// Functions with stable, tool-qualified names. + /// The tool has no explicitly described methods. + public IReadOnlyList CreateFunctions() + { + var toolType = GetType(); + var functions = toolType.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(method => method.GetCustomAttribute() is not null) + .Select(method => AIFunctionFactory.Create(method, this, new AIFunctionFactoryOptions + { + Name = CreateFunctionName(toolType, method) + })) + .ToList(); + if (functions.Count == 0) + { + throw new InvalidOperationException($"Tool '{toolType.Name}' has no described functions."); + } + + return functions; + } + + private static string CreateFunctionName(Type toolType, MethodInfo method) + { + var name = $"{toolType.Name}_{method.Name}"; + if (name.Length <= 64) + { + return name; + } + + // Preserve a readable prefix and a stable suffix to avoid truncation collisions. + var suffix = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(name)))[..16]; + return $"{name[..47]}_{suffix}"; + } + /// /// Sends an application request with a response through a new dependency-injection scope. /// diff --git a/src/Presentation.Api/appsettings.Development.json b/src/Presentation.Api/appsettings.Development.json index d73ea05..17d337c 100644 --- a/src/Presentation.Api/appsettings.Development.json +++ b/src/Presentation.Api/appsettings.Development.json @@ -31,18 +31,25 @@ }, "Ollama": { "ChatCompletionModelId": "phi4", + "ToolCompletionModelId": "gpt-oss:20b", + "ToolReasoningModelId": "gpt-oss:20b", "Endpoint": "http://localhost:11434/api" }, "AzureOpenAI": { "ChatDeploymentName": "chat-fast", - "EmbeddingDeploymentName": "embedding-fast", + "ToolDeploymentName": "tool-standard", + "ToolReasoningDeploymentName": "tool-reasoning", + "EmbeddingDeploymentName": "embedding-standard", + "EmbeddingDimension": 1536, "Endpoint": "https://your-resource.openai.azure.com", "ApiKey": "" }, "AzureAIFoundry": { - "ChatCompletionModelId": "gpt-5.4-mini", + "ChatCompletionModelId": "chat-fast", + "ToolCompletionModelId": "tool-standard", + "ToolReasoningModelId": "tool-reasoning", "TextGenerationModelId": "gpt-3.5-turbo-instruct", - "TextEmbeddingModelId": "text-embedding-3-small", + "TextEmbeddingModelId": "embedding-standard", "TextModerationModelId": "text-moderation-latest", "ImageModelId": "gpt-image-1", "AudioModelId": "gpt-4o-mini-tts", @@ -51,6 +58,8 @@ }, "GitHubCopilotSDK": { "ChatCompletionModelId": "gpt-5.4", + "ToolCompletionModelId": "gpt-5.4", + "ToolReasoningModelId": "gpt-5.4", "TextGenerationModelId": "gpt-3.5-turbo-instruct", "TextEmbeddingModelId": "text-embedding-3-small", "TextModerationModelId": "text-moderation-latest", @@ -61,6 +70,8 @@ }, "OpenAI": { "ChatCompletionModelId": "gpt-5.4-mini", + "ToolCompletionModelId": "gpt-5.4-mini", + "ToolReasoningModelId": "gpt-5.4-mini", "TextGenerationModelId": "gpt-3.5-turbo-instruct", "TextEmbeddingModelId": "text-embedding-3-small", "TextModerationModelId": "text-moderation-latest", diff --git a/src/Presentation.Api/appsettings.Production.json b/src/Presentation.Api/appsettings.Production.json index 3831236..bcc7139 100644 --- a/src/Presentation.Api/appsettings.Production.json +++ b/src/Presentation.Api/appsettings.Production.json @@ -31,18 +31,25 @@ }, "Ollama": { "ChatCompletionModelId": "phi4", + "ToolCompletionModelId": "gpt-oss:20b", + "ToolReasoningModelId": "gpt-oss:20b", "Endpoint": "http://localhost:11434/api" }, "AzureOpenAI": { - "ChatDeploymentName": "chat-balanced", - "EmbeddingDeploymentName": "embedding-fast", + "ChatDeploymentName": "chat-fast", + "ToolDeploymentName": "tool-standard", + "ToolReasoningDeploymentName": "tool-reasoning", + "EmbeddingDeploymentName": "embedding-standard", + "EmbeddingDimension": 1536, "Endpoint": "https://your-resource.openai.azure.com", "ApiKey": "" }, "AzureAIFoundry": { - "ChatCompletionModelId": "gpt-5.4-mini", + "ChatCompletionModelId": "chat-fast", + "ToolCompletionModelId": "tool-standard", + "ToolReasoningModelId": "tool-reasoning", "TextGenerationModelId": "gpt-3.5-turbo-instruct", - "TextEmbeddingModelId": "text-embedding-3-small", + "TextEmbeddingModelId": "embedding-standard", "TextModerationModelId": "text-moderation-latest", "ImageModelId": "gpt-image-1", "AudioModelId": "gpt-4o-mini-tts", @@ -51,6 +58,8 @@ }, "GitHubCopilotSDK": { "ChatCompletionModelId": "gpt-5.4", + "ToolCompletionModelId": "gpt-5.4", + "ToolReasoningModelId": "gpt-5.4", "TextGenerationModelId": "gpt-3.5-turbo-instruct", "TextEmbeddingModelId": "text-embedding-3-small", "TextModerationModelId": "text-moderation-latest", @@ -61,6 +70,8 @@ }, "OpenAI": { "ChatCompletionModelId": "gpt-5.4-mini", + "ToolCompletionModelId": "gpt-5.4-mini", + "ToolReasoningModelId": "gpt-5.4-mini", "TextGenerationModelId": "gpt-3.5-turbo-instruct", "TextEmbeddingModelId": "text-embedding-3-small", "TextModerationModelId": "text-moderation-latest", diff --git a/src/Presentation.Api/appsettings.local.json b/src/Presentation.Api/appsettings.local.json index a6186d8..2f705d5 100644 --- a/src/Presentation.Api/appsettings.local.json +++ b/src/Presentation.Api/appsettings.local.json @@ -31,18 +31,25 @@ }, "Ollama": { "ChatCompletionModelId": "phi4", + "ToolCompletionModelId": "gpt-oss:20b", + "ToolReasoningModelId": "gpt-oss:20b", "Endpoint": "http://localhost:11434/api" }, "AzureOpenAI": { "ChatDeploymentName": "chat-fast", - "EmbeddingDeploymentName": "embedding-fast", + "ToolDeploymentName": "tool-standard", + "ToolReasoningDeploymentName": "tool-reasoning", + "EmbeddingDeploymentName": "embedding-standard", + "EmbeddingDimension": 1536, "Endpoint": "https://your-resource.openai.azure.com/openai/v1", "ApiKey": "" }, "AzureAIFoundry": { - "ChatCompletionModelId": "gpt-5.4-mini", + "ChatCompletionModelId": "chat-fast", + "ToolCompletionModelId": "tool-standard", + "ToolReasoningModelId": "tool-reasoning", "TextGenerationModelId": "gpt-3.5-turbo-instruct", - "TextEmbeddingModelId": "text-embedding-3-small", + "TextEmbeddingModelId": "embedding-standard", "TextModerationModelId": "text-moderation-latest", "ImageModelId": "gpt-image-1", "AudioModelId": "gpt-4o-mini-tts", @@ -51,6 +58,8 @@ }, "GitHubCopilotSDK": { "ChatCompletionModelId": "gpt-5.4", + "ToolCompletionModelId": "gpt-5.4", + "ToolReasoningModelId": "gpt-5.4", "TextGenerationModelId": "gpt-3.5-turbo-instruct", "TextEmbeddingModelId": "text-embedding-3-small", "TextModerationModelId": "text-moderation-latest", @@ -61,6 +70,8 @@ }, "OpenAI": { "ChatCompletionModelId": "gpt-5.4-mini", + "ToolCompletionModelId": "gpt-5.4-mini", + "ToolReasoningModelId": "gpt-5.4-mini", "TextGenerationModelId": "gpt-3.5-turbo-instruct", "TextEmbeddingModelId": "text-embedding-3-small", "TextModerationModelId": "text-moderation-latest", diff --git a/src/Tests.Integration/AgentFramework/AgentFunctionRegistrationTests.cs b/src/Tests.Integration/AgentFramework/AgentFunctionRegistrationTests.cs new file mode 100644 index 0000000..8b08879 --- /dev/null +++ b/src/Tests.Integration/AgentFramework/AgentFunctionRegistrationTests.cs @@ -0,0 +1,136 @@ +using DescriptionAttribute = System.ComponentModel.DescriptionAttribute; +using System.Reflection; +using System.Text.Json; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Tools; +using Microsoft.Extensions.AI; + +namespace Goodtocode.AgentFramework.Tests.Integration.AgentFramework; + +[TestClass] +public sealed class AgentFunctionRegistrationTests +{ + [TestMethod] + public void EveryDescribedToolMethodHasAFunctionSchema() + { + using var provider = new ServiceCollection().BuildServiceProvider(); + ScopedAgentTool[] tools = + [ + new MyChatSessionsTool(provider), + new ActorsTool(provider, new AgentChatContextAccessor()), + new MyChatMessagesTool(provider), + new WebSearchTool(provider) + ]; + var names = new HashSet(StringComparer.Ordinal); + foreach (var tool in tools) + { + var methods = tool.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(method => method.GetCustomAttribute() is not null).ToList(); + var functions = tool.CreateFunctions(); + Assert.IsNotEmpty(functions); + Assert.AreEqual(methods.Count, functions.Count); + foreach (var method in methods) + { + var function = functions.Single(candidate => candidate.Name == $"{tool.GetType().Name}_{method.Name}"); + Assert.IsTrue(function.Name.Length <= 64); + Assert.IsTrue(names.Add(function.Name)); + Assert.AreEqual(method.GetCustomAttribute()!.Description, function.Description); + var properties = function.JsonSchema.GetProperty("properties"); + Assert.IsFalse(properties.TryGetProperty("cancellationToken", out _)); + foreach (var parameter in method.GetParameters().Where(parameter => parameter.ParameterType != typeof(CancellationToken))) + { + Assert.IsTrue(properties.TryGetProperty(parameter.Name!, out _)); + } + } + } + } + + [TestMethod] + public async Task FunctionInvocationPreservesScopedResolution() + { + var state = new InvocationState(); + var services = new ServiceCollection(); + services.AddSingleton(state); + services.AddScoped(); + using var provider = services.BuildServiceProvider(validateScopes: true); + var function = new YearTool(provider).CreateFunctions().Single(); + + var result = await function.InvokeAsync(new AIFunctionArguments(), CancellationToken.None); + + Assert.IsTrue(result is JsonElement { ValueKind: JsonValueKind.Number }); + if (result is JsonElement element) + { + Assert.AreEqual(DateTime.UtcNow.Year, element.GetInt32()); + } + Assert.AreEqual(1, state.Invocations); + Assert.AreEqual(1, state.Disposals); + } + + [TestMethod] + public void ToolWithoutDescribedMethodsFailsExplicitly() + { + using var provider = new ServiceCollection().BuildServiceProvider(); + Assert.ThrowsExactly(() => new EmptyTool(provider).CreateFunctions()); + } + + [TestMethod] + public async Task LongFunctionNamesAreStableDistinctAndInvocable() + { + using var provider = new ServiceCollection().BuildServiceProvider(); + var tool = new LongNameTool(provider); + var functions = tool.CreateFunctions(); + var repeated = tool.CreateFunctions(); + + Assert.AreEqual(2, functions.Count); + Assert.AreEqual(2, functions.Select(function => function.Name).Distinct().Count()); + CollectionAssert.AreEqual(functions.Select(function => function.Name).ToArray(), repeated.Select(function => function.Name).ToArray()); + foreach (var function in functions) + { + Assert.AreEqual(64, function.Name.Length); + StringAssert.Matches(function.Name, new System.Text.RegularExpressions.Regex("^[a-zA-Z0-9_-]{64}$")); + var result = await function.InvokeAsync(new AIFunctionArguments(), CancellationToken.None); + Assert.IsInstanceOfType(result); + if (result is JsonElement element) + { + Assert.AreEqual(1, element.GetInt32()); + } + } + } + + private sealed class LongNameTool(IServiceProvider provider) : ScopedAgentTool(provider) + { + [Description("Synthetic function with a long name.")] + public int QuerySyntheticFunctionWithAnExtremelyLongSharedNameForProviderBoundaryFirst() => 1; + + [Description("Another synthetic function with the same long prefix.")] + public int QuerySyntheticFunctionWithAnExtremelyLongSharedNameForProviderBoundarySecond() => 1; + } + + private sealed class EmptyTool(IServiceProvider provider) : ScopedAgentTool(provider); + + private sealed class YearTool(IServiceProvider provider) : ScopedAgentTool(provider) + { + [Description("Returns the current UTC year using a scoped service.")] + public Task GetYearAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return ResolveScopedAsync(scoped => Task.FromResult(scoped.GetRequiredService().GetYear())); + } + } + + private sealed class InvocationState + { + public int Invocations { get; set; } + public int Disposals { get; set; } + } + + private sealed class YearProvider(InvocationState state) : IDisposable + { + public int GetYear() + { + state.Invocations++; + return DateTime.UtcNow.Year; + } + + public void Dispose() => state.Disposals++; + } +} diff --git a/src/Tests.Integration/AgentFramework/AzureOpenAiEmbeddingGeneratorTests.cs b/src/Tests.Integration/AgentFramework/AzureOpenAiEmbeddingGeneratorTests.cs index cf07ab3..2488e19 100644 --- a/src/Tests.Integration/AgentFramework/AzureOpenAiEmbeddingGeneratorTests.cs +++ b/src/Tests.Integration/AgentFramework/AzureOpenAiEmbeddingGeneratorTests.cs @@ -31,7 +31,7 @@ public async Task GenerateBatchAsyncReturnsEmptyForEmptyInput() [TestMethod] public async Task GenerateAsyncReturnsVectorFromAzureResponse() { - var generator = CreateGenerator(new QueueHandler(CreateResponse("[0.1, 0.2, 0.3]", 0))); + var generator = CreateGenerator(new QueueHandler(CreateResponse("[0.1, 0.2, 0.3]", 0)), dimension: 3); var result = await generator.GenerateAsync("create a playbook", CancellationToken.None); @@ -42,7 +42,7 @@ public async Task GenerateAsyncReturnsVectorFromAzureResponse() public async Task GenerateBatchAsyncMapsResponsesByInputIndex() { var handler = new QueueHandler(CreateBatchResponse()); - var generator = CreateGenerator(handler); + var generator = CreateGenerator(handler, dimension: 1); var result = await generator.GenerateBatchAsync(new[] { "first", "second" }, CancellationToken.None); @@ -51,14 +51,38 @@ public async Task GenerateBatchAsyncMapsResponsesByInputIndex() Assert.AreEqual(1, handler.SendCount); } - private static AzureOpenAiEmbeddingGenerator CreateGenerator(HttpMessageHandler handler) + [TestMethod] + public async Task GenerateAsyncUsesV1EndpointDeploymentAndDimension() + { + var handler = new QueueHandler(CreateResponse("[0.1, 0.2, 0.3]", 0)); + var generator = CreateGenerator(handler, dimension: 3, endpoint: "https://example.test/openai/v1/"); + + await generator.GenerateAsync("synthetic intent", CancellationToken.None); + + Assert.AreEqual("https://example.test/openai/v1/embeddings", handler.RequestUri!.ToString()); + using var request = System.Text.Json.JsonDocument.Parse(handler.RequestBody!); + Assert.AreEqual("embedding-standard", request.RootElement.GetProperty("model").GetString()); + Assert.AreEqual(3, request.RootElement.GetProperty("dimensions").GetInt32()); + } + + [TestMethod] + public async Task GenerateAsyncRejectsUnexpectedVectorDimension() + { + var generator = CreateGenerator(new QueueHandler(CreateResponse("[0.1]", 0))); + + await Assert.ThrowsExactlyAsync(() => + generator.GenerateAsync("synthetic intent", CancellationToken.None)); + } + + private static AzureOpenAiEmbeddingGenerator CreateGenerator(HttpMessageHandler handler, int dimension = 1536, string endpoint = "https://example.test") { return new AzureOpenAiEmbeddingGenerator( Options.Create(new AzureOpenAIOptions { ApiKey = "test-key", - Endpoint = "https://example.test", - EmbeddingDeploymentName = "embedding-fast" + Endpoint = endpoint, + EmbeddingDeploymentName = "embedding-standard", + EmbeddingDimension = dimension }), NullLogger.Instance, new HttpClient(handler)); @@ -91,13 +115,17 @@ private sealed class QueueHandler(params HttpResponseMessage[] responses) : Http private readonly Queue responses = new(responses); public int SendCount { get; private set; } + public Uri? RequestUri { get; private set; } + public string? RequestBody { get; private set; } - protected override Task SendAsync( + protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { SendCount++; - return Task.FromResult(responses.Dequeue()); + RequestUri = request.RequestUri; + RequestBody = await request.Content!.ReadAsStringAsync(cancellationToken); + return responses.Dequeue(); } } } diff --git a/src/Tests.Integration/AgentFramework/ChatMessageRouterTests.cs b/src/Tests.Integration/AgentFramework/ChatMessageRouterTests.cs index c11db48..49b121d 100644 --- a/src/Tests.Integration/AgentFramework/ChatMessageRouterTests.cs +++ b/src/Tests.Integration/AgentFramework/ChatMessageRouterTests.cs @@ -20,30 +20,51 @@ public async Task ResolveReplyAsyncDeterministicIntentSkipsAgentRuns() } [TestMethod] - public async Task ResolveReplyAsyncForcedToolReplyWithoutInvocationFallsThroughToOpenAgentTurn() + public async Task ResolveReplyAsyncGeneralMissUsesOneAutomaticToolTurn() { var router = ServiceProvider.GetRequiredService(); var reply = await router.ResolveReplyAsync(Guid.NewGuid(), "Can you help with my saved information?", CancellationToken.None); Assert.AreEqual("mock-response", reply); - Assert.AreEqual(2, agent.RunCount); - Assert.IsInstanceOfType(agent.RunOptions[0]); - Assert.IsNull(agent.RunOptions[1]); + Assert.AreEqual(1, agent.RunCount); + Assert.IsNull(agent.RunOptions[0]); } [TestMethod] - public async Task ResolveReplyAsyncForcedToolFailureFallsThroughToOpenAgentTurn() + public async Task ResolveReplyAsyncRequiredToolFailurePropagatesWithoutRetry() { agent.ThrowOnForcedToolRun = true; var router = ServiceProvider.GetRequiredService(); - var reply = await router.ResolveReplyAsync(Guid.NewGuid(), "Can you help with my saved information?", CancellationToken.None); - - Assert.AreEqual("mock-response", reply); - Assert.AreEqual(2, agent.RunCount); + await Assert.ThrowsExactlyAsync(() => + router.ResolveReplyAsync(Guid.NewGuid(), "Explain my chat history", CancellationToken.None)); + Assert.AreEqual(1, agent.RunCount); Assert.IsInstanceOfType(agent.RunOptions[0]); - Assert.IsNull(agent.RunOptions[1]); + Assert.AreEqual(0, chatAgent.RunCount); + } + + [TestMethod] + public async Task ResolveReplyAsyncRequiredToolWithoutInvocationFailsWithoutRetry() + { + var router = ServiceProvider.GetRequiredService(); + await Assert.ThrowsExactlyAsync(() => + router.ResolveReplyAsync(Guid.NewGuid(), "Explain my chat history", CancellationToken.None)); + + Assert.AreEqual(1, agent.RunCount); + Assert.AreEqual(0, chatAgent.RunCount); + } + + [TestMethod] + public async Task ResolveReplyAsyncRequiredToolCancellationPropagatesWithoutRetry() + { + agent.CancelOnForcedToolRun = true; + var router = ServiceProvider.GetRequiredService(); + await Assert.ThrowsExactlyAsync(() => + router.ResolveReplyAsync(Guid.NewGuid(), "Explain my chat history", CancellationToken.None)); + + Assert.AreEqual(1, agent.RunCount); + Assert.AreEqual(0, chatAgent.RunCount); } [TestMethod] @@ -58,28 +79,34 @@ public async Task ResolveReplyAsyncDirectModeSkipsTheFirstTwoTiers() ChatRoutingMode.Direct); Assert.AreEqual("mock-response", reply); - Assert.AreEqual(1, agent.RunCount); - Assert.IsNull(agent.RunOptions[0]); + Assert.AreEqual(0, agent.RunCount); + Assert.AreEqual(1, chatAgent.RunCount); + Assert.IsNull(chatAgent.RunOptions[0]); + Assert.IsTrue(chatAgent.LastMessages.Any(message => message.Text.Contains("text-only assistant", StringComparison.Ordinal))); } [TestMethod] public async Task ResolveReplyAsyncAnnouncedWebSearchIsRegeneratedWithoutFutureTense() { - agent.QueuedResponseTexts.Enqueue("forced-tool-no-call"); agent.QueuedResponseTexts.Enqueue("I will perform a web search to find the latest information on .NET."); - agent.QueuedResponseTexts.Enqueue("Web search is not available."); + chatAgent.QueuedResponseTexts.Enqueue("Web search is not available."); var router = ServiceProvider.GetRequiredService(); var reply = await router.ResolveReplyAsync(Guid.NewGuid(), "Tell me about the latest information on .NET", CancellationToken.None); Assert.AreEqual("Web search is not available.", reply); Assert.IsFalse(FutureTenseGuard.ContainsFutureTense(reply), $"Future tense reached the user: {reply}"); + Assert.AreEqual(1, agent.RunCount); + Assert.AreEqual(1, chatAgent.RunCount); + Assert.IsTrue(chatAgent.LastMessages.Any(message => message.Role == ChatRole.Assistant + && message.Text == "I will perform a web search to find the latest information on .NET.")); } [TestMethod] public async Task ResolveReplyAsyncPersistentFutureTenseNeverReachesTheUser() { agent.ResponseText = "I will perform a web search to find the latest information on .NET."; + chatAgent.ResponseText = agent.ResponseText; var router = ServiceProvider.GetRequiredService(); var reply = await router.ResolveReplyAsync(Guid.NewGuid(), "Tell me about the latest information on .NET", CancellationToken.None); diff --git a/src/Tests.Integration/AgentFramework/InferencePurposeTests.cs b/src/Tests.Integration/AgentFramework/InferencePurposeTests.cs new file mode 100644 index 0000000..035e578 --- /dev/null +++ b/src/Tests.Integration/AgentFramework/InferencePurposeTests.cs @@ -0,0 +1,147 @@ +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Net; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Governance; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Playbooks.Essay; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Playbooks.Taxonomy; +using Goodtocode.Agents.Governance.Application; +using Goodtocode.Agents.Governance.Domain; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Options; +using OpenAI; +using OpenAI.Chat; + +namespace Goodtocode.AgentFramework.Tests.Integration.AgentFramework; + +[TestClass] +public sealed class InferencePurposeTests +{ + [TestMethod] + public void ClientsResolveTheirExplicitPurposeDeployments() + { + using var provider = CreateServices().BuildServiceProvider(); + + foreach (var (purpose, model) in new[] + { + (InferencePurpose.Chat, "chat-fast"), + (InferencePurpose.Tool, "tool-standard"), + (InferencePurpose.ToolReasoning, "tool-reasoning") + }) + { + var client = provider.GetRequiredKeyedService(purpose).AsIChatClient(); + Assert.AreEqual(model, client.GetService()?.DefaultModelId); + } + Assert.AreSame(provider.GetRequiredKeyedService(InferencePurpose.Tool), provider.GetRequiredService()); + } + + [TestMethod] + [DataRow("AzureOpenAI:ToolDeploymentName", "")] + [DataRow("AzureOpenAI:ToolDeploymentName", "chat-fast")] + [DataRow("AzureOpenAI:ToolReasoningDeploymentName", "chat-fast")] + [DataRow("AzureOpenAI:ChatDeploymentName", "tool-standard")] + [DataRow("AzureOpenAI:EmbeddingDeploymentName", "tool-standard")] + public void MissingOrWrongPurposeConfigurationFailsValidation(string key, string value) + { + using var provider = CreateServices(key, value).BuildServiceProvider(); + Assert.ThrowsExactly(() => provider.GetRequiredService>().Value); + } + + [TestMethod] + public void PlaybookInferenceUsesReasoningAgent() + { + Type[] tools = + [ + typeof(TaxonomyCollectAgentTool), + typeof(TaxonomyEvaluateAgentTool), + typeof(TaxonomyRecordAgentTool), + typeof(EssayEvaluateAgentTool) + ]; + foreach (var tool in tools) + { + var agentParameter = tool.GetConstructors().Single().GetParameters().Single(parameter => parameter.ParameterType == typeof(AIAgent)); + Assert.AreEqual(InferencePurpose.ToolReasoning, agentParameter.GetCustomAttribute()?.Key); + } + } + + [TestMethod] + [DataRow(InferencePurpose.Chat, false)] + [DataRow(InferencePurpose.Tool, true)] + [DataRow(InferencePurpose.ToolReasoning, false)] + public async Task OnlyInteractiveAgentAdvertisesApplicationFunctions(InferencePurpose purpose, bool expectsTools) + { + var handler = new RecordingHandler(); + using var http = new HttpClient(handler); + var services = CreateServices(); + services.AddKeyedSingleton(purpose, (_, _) => + new OpenAIClient(new ApiKeyCredential("synthetic-test-key"), new OpenAIClientOptions + { + Endpoint = new Uri("https://example.test/openai/v1/"), + Transport = new HttpClientPipelineTransport(http) + }).GetChatClient("synthetic-deployment")); + using var provider = services.BuildServiceProvider(); + using var governed = provider.GetRequiredService().Begin( + Guid.NewGuid(), AgentInstruction.Create("Reply with OK.", InstructionScope.Session)); + + await provider.GetRequiredKeyedService(purpose).RunAsync("Reply with OK.", cancellationToken: CancellationToken.None); + + Assert.AreEqual(expectsTools, handler.FunctionNames.Count > 0); + if (expectsTools) + { + Assert.IsTrue(handler.FunctionNames.All(name => name.Length <= 64)); + CollectionAssert.Contains(handler.FunctionNames, "MyChatSessionsTool_ListRecentSessionsAsync"); + CollectionAssert.Contains(handler.FunctionNames, "MyChatMessagesTool_ListRecentMessagesAsync"); + Assert.AreEqual(handler.FunctionNames.Count, handler.FunctionNames.Distinct().Count()); + } + } + + private static IServiceCollection CreateServices(string? key = null, string? value = null) + { + var values = new Dictionary + { + ["AgentProvider:Kind"] = "AzureOpenAI", + ["AzureOpenAI:ChatDeploymentName"] = "chat-fast", + ["AzureOpenAI:ToolDeploymentName"] = "tool-standard", + ["AzureOpenAI:ToolReasoningDeploymentName"] = "tool-reasoning", + ["AzureOpenAI:ApiKey"] = "synthetic-test-key", + ["AzureOpenAI:Endpoint"] = "https://example.test/openai/v1/", + ["SearchProvider:Provider"] = "None" + }; + if (key is not null) + { + values[key] = value; + } + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAgentFrameworkOpenAIServices(new ConfigurationBuilder().AddInMemoryCollection(values).Build()); + return services; + } + + private sealed class RecordingHandler : HttpMessageHandler + { + public List FunctionNames { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)); + if (body.RootElement.TryGetProperty("tools", out var tools)) + { + foreach (var tool in tools.EnumerateArray()) + { + FunctionNames.Add(tool.GetProperty("function").GetProperty("name").GetString()!); + } + } + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """{"id":"synthetic-completion","object":"chat.completion","created":0,"model":"synthetic-model","choices":[{"index":0,"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}""", + Encoding.UTF8, "application/json") + }; + } + } +} diff --git a/src/Tests.Integration/Mocks/MockAIAgent.cs b/src/Tests.Integration/Mocks/MockAIAgent.cs index bbe3074..57d7724 100644 --- a/src/Tests.Integration/Mocks/MockAIAgent.cs +++ b/src/Tests.Integration/Mocks/MockAIAgent.cs @@ -10,6 +10,7 @@ public class MockAIAgent : AIAgent public IReadOnlyList RunOptions { get; private set; } = []; public int RunCount { get; private set; } public bool ThrowOnForcedToolRun { get; set; } + public bool CancelOnForcedToolRun { get; set; } public string ResponseText { get; set; } = "mock-response"; public Queue QueuedResponseTexts { get; } = new(); @@ -30,6 +31,10 @@ protected override Task RunCoreAsync(IEnumerable mes LastMessages = [.. messages]; RunOptions = [.. RunOptions, options]; RunCount++; + if (CancelOnForcedToolRun && options is ChatClientAgentRunOptions) + { + throw new OperationCanceledException("Required-tool inference was cancelled."); + } if (ThrowOnForcedToolRun && options is ChatClientAgentRunOptions) { throw new InvalidOperationException("Forced-tool inference failed."); diff --git a/src/Tests.Integration/TestBase.cs b/src/Tests.Integration/TestBase.cs index 29eaa67..01ec05c 100644 --- a/src/Tests.Integration/TestBase.cs +++ b/src/Tests.Integration/TestBase.cs @@ -50,6 +50,7 @@ public enum CommandResponseType internal IRlsContext rlsContext; internal MockAIAgent agent = new(); + internal MockAIAgent chatAgent = new(); internal OpenAIOptions optionsOpenAi = new(); internal FakeSqlDatabaseStatisticsProvider sqlDatabaseStatisticsProvider = new(); @@ -75,6 +76,8 @@ public TestBase() services.AddSingleton(provider => UserContext.Create(provider.GetRequiredService())); services.AddSingleton(provider => agent); + services.AddKeyedSingleton(InferencePurpose.Chat, (provider, key) => chatAgent); + services.AddKeyedSingleton(InferencePurpose.ToolReasoning, (provider, key) => agent); services.AddTransient(typeof(IPipelineBehavior<>), typeof(TestInjectUserContextBehavior<>)); services.AddTransient(typeof(IPipelineBehavior<,>),