Version 2.0.0 Release Notes
Package version: 2.0.0
CrestApps.Core 2.0.0 is not yet released and is currently in development. Nightly
and preview builds are published from main under the 2.0.0 version prefix.
Breaking Changes
-
The ingestion path moves into
CrestApps.Core.AI.Ingestion, so document processing and file sources no longer depend on each other.CrestApps.Core.AI.FileSourcesreferencedCrestApps.Core.AI.Documentsfor one reason — the knowledge ingestion contract — but the only way to register it wasAddCoreAIDocumentProcessing(), a single method that also registered chat uploads, tabular workspaces, generated file writers and twelve chat tools. A host that wanted to read a folder into a knowledge base had to take all of it. The readers, the processor pipeline, the file store andIKnowledgeIngestionServicenow live in their own package that both sides depend on, and neither depends on the other.- what you have to change: six types that shipped in 1.0.0 move to the
CrestApps.Core.AI.Ingestionnamespace —IDocumentFileStore,FileSystemFileStore,DocumentFileSystemFileStoreOptions,IImageAnalysisService,ChatDocumentsOptionsandExtractorExtension. Update theusingand nothing else; the types themselves are unchanged AddCoreAIDocumentIngestion()registers only what cannot be left out — the pipeline, the file store and the knowledge service. Readers and processors are asked for through a builder, so a host ingesting plain text no longer carries a vision model or a background job it never wanted:AddCoreAIDocumentIngestion(i => i.AddPlainTextReader().AddFigureProcessing().AddFigureBackfill()).AddCoreAIDocumentProcessing()andAddCoreFileSources()both opt into all three, so a host calling either behaves exactly as beforeAddCoreFileSources()now registers the ingestion path itself. A file source host needs nothing fromCrestApps.Core.AI.Documents. A host that wants chat document processing as well still callsAddDocumentProcessing()before it, because keyed readers resolve to the last registration and the HTML reader has to win.htmlover the plain-text one- the Files data source entry moves to
AddCoreFileSources(), matching how the web crawlers package already registers its ownWebentry. Its description points at the File Sources screens, so it is only registered where those screens exist. The read side,FileAIDataSourceSourceHandler, stays in ingestion, because core retrieval and the knowledge tools resolve it to read knowledge objects back - reading a PDF is ingestion; writing one is a generated file. The PdfPig-based reader moves out of
CrestApps.Core.AI.Documents.PdfintoCrestApps.Core.AI.Ingestion.Pdf, which depends only on the ingestion package, so reading PDFs into a knowledge base no longer pulls in the chat and tabular stack. The PDFsharp/MigraDoc writer stays put, andAddCoreAIPdfDocumentProcessing()still registers both halves — a host that generates PDFs is unaffected.AddPdf()on the ingestion builder registers the reader alone - Office documents are the exception.
.docx,.xlsxand.pptxare read byCrestApps.Core.AI.Documents.OpenXml, which is mostly tabular workspaces and the spreadsheet and Word writers with the reader as one file of nine. It was left whole rather than split against that grain, so a host that ingests Office documents still registers document processing - Stored data needs no migration. Knowledge objects, data sources and file source records are untouched; only assemblies and namespaces moved
- what you have to change: six types that shipped in 1.0.0 move to the
-
CrestApps.Core.AI.Indexersis renamed toCrestApps.Core.AI.FileSources. The screens in both sample hosts have said File Sources since this release; the package still said Indexers, so the same thing had two names depending on where you looked. The package and namespace now match the concept, and everything that carried the old word moved with them:AddCoreFileSources(),FileSourceOptions,IFileSourceRunService,FileSourceRunStatus,FileSourceVisionDeploymentResolver,FileSourceSettingsCatalogHandler, and the stored shapesFileSourceMetadata,FileSystemFileSourceMetadata,RemoteFileSourceMetadataandFileSourceRunSummary.- the configuration section follows the package, from
CrestApps:IndexerstoCrestApps:AI:FileSources, which also puts it beside the other AI sections rather than at the root. A configuration key is read from places this repository cannot see — user secrets, environment variables, a deployedappsettings.json— and renaming one breaks a working host silently rather than at compile time, so the old section is still read. Bind through the new overload,AddCoreFileSources(builder.Configuration), which readsCrestApps:AI:FileSourcesand falls back toCrestApps:Indexerswhen the new section is absent. One section wins outright rather than the two being layered:AllowedLocalRootsis a list, so binding both would merge them by index and a host that had shortened its list would keep entries it thought it had removed - a file source's connector is stored as the record's source, the string
FileSystem,FtporSftp
- the configuration section follows the package, from
-
The FTP and SFTP packages are consolidated, one per protocol.
CrestApps.Core.AI.Mcp.FtpandCrestApps.Core.AI.Mcp.Sftpare replaced byCrestApps.Core.AI.FtpandCrestApps.Core.AI.Sftp, which now carry both halves of a protocol: the ingestion connector a file source reads with, and the MCP resource type a model reads through. The split was not along a real seam. Both halves read the same stored connection,FtpConnectionMetadatalived in the MCP package, and the ingestion package had to depend on the MCP one to reach it — so taking FTP ingestion already pulled in the MCP package.- the namespaces follow the packages:
CrestApps.Core.AI.Mcp.Ftp[.Models|.Handlers]becomesCrestApps.Core.AI.Ftp[.Models|.Handlers], and the ingestion connectors move offCrestApps.Core.AI.FileSources.FileTransfer.FtpontoCrestApps.Core.AI.Ftp. The sharedCrestApps.Core.AI.FileSources.FileTransferbase (IRemoteFileClient,RemoteFileIngestionConnector) is unchanged and stays inCrestApps.Core.AI.FileSources - every registration method keeps its name and behaviour:
AddCoreFtpIngestionConnector,AddCoreAIFtpMcpResources,AddFtpResources, and the SFTP equivalents. Registering one side does not start the other — taking the FTP file source does not stand up an MCP server - Stored data needs no migration. Connection settings are stored under the short type name
(
FtpConnectionMetadata,SftpConnectionMetadata), which is unchanged, and the connector names written onto a file source are still the stringsFtpandSftp
- the namespaces follow the packages:
-
AIDeploymentPurposeis removed. A deployment declares what its model can do (capabilities); where it gets used is a slot resolved from site settings and profile configuration. See Deployment capabilities and slots for the full change and the upgrade path.- removed:
AIDeploymentPurpose,AIDeploymentPurposeExtensions,AIDeploymentType,AIDeploymentTypeExtensions,AIDeployment.Purpose,AIDeployment.Type,AIDeployment.SupportsPurpose,AIDeployment.SupportsType, andAIDeployment.CanServeTextCompletion() - removed from
IAIDeploymentManager:GetByPurposeAsync,GetAllByPurposeAsync, the purpose and type overloads ofResolveOrDefaultAsyncandGetDefaultAsync,GetByTypeAsync, andGetAllByTypeAsync. UseResolveSlotAsyncandGetAllBySlotAsync AIDeploymentManagerExtensions.ResolveAsync(purpose, …)becomesResolveSlotOrThrowAsync(slotName, …)AIDeploymentConfigurationEntry.Purpose/.TypebecomeLegacyPurposes(string[])AICompletionServiceBase.ResolveDeploymentAsynctakes a slot name instead of a purpose- Stored data needs no migration. A record that still carries
Purpose,Capability, orTypeis projected onto capabilities every time it is read
- removed:
-
ChatMode.RealtimeandAIProfile.RealtimeDeploymentNameare removed. Whether a profile or interaction is a speech-to-speech conversation now follows from its chat deployment declaring therealtimecapability, rather than from a second stored answer that could disagree with the deployment actually selected. This is how Chat Interactions already worked; AI Profiles now match.- the chat deployment picker lists text-capable and realtime-capable deployments
(
GetConversationalDeploymentsAsync); the utility picker stays text-only - the chat mode selector appears only when the selected chat deployment is not realtime, and the voice selector switches to that deployment's realtime voices when it is
- removed:
ChatMode.Realtime,AIProfile.RealtimeDeploymentName, and the separate "Realtime deployment" field in all four editors.ProfileTemplateMetadata.RealtimeDeploymentNameis[Obsolete]and still applies, supplying the chat deployment - a site can no longer force realtime globally through
ChatMode. A voice-first interaction selects a realtime deployment;DefaultAIDeploymentSettings.DefaultRealtimeDeploymentNamestill backs therealtimeslot for callers that resolve it directly - adds
IAIDeploymentCapabilityService.IsRealtimeDeploymentAsync, the single check every surface uses - Stored data needs no migration. A profile that named a
RealtimeDeploymentNamefolds it ontoChatDeploymentNamewhen read, and its old chat deployment moves toUtilityDeploymentNamewhen the profile has not named one — so background work keeps running on the model it always did. A storedChatModeof"Realtime"reads asTextInputinstead of failing the settings load
- the chat deployment picker lists text-capable and realtime-capable deployments
(
-
Tool authorization now depends on how the AI is invoked, and
IAIToolAccessEvaluatoris applied in a narrower, more correct place:- AI Sessions no longer apply a per-user tool gate. Previously
FunctionInvocationAICompletionServiceHandlercalledIAIToolAccessEvaluator.IsAuthorizedAsyncfor every scoped tool and excluded the ones the caller was not individually authorized for. Because every tool reaching the handler is already scoped to the AI Profile's configuration (profile-selected tools, operator-attached MCP connections, and context-driven system tools), that gate stripped out capabilities the profile author deliberately included — and it is wrong for sessions, which may be anonymous and are meant to run the profile exactly as configured. The AI Profile is now the authorization boundary for a session. - Chat Interactions now re-verify tool access at send time. A Chat Interaction persists its
own selected tool names, which are attacker-controllable, so the completion pipeline checks each
listable (user-selectable) tool against
IAIToolAccessEvaluatorand excludes any the caller is not authorized for (reported in a singleWarning). System tools and hidden/dependency tools are never checked; anullcaller (trusted server-side invocation) skips the check. IAIToolAccessEvaluatorand its default (allow-all) implementation remain supported (they are not obsolete). Hosts that enforce per-user tool permissions should keep their custom evaluator; it now governs Chat Interaction sends rather than every session completion.
- AI Sessions no longer apply a per-user tool gate. Previously
Cascaded realtime deployments
Realtime voice no longer requires a provider that ships a speech-to-speech model. A deployment carrying
CascadedRealtimeMetadata chains a realtime speech-to-text deployment, a chat deployment, and a
text-to-speech deployment into a single IRealtimeClient, and may mix vendors across the three. See
Realtime Voice for the guide.
- adds
CascadedRealtimeMetadata, stored on anAIDeploymentto name the three deployments to chain. - adds
CascadedRealtimeClient, composed byIAIClientFactory.CreateRealtimeClientAsyncwhen a deployment carries that metadata. A client provider only ever sees one connection, so the cascade is assembled by the factory rather than by a provider. - the chat leg is built with function invocation applied, so tools, data sources, and the profile's system message keep working exactly as they do for a text profile.
- the reply is spoken a sentence at a time so audio begins playing before the model finishes writing, and a partial transcript arriving mid-reply cancels the turn and signals the client to flush buffered audio.
DefaultRealtimeVoiceResolvernow lists the text-to-speech leg's voices for a cascaded deployment, since that is the deployment that actually speaks.DefaultRealtimeVoiceResolvertakes an additionalIAIDeploymentStoreconstructor argument to reach that leg. Hosts that resolve it from the container are unaffected.- the text-to-speech leg must answer with raw PCM (
audio/L16oraudio/pcm) at the session's output rate. Realtime audio is played as headerless samples, so an MP3 or WAV reply would have been played as noise with nothing reporting a problem; the session now refuses it and names the format it was given. - the speech-to-text leg transcribes with its own deployment's model. Previously the model resolved for a native realtime session was forwarded to it, which named a model of a different provider entirely.
AzureSpeechServiceTextToSpeechClienthonorsTextToSpeechOptions.AudioFormatinstead of always synthesizing 16 kHz MP3, and reports the matching media type. Raw PCM, RIFF/WAV, and MP3 are selectable by name (Pcm24000,Riff16000,Mp3, and so on), as are the Speech SDK's own format names; MP3 remains the default. This is what lets Azure Speech serve as the speaking leg of a cascaded realtime deployment.ai-chat.jsnow hands the realtime controller its message box, send button, and microphone button, so they are hidden while a realtime session is running. Realtime is audio-only, but the AI chat session had gone on showing a text field that played no part in the conversation. The chat interaction client already did this; the chat client passed only the button.- the realtime orchestrator no longer substitutes its OpenAI fallback voice (
alloy) when a cascaded deployment has no voice selected. That name is meaningless to another vendor's speech model and would be rejected, so the voice is left unset and the speaking provider applies its own configured default.
Metadata-driven model features and parameters
AI deployments can now describe what their model supports instead of relying on hardcoded, provider-specific options. See Model Capabilities for the full guide.
- adds a startup registry of model features (binary capabilities such as
toolCalling,reasoning, andstreaming) and model parameters (configurable options carrying kind, allowed values, ranges, and defaults), registered through the newAddAIModelFeatureandAddAIModelParameterservice-collection extensions - adds
AddCoreAIModelCapabilities(), chained automatically byAddCoreAIServices(), which registers the capability service, the completion handlers, the reasoning-effort binder, eleven built-in trained features (toolCalling,structuredOutputs,streaming,reasoning,imageInput,imageOutput,audioInput,audioOutput,videoInput,videoOutput, andrealtime), and the built-inreasoningEffortparameter. Features model genuine trained model capabilities; provider-hosted tools such as web search or computer use are intentionally not registered as features - models realtime (speech-to-speech) as the
realtimecapability. A realtime deployment is one whose model declares therealtimefeature, and it fills therealtimeslot.IRealtimeCapabilityResolverresolves the deployment to use for a realtime session (an explicit deployment name, then the site-configured default realtime deployment, then the first realtime-capable chat deployment) and only returns one whose model declares the capability, so the deployment editors, profile and settings realtime selectors list exactly the chat deployments that support realtime - adds
AIModelFeatureDescriptor.EnabledByDefaultso a registered feature can be pre-selected on newly created deployments, and flagstoolCallingandstreamingas enabled by default - adds
AIModelParameterDescriptor.RequiredFeatureso a parameter can declare a dependency on a trained feature. The built-inreasoningEffortparameter now requires thereasoningfeature.IAIModelCapabilityService.GetCapabilitiesexcludes a parameter whose required feature is not declared by the deployment, so the enforcement handlers never apply it regardless of how the metadata was authored. The deployment editors render a dependent parameter inline beneath its feature, only show it while the feature is enabled, and clear it when the feature is turned off, and the Model parameters heading only appears for parameters that are not linked to a feature - adds
ModelFeaturesAICompletionServiceHandler, registered after the tool-adding handlers, which for deployments that declare capability metadata removes tools andToolModewhentoolCallingis not declared, removes a JSON response format whenstructuredOutputsis not declared, removes reasoning options whenreasoningis not declared, and whenreasoningis declared removes the reasoning effort if thereasoningEffortparameter is not exposed or coerces an unsupported effort to the deployment default. Deployments without metadata stay unconstrained - adds a
CapabilityEnforcingChatClientthatIAIClientFactorywraps as the terminal layer immediately above the provider-facing client (below any pipeline middleware), so the same trained-feature enforcement runs even when middleware adds unsupported options and when a caller resolves anIChatClientand calls it directly outside the completion pipeline. The wrapper clones the request options before removing unsupported tools, tool modes, JSON response formats, and reasoning options, which prevents avoidable provider validation errors (for example HTTP 400) on the direct client path - enforces the
streamingfeature at the call site: when a deployment does not declarestreaming, a streaming request is transparently completed as a single non-streaming response and replayed as one streaming update. This applies to bothCapabilityEnforcingChatClient(the client-factory path) andAzureOpenAICompletionClient, which streams through the Azure SDK directly - clears
ChatOptions.ToolModewhenevertoolCallingis not declared, even when no tools were supplied, so an unsupported deployment never receives a tool mode on its own - converts model parameter values written to
ChatOptions.AdditionalPropertiesto their underlying primitive (Integer,Number, orBoolean) using invariant parsing instead of always sending them as strings, rejects non-finite numbers during validation, and skips values that cannot be represented as the declared type instead of sending them as a mismatched string - adds
AIDeploymentModelMetadataso a deployment declares the features and parameters its model exposes and can narrow the allowed values, default, or numeric bounds of a registered parameter. The metadata flows through configuration and recipes with no additional code because deployment properties are already deep-merged - adds
IAIModelCapabilityService, which merges the registered definitions with the deployment metadata and returns only the capabilities a deployment actually exposes - adds
AIModelParametersMetadataso AI profiles, AI profile templates, and chat interactions store the selected parameter values, and addsAICompletionContext.ModelParametersplusApplyModelParametersto carry them into a request - adds
ModelParametersAICompletionServiceHandler, the single runtime enforcement point that ignores values for parameters the deployment does not expose, falls back to the deployment default when a stored value is missing or invalid, dispatches to a matchingIAIModelParameterBinder, and otherwise writes the value toChatOptions.AdditionalProperties - adds
IAIModelParameterBinderwith a built-in reasoning-effort binder that setsChatOptions.Reasoning.Effort, and updatesAzureOpenAICompletionClientto translate the resolved effort ontoChatCompletionOptions.ReasoningEffortLevelso both request paths behave the same. TheExtraHigheffort is clamped to the highest level the Azure/OpenAI SDK exposes (High) and the downgrade is logged rather than applied silently - validates the per-deployment parameter metadata when a deployment is saved from the MVC editor, so an incoherent default (outside the allowed set or numeric range), an unregistered allowed value, or a minimum greater than a maximum is reported inline instead of being silently corrected at request time
- surfaces a warning when a binder-less model parameter would be applied on the
AzureOpenAICompletionClientpath: because that client projects a fixed set of options onto the Azure SDK'sChatCompletionOptions, a custom parameter written only toChatOptions.AdditionalPropertiescannot be forwarded and is logged rather than dropped silently. The built-inreasoningEffortparameter is unaffected because it binds throughChatOptions.Reasoning.Effort - adds a
ModelParametersfront-matter key to the markdown profile template parser, acceptingname=valuepairs separated by;or by a new line - adds
CompletionServiceConfigureContext.Deploymentso handlers can resolve the deployment that is being configured.DeploymentNamecontinues to carry the model name - updates
CrestApps.Core.Mvc.Webwith a reusable deployment capability editor and a metadata-driven model parameter editor wired into the AI deployment, AI profile, AI profile template, and chat interaction screens. Unsupported fields are hidden and disabled so they are never submitted. The deployment editor groups features under a Trained features heading, sorts the trained features alphabetically, pre-checks the default features on new deployments, and renders the allowed-values selectors with the@crestapps/bootstrap-selectsearchable multi-select. The AI profile, profile template, and chat interaction editors render only the editable parameters the selected deployment declares — no read-only capability badges and no "nothing to configure" notice — and the whole block, heading included, is hidden when the deployment exposes none - updates
CrestApps.Core.Blazor.Webwith the equivalentModelCapabilitiesEditorandModelParametersEditorcomponents, which re-render when the selected deployment changes and prune values the new deployment does not support. The capabilities editor uses the Trained features heading, sorts the trained features alphabetically, and pre-selects the default features on new deployments, and the parameters editor renders only the editable parameters, hiding itself entirely when the selected deployment declares none. The allowed-values selectors use the same@crestapps/bootstrap-selectsearchable multi-select as the MVC editor through an isolatedBootstrapMultiSelectcomponent and JS interop module, so both hosts present the same editing experience
Deployment capabilities and slots
AIDeploymentPurpose is replaced by two separate ideas: a capability says what the model can do, and
a slot says what this installation uses a deployment for. The enum itself is gone; stored records
that still carry it are projected onto capabilities at read time.
- adds three model features to
AIDeploymentFeatureNamesand toAddCoreAIDeploymentCapabilities():textEmbedding,speechToText, andtextToSpeech. These are deliberately distinct fromaudioInputandaudioOutput, which mean "this chat model accepts or emits audio inline" rather than "this is a transcription or synthesis endpoint" — conflating them would offergpt-4o-audioin the Whisper slot and Whisper in the chat picker - adds a deployment slot registry:
AIDeploymentSlotNames,AIDeploymentSlotDescriptor,AIDeploymentSlotOptions, and theAddAIDeploymentSlotservice-collection extension. A slot pairs the capability a deployment must declare with the site-wide default that fills it and an optional fallback slot.AddCoreAIDeploymentSlots()(chained byAddCoreAIServices()) registerschat,utility,embedding,image,vision,speechToText,textToSpeech, andrealtime - adds
IAIDeploymentManager.ResolveSlotAsyncandGetAllBySlotAsync. Deployment resolution now filters on the slot's required capability instead of the purpose flag, and realtime finally joins the same registry — the parallel resolution thatDefaultRealtimeOrchestrator,AIChatHubCore, andChatInteractionHubBaseeach re-implemented is gone ResolveUtilityOrDefaultAsyncandAICompletionServiceBase.ResolveRequestDeploymentAsyncare now a single ordered chain — explicit utility name, site default utility deployment, explicit chat name, site default chat deployment, and only then the first text-capable deployment. They were previously two independent resolves joined with??, which relied on the utility resolve returningnull. Now that both slots filter on the sametextGenerationcapability, an independent utility resolve would answer with an arbitrary text model and the caller's own chat deployment would never be consulted, silently routing summarization, data extraction, and query rewriting to the wrong modelDefaultImageAnalysisServicenow tests theimageInputcapability rather than the vision purpose flag, so a genuinely vision-capable model is no longer rejected merely because nobody ticked the flagConfigurationAIDeploymentSourceemits capabilities directly for the deployments it synthesizes from connection configuration, since those are read-only in the UI and an operator cannot declare them by hand. It also gained a text-to-speech case (TextToSpeechDeploymentName/DefaultTextToSpeechDeploymentName), which it previously had no handling for at all
Editors and pickers
The deployment editors no longer ask for a purpose. Model capabilities are now the only thing an operator declares about a deployment, in both the MVC and Blazor admin UIs.
- removes the "Deployment purposes" checkbox group from all four deployment editors. The Model capabilities editor is the single place a deployment describes itself
- the deployment list replaces its Purposes column with Capabilities, showing the declared features
- "At least one deployment purpose is required" becomes "At least one model capability is required", in
the editors and in
AIDeploymentCatalogHandler.ValidatingAsync. A payload that carries only a legacy purpose still passes, because the read-time projection runs first - the settings deployment pickers resolve through slots (
GetAllBySlotAsync) rather than purpose flags, so each picker lists exactly the deployments capable of filling that slot. The realtime picker joins the same mechanism instead of querying the capability service directly - the editors no longer write a deployment purpose at all — the field is gone. A record that still carries one is read through the compatibility projection described under Upgrading
- removes
AIDeployment.CanServeTextCompletion()and the three call sites that re-checked it defensively. The chat and utility slots now declarerealtimeas an excluded feature, so a realtime deployment cannot fill a text slot even when an operator also ticks text generation for it — the rule lives in the slot registry instead of being repeated at each call site
Upgrading
No action is required. The legacy purpose is projected onto capabilities at read time, in the
framework, on both paths a deployment can arrive by — the store deserialization path
(AIDeployment.OnDeserialized) and the recipe, configuration, and API path
(AIDeploymentCatalogHandler). This is the same legacy-shape normalization the framework already
performs for this field, not a data migration, so a host that never rewrites its stored JSON stays
correct.
The projection is additive and conditional:
Embedding,Image,Vision,SpeechToText, andTextToSpeechaddtextEmbedding,imageOutput,imageInput,speechToText, andtextToSpeechrespectively, merging into whatever capability metadata the deployment already declaresChat(orUtility) addstextGenerationonly when the deployment does not declarerealtime. A speech-to-speech-only deployment isPurpose.Chatwith therealtimefeature and deliberately notextGeneration; re-adding it would route text completions to a model that answers them with an HTTP 400
This matters because textGeneration is opt-out — a deployment declaring no metadata at all is assumed
text-capable — while the five features above are opt-in. Without the projection, an existing embedding,
transcription, or image deployment would simultaneously vanish from its own picker and appear in the chat
picker, and embedding resolution would return null, breaking document indexing.
Rewriting stored deployment JSON to declare capabilities directly (for example through an OrchardCore
DataMigration) is optional cleanup, not a correctness requirement. The read-time projection is
permanent — it is the upgrade path, and it outlives the enum it translates.
Only two things need attention when upgrading:
- Code that referenced the removed API — the compiler finds all of it. The mapping is mechanical:
ResolveOrDefaultAsync(AIDeploymentPurpose.X, …)becomesResolveSlotAsync(AIDeploymentSlotNames.X, …), andGetByPurposeAsync/GetAllByPurposeAsyncbecomeGetAllBySlotAsync. - A host that persists the purpose in its own tables, rather than inside the deployment JSON. The read-time projection does not reach a separate column, so such a host needs its own migration — or should simply stop reading that column, since it is no longer written.
Model parameters for the utility deployment
A profile, profile template, or chat interaction selects a chat deployment and a utility
deployment, but only the chat deployment's model parameters (such as reasoningEffort) could be
configured. The utility deployment — which backs title generation, orchestration planning, data
extraction, and post-session processing — silently ran with provider defaults even when its model
declared reasoning. Both deployments are now configurable, and both are honored at runtime.
- adds
AIDeploymentParametersMetadata.UtilityValues, which stores the values selected for the utility deployment alongside the existingValuesfor the chat deployment. Existing stored metadata keeps working unchanged; the new dictionary is simply empty - adds
AICompletionContext.UtilityModelParametersandAICompletionContext.IsUtilityCompletion. The profile and chat interaction context builder handlers copy both value sets onto the context, and a caller marks a request as a background utility completion withIsUtilityCompletion - resolves the utility deployment (falling back to the chat deployment) in
NamedAICompletionClientwhenIsUtilityCompletionis set, so a utility completion runs on the deployment it was configured for. Chat title generation now sets this flag - extracts the parameter-binding loop out of
ModelParametersAICompletionServiceHandlerinto the newIAIDeploymentParameterApplier/DefaultAIDeploymentParameterApplier, which takes anAIDeploymentParameterScope(ChatorUtility) to select which stored values to read. The handler is now a thin wrapper that picks the scope from the completion context - adds
ChatClientBuilder.UseModelParametersandUseUtilityModelParametersso background completions that resolve anIChatClientdirectly fromIAIClientFactory— orchestration planning, data extraction, and post-session processing — apply the same operator selections as the completion pipeline - adds a
UtilityModelParametersfront-matter key to the markdown profile template parser, accepting the samename=valuepairs asModelParameters - renders a second Utility model parameters editor on the AI profile, AI profile template, and chat
interaction screens in both
CrestApps.Core.Mvc.WebandCrestApps.Core.Blazor.Web, bound to the selected utility deployment. Each editor shows only the parameters its own deployment declares, so a reasoning-capable utility model exposes its reasoning effort selector even when the chat model does not (and the reverse). Each editor now owns its own heading and hides itself completely — heading included — when the deployment it is bound to declares no configurable parameter - adds a Utility deployment selector to the chat interaction editor in both hosts, which previously offered only the chat deployment
- fixes the
CrestApps.Core.Blazor.Webeditors passing the literal string"_model.ChatDeploymentName"toModelParametersEditor.DeploymentName. Razor treats an unprefixed attribute value as a literal for astringparameter, so the component never resolved a deployment and rendered no parameters at all. The pages now pass@_model.ChatDeploymentName(and@_model.UtilityDeploymentName)
Realtime (speech-to-speech)
- New realtime client capability.
IAIClientFactorygainsCreateRealtimeClientAsync(AIDeployment), which returns aMicrosoft.Extensions.AIIRealtimeClientfor low-latency, bidirectional audio conversations — speech in, speech out — without the separate speech-to-text and text-to-speech steps (and their added latency and failure points). Providers expose it through the newIAIClientProvider.GetRealtimeClientAsync(connection, deploymentName); providers without a realtime API surfaceNotSupportedException.- OpenAI is implemented via the packaged
OpenAIRealtimeClientfromMicrosoft.Extensions.AI.OpenAI. - Azure OpenAI connects directly to the GA realtime WebSocket endpoint (
/openai/v1/realtime, the GAsession.updateschema, and API-key or Microsoft Entra ID authentication) through a self-contained, deletable transport (CrestApps.Core.AI.OpenAI.Azure/Realtime). This custom transport exists only because the pinnedAzure.AI.OpenAItargets an olderOpenAISDK thanMicrosoft.Extensions.AI.OpenAIrequires, so the SDK'sAzureOpenAIClient.GetRealtimeClient()throwsMissingMethodExceptionat runtime; it will be replaced by the SDK path once a compatibleAzure.AI.OpenAIships.
- OpenAI is implemented via the packaged
- Realtime is a model capability. A realtime deployment is one whose model declares the
realtimefeature, and a chat profile or interaction becomes a voice conversation by selecting that deployment as its chat deployment. There is no realtime chat mode, no separate realtime deployment field, and no dedicated realtime profile type. - Realtime voices.
IAIClientProvider.GetRealtimeVoicesAsync(connection, deploymentName)returns the provider's supported realtime voices asSpeechVoice[](mirroringGetSpeechVoicesAsync), resolved through the newIRealtimeVoiceResolver(registered by default, mirroringISpeechVoiceResolver). OpenAI and Azure OpenAI return the fixedgpt-realtimevoice set (alloy,ash,ballad,cedar,coral,echo,marin,sage,shimmer,verse); the realtime API has no enumeration endpoint, so the set is declared inOpenAIRealtimeVoicesand carries a best-effort (unofficial) gender to help group a voice selector. - Realtime testing surfaces. Realtime sessions are exercised through Chat Interactions, AI Profile chat sessions, and the admin chat widget in both sample hosts; the temporary standalone realtime test page and raw WebSocket bridge are not part of the product UI.
- Orchestrated realtime sessions. A realtime profile runs its voice turns through the shared
orchestration pipeline:
IRealtimeOrchestratorbuilds the system prompt, materializes the profile's tools, and adds retrieval-augmented search guidance, so a realtime session honors tools and data sources like a chat profile while text turns continue to use the profile's orchestrator. - Profile voice parity across chat surfaces. AI Profile chat sessions and the MVC/Blazor admin chat widgets now pass the profile's configured realtime voice through the shared realtime client, and profile sessions use the resolved realtime deployment consistently when the site default is selected.
Realtime reliability and audio quality
A review of the realtime client end to end (WebRTC primary, WebSocket fallback, barge-in on and off) turned up a set of problems that made voice unreliable outside a LAN and with anything other than a headset. The fixes:
-
The idle timeout no longer ends a session while anyone is still talking or listening. The watchdog measured silence from the last audio the provider sent, which is the same thing as the last audio the listener heard only when a deployment produces speech at roughly real time. A cascaded deployment does not: it synthesizes a whole reply in moments and leaves most of it queued, so a long answer was cut off mid-sentence one idle window after the provider fell quiet — while the user was still listening. When the window expires the watchdog now checks what is still waiting to be played, waits that out, and starts the window again from there, so the silence is measured from when the reply actually finished. The WebSocket transport reports that too: forwarding a chunk does not play it, it moves the queue into the browser, which plays it at real time. A queue that has not moved at all by the time it has been waited out ends the session rather than holding it open on a loop.
-
A long utterance is no longer cut off mid-sentence either. Speech-started and the commit at the end of an utterance are the only events an utterance raises, and nothing arrives in between, so anyone whose answer ran longer than the idle window was hung up on while they were still speaking. An utterance the provider has not committed yet now holds the timeout off, bounded at two minutes so a dropped turn cannot keep a billed session open.
-
Configured TURN servers now actually reach the browser. Both realtime hubs gain
GetRealtimeIceServers(), and the client calls it immediately before creating its peer. Previously the browser was always built with a hardcoded public STUN server, so every user behind a symmetric NAT or blocked UDP fell back to WebSocket after an 8-second wait no matter how TURN was configured. Fetching per session also means ephemeral TURN credentials are minted fresh rather than baked into the page at render time. -
A voice session no longer blocks every other hub call from the same browser. A realtime session keeps its hub invocation open for the whole conversation, and SignalR's default of one parallel invocation per client meant trickled ICE candidates, session loads, history clears and settings updates all queued behind it until the call ended. Chat hubs now allow a small number of concurrent invocations, and raise
StreamBufferCapacityso uploaded audio frames do not stall the connection's dispatch loop while the provider session opens. -
Barge-in now stops playback on the WebSocket transport. The server sends
speech_started/playback_flushto the client, which drops the audio already scheduled in the browser. Previously the interrupted reply played to the end and the new one was appended behind it. -
The client is told when a session ends. A new client method,
ReceiveRealtimeEvent(identifier, type, payload), carriessession_ready,speech_started,playback_flush, andsession_ended(with a reason). When the provider socket closed, the session cap was reached, an error occurred, or the SignalR connection dropped, the browser used to keep the microphone open and stream audio into a session that no longer existed. -
Session title generation moved off the audio pump. It is an LLM call, and the first user transcript usually arrives while the assistant is already speaking, so awaiting it inline stalled the loop delivering assistant audio — audible as a 1–3 second gap in the first answer.
-
Rewritten microphone gate. The gate now runs in an
AudioWorkletinstead of on arequestAnimationFrameloop, which froze whenever the tab was hidden (frozen closed meant the user was never heard again). It decides in dBFS against a continuously tracked noise floor rather than a fixed level, so a quiet webcam microphone opens it and a loud room's echo does not; it holds "the assistant is speaking" through the gaps between the assistant's words, so the half-duplex gate no longer re-opens into the echo tail; and it delays the gated signal ~80 ms so utterance onsets are no longer clipped. A new Voice gate setting offers Auto, Off (always-open mic, best with a headset) and Strict (loud rooms). The implementation is shared by both chat clients. -
Half-duplex now waits for playback to drain. With barge-in off, the microphone gate reopened at the provider's
response.done, which precedes the end of playback by however much paced audio is still queued — so the tail of the assistant's own voice could reach the provider. The peer now reports its queued playback and the runner holds the gate until it drains. -
Turn integrity. Failed input-audio transcription is now mapped and consumed, so the barge-in-off bookkeeping that pairs utterances with transcripts stays aligned (it previously drifted by one turn, making an answered prompt disappear). A response that ends
failedorincompletenow flushes its turn and surfaces the reason, instead of the next reply's text appending to the same bubble. Abnormal provider WebSocket closes are reported rather than silently ending the stream. -
OpenAI-direct parity. Speech-started detection previously only worked on the custom Azure transport, because it matched on raw JSON;
Microsoft.Extensions.AI's OpenAI client carries SDK objects instead. Barge-in flush and partial-turn persistence now work on OpenAI-direct deployments too. -
WebRTC media quality. Outgoing audio is paced against a media clock and catches up bounded numbers of frames when the timer runs late, so long replies no longer drift behind the transcript; gaps are filled with comfort silence so RTP timestamps stay contiguous and the browser's jitter buffer does not time-stretch the opening words after an interruption. Inbound microphone audio is bounded (dropping oldest) instead of growing without limit, and is batched to ~100 ms before being sent to the provider rather than one message per 20 ms frame.
-
Smaller client fixes. Push-to-talk no longer swallows the space bar while the user is typing in a text box; the Auto language setting now sends no language hint at all (it previously pinned transcription and the reply to the browser's locale, which mistranscribed bilingual users); output routing no longer guesses a device on browsers without
default/communicationsaliases (Firefox), where it could route the assistant to an HDMI monitor and appear silent; and blocked autoplay is now detected and reported instead of failing silently.
Turn integrity and interruptions
- Utterances and transcripts are paired by the provider's item id. Input-audio transcription lags the spoken reply, can fail outright, and — with barge-in off — some utterances are never answered at all. Any of those shifted the previous order-based pairing by one turn, which silently removed an answered prompt from the conversation. Whether an utterance gets answered is now also judged when the provider commits it rather than when speech starts, so one that began over the assistant but committed after it finished is answered normally.
- A spoken prompt appears above the reply it produced. The user turn is created when the provider commits the
utterance, and a
user_turn_pendingevent puts a placeholder in the conversation at that moment; its text is filled in when transcription completes, keeping the earlier timestamp. Previously the turn was created on the transcript, which arrives after the assistant has answered — so history reloaded with the prompt underneath its own reply. - Interrupting truncates the assistant's item. The server tells the provider how much of the reply was
actually heard (
conversation.item.truncate), so the rest leaves the model's context. Without it the model believed it had delivered the whole answer and follow-ups like "what did you just say?" reflected text the user never heard. Exact on WebRTC, where the peer reports its queued audio; an over-estimate that trims nothing on the WebSocket transport.
Session lifecycle and settings
RealtimeTransportOptions.EnableWebRtc(defaulttrue) so hosts with no inbound UDP and no reachable TURN relay stop advertising a transport that cannot connect — otherwise every conversation waits out the connect timeout before falling back.- The connect experience is honest about what it is doing. The client reports
requesting-mic→connecting→listeningrather than flipping the button to "End Conversation" before anything is connected, stops waiting early once ICE gathering finishes without a relay candidate, remembers a failed WebRTC attempt for the rest of the browser session instead of paying the timeout on every conversation, and says when it is running in compatibility (WebSocket) mode. UpdateRealtimeSettingsapplies barge-in and turn-detection changes to a running conversation. These are enforced by the browser's gate, the server's input pump and the provider's turn detection at once, and changing only the browser's half left the three disagreeing until the user started a new session. (Turn-detection values reach the provider on the Azure transport; on OpenAI-direct they apply to the browser and server halves.)- Realtime sessions are bounded. A realtime session holds an open, billed provider connection whether or not
anyone is talking, so two guard rails cap what one can cost.
RealtimeTransportOptions.IdleTimeoutSeconds(default 30,0to disable) ends a session after that long with neither the user nor the assistant speaking — the clock is reset by both sides, so a long spoken answer never trips it — reportingsession_ended: idle.RealtimeTransportOptions.MaxSessionDurationSeconds(default 300,0to disable) ends one after that long however busy it has been, reportingsession_ended: max_duration; that is the backstop for a session held open for hours, or a runaway page whose audio keeps the idle clock warm. In both cases the client says what happened and offers to start again. - Sessions end cleanly on a lost device. Unplugging the microphone mid-conversation now ends the session with a reason instead of silently continuing on a different device.
Audio setup
- Audio setup presets — Headset, Laptop speakers, and Room speakers + separate mic — set barge-in, the voice gate and automatic gain together. One is suggested from the device labels once microphone permission is granted: a headset has no acoustic path so full duplex is safe, a standalone microphone in front of speakers does.
- An echo self-test. Test my audio plays a short two-tone chirp through the output the assistant uses and measures how much survives echo cancellation on the way back in. More than ~6 dB above the room means the model will hear itself often enough to answer itself, and the half-duplex preset is chosen. This is the same decision meeting apps make when they pick half duplex, and it replaces guesswork for open-office users.
- A speaker picker. Automatic routing prefers the communications sink because that is what couples playback with the microphone's echo canceller — but on Windows that can be a different device from the one the user is listening to. Firefox uses the browser's own picker, since it exposes no output list until the user chooses.
Simpler settings, echo-aware interruptions, semantic turn detection
A second pass after two users tested the changes above with a headset and with an open-office setup (webcam microphone, desk speakers):
- The WebRTC microphone path no longer arrives ~40 dB too quiet. The server-relay peer asked Concentus to decode the browser's 48 kHz Opus straight to 24 kHz, and Concentus's decode-side resampler attenuates that output by roughly 40 dB — a full-scale voice reached the provider at about −37 dBFS, below its speech detection, so sessions sat at Listening and never answered anyone who did not shout. The peer now decodes at 48 kHz and downsamples to 24 kHz itself.
- Assistant playback quality. The outgoing Opus encoder used Concentus defaults, which land at ~16 kbps for 24 kHz voice (telephone quality, smeared consonants); it now runs at 64 kbps VBR, complexity 10, with in-band FEC. The pacing loop pre-buffers before releasing a reply instead of inserting 20 ms of silence into a word — a baseline recording of a short reply had six such holes, the longest 160 ms — and the RTP stream now runs for the whole session so the first words after a long pause are no longer time-stretched by the browser's jitter buffer.
- Even packet timing. Words speeding up or clipping mid-phrase turned out to be the browser's jitter buffer
reacting to bursty sending: the pacing loop ran on a Windows timer with ~16 ms resolution on the thread pool,
so frames left in bursts of two or three whenever the machine was busy. The peer now paces from a dedicated
high-priority thread with 1 ms timer resolution, one frame per 20 ms slot, and never bursts to catch up. The
client exposes the receiver's own statistics (
CoreAIRealtime.activeController.getTransportStats()). - Provider audio is never dropped. The encoder was fed through a 320 ms ring buffer that discarded its oldest samples whenever a provider chunk was larger than it — and the provider sends chunks of up to a second, so the start of every long chunk vanished and speech audibly jumped ahead. Every sample is now framed and encoded in order, and the peer's closing log line accounts for samples in versus encoded and reports the largest chunk seen.
- Clock-driven RTP timestamps. A mid-reply stall used to hold packets back, and packets that arrive late against their own timestamps make the browser's jitter buffer speed speech up afterwards to catch up — the "speeds up for a moment" complaint. The timestamp now follows the wall clock: a slot nothing was sent in is a short gap the browser conceals and forgets, never a delay the rest of the reply carries. A reply is buffered ~300 ms before release (the provider delivers audio in bursts with pauses between them) and briefly re-buffered when it resumes after a stall; the client pins the receiver's jitter-buffer target at 150 ms where the browser supports it; and the raw send path uses the negotiated Opus payload type, which Firefox numbers 109 rather than Chrome's 111. A 35 s reply now measures 0 packets lost, 0 concealment events and 0 accelerated samples in both browsers.
- One consistent voice. The encoder now runs Opus in CELT-only mode (the transform half of Opus, what it uses for music) at 96 kbps VBR with inter-frame prediction disabled, and without in-band FEC. Asking for FEC with an expected loss rate had forced Opus into its hybrid mode, whose SILK layer re-synthesises everything below 8 kHz with a quality that varies from phoneme to phoneme, and whose frames are delta-coded against the previous one — the browser's decoder, which sees comfort silence before each reply and across gaps, then decoded the first frames of a reply at the wrong level (measured: an error as large as the signal itself; with prediction off, ~15 dB below it). Every frame now decodes on its own, so a splice, a gap or a lost packet affects only itself.
- Interruptions are on by default again. A removed device-label guess had stored the room preset (interruptions off) for any microphone whose name matched a pattern, and the earlier preference repair left that alone. Preferences are now version 3, which restores interruptions and drops every removed setting.
- The gate learns the room's echo. The gate judged an interruption by level alone, so with desk speakers the assistant's own echo — louder than the user after cancellation — opened it, the provider heard the reply, interrupted itself and kept detecting "speech" for as long as the reply played. The gate now learns the echo return level (how loud the assistant comes back into the microphone relative to its own level) while the assistant is audible and the gate is shut, and only opens for a voice clearly above the expected echo, sustained for a quarter of a second. With loud speakers it waits its turn; with a headset interruptions stay cheap. The same gate now runs on the WebSocket transport too.
- Semantic turn detection. Sessions request
turn_detection.type = semantic_vadby default, so the model decides when the user has finished and a pause for thought mid-question no longer makes the assistant answer half of it.RealtimeTransportOptions.TurnDetectionType/TurnDetectionEagernessconfigure it; a deployment that rejects semantic detection is switched to server VAD in place. - Mid-session settings no longer re-send the voice. Changing interruptions during a conversation sent the whole
session configuration, which the provider rejects once the assistant has spoken, and that error ended the
session. Only
turn_detectionis sent now. A barge-in truncation naming more audio than the item holds is treated as benign. - One realtime client. The AI Chat host (
ai-chat.js) now usesCoreAIRealtime.attachlike the chat-interaction host; its ~1,100-line copy of the realtime client is gone. - The voice settings popover is short: microphone, speaker, volume, language, Allow interruptions, and push-to-talk. Audio-setup presets, the echo self-test, voice-gate modes, the echo-guard delay, turn-detection sliders and the noise/gain switches were removed — all of that is measured or decided automatically.
Tests
- Browser tests for the realtime client (
npm run test:client), running in Chromium and Firefox against a static harness page with fake media — no server, SignalR connection or AI provider involved. They cover transport selection and fallback, the session state machine, and that the microphone gate gates a live audio graph. - Node tests for the gate's rules (
npm run test:gate), asserting every threshold as a number against the same pure decision function the AudioWorklet runs. The decision now exists once and is stringified into the worklet rather than written twice.
Diagnostics
- A session that ran a whole conversation without the provider ever reporting user speech now logs a warning: that deployment's events are not recognised, and barge-in cannot work for it. It previously failed silently.
Documentation search
- Hardened the sitemap documentation search source so it indexes the sitemap formats found across the
web, not just a single flat
urlset. It now follows asitemapindexinto its child sitemaps (including nested indexes, bounded by depth and document-count caps), decompresses gzip-compressed sitemaps (.xml.gz), reads RSS 2.0 and Atom 1.0 feeds (which search engines also accept as sitemaps), parses plain-text sitemaps, and discovers sitemaps advertised inrobots.txtwhen only a base URL is configured. Previously every<loc>ending in.xmlwas discarded, so a sitemap index — the format emitted by generators such as Yoast and Rank Math — yielded zero pages and every search returned no results. - Page URLs are now classified by their parent element (
<url>versus<sitemap>), so image, video, and news extension locations (<image:loc>,<video:loc>, …) are no longer mistaken for pages and crawled. - The documentation crawler's
HttpClientnow sends an identifiableUser-Agent(many hosts reject requests without one), enables gzip/deflate/brotli decompression, follows redirects, and applies a 30-second timeout. - An empty crawl is cached for only 5 minutes instead of the full cache duration, so a site that was temporarily unreachable recovers quickly instead of returning nothing for an hour.
- Empty search results now return a message that tells the model the site was fully indexed and that rewording the query will not help, preventing the model from retrying the same fruitless search until it exhausts its tool-call iteration budget. This applies to all documentation search sources (sitemap, search index, and Algolia).
- Local keyword ranking (used by the sitemap and search-index sources) now ignores common English stop
words in the query and awards a phrase/adjacency bonus, so a page that contains the query as an exact
phrase outranks one that merely scatters the same keywords. The search function's
queryparameter description now guides the model to pass concise keywords rather than the user's full sentence. - The first search against a not-yet-indexed sitemap or search-index source no longer blocks the caller:
the corpus builds in the background and a search waits only a short budget
(
DocumentationSearchOptions.FirstSearchWaitBudget, default 8 seconds) before returning a "still indexing, ask again shortly" message that the model relays instead of retrying. A site whose crawl finishes within the budget still returns results on the very first search. This keeps a slow first crawl from exhausting the request's tool-call iteration budget, and a build that was left running warms the cache for the next search. - Added a live website search source (
AddWebsiteSearchSource()) that queries a site's own search API instead of crawling it — so there is no crawl, no local corpus, and no cold-start indexing delay, and results reflect the site's own relevance ranking. It defaults to the WordPress REST search endpoint (wp-json/wp/v2/searchwith_embed), so a WordPress site needs only a base URL; the endpoint path, the query parameter, and the JSON field paths for the result title, URL, and snippet are all overridable to target another site's search API. Each result returns a title, source URL, and a text snippet (the embedded page excerpt, HTML stripped), so the model has real content to answer from rather than only a link.
Data source search tool instances
- Added a data source search tool instance source (
AddDataSourceSearchSource()) that turns any existing AI data source into its own model-callable vector search function. Until now a data source reached the model only by being attached to an AI profile or chat interaction, which allowed exactly one per conversation and gave the model no say in which knowledge base to consult. An instance binds one data source, so several instances can expose several knowledge bases side by side, each under its own function name and description, and the model picks.- Each instance carries its own retrieval parameters rather than reading them off the resource:
data source, retrieval model (
Chunkby default, orHierarchical), retrieved documents (top N), strictness, and filter. The query-time parameters are applied exactly the way a chat interaction's are. - The model's search phrases are embedded with the same embedding deployment the knowledge base index
was indexed with —
SearchIndexProfile.EmbeddingDeploymentName, optionally overridden byDataSourceIndexProfileMetadata— so the queries and the stored chunks are compared in the same vector space. - A single call may carry up to three phrases, for a question spanning genuinely distinct topics. They are embedded in one batched request and searched in parallel, then merged by chunk into a single ranking, so a passage matched by two phrases is returned once under one citation and the union is trimmed to one top-N. Previously the model had to call the tool once per phrase, spending its tool-call iteration budget and getting back independent result sets that nothing deduplicated. Ranking uses Reciprocal Rank Fusion, because similarity scores are not comparable across query vectors — ordering the union by raw score lets whichever phrase happens to produce higher similarities crowd the others out entirely. Strictness still applies first as an absolute quality floor on each chunk's best raw score. The cap is enforced by truncation rather than rejection, and exact repeats are dropped before searching; a single phrase behaves exactly as it did.
Hierarchicalretrieval collapses the matching chunks onto their source documents and returns each document's complete text under one citation, read back through the data source's own source handler. When those documents cannot be read, the search degrades to the matching chunks rather than failing.- Both editors (MVC and Blazor) gained the matching form section, and
DataSourceRetrievalModewas added alongside the existingDocumentRetrievalMode. The two are separate becauseDocumentRetrievalModelives inCrestApps.Core.AI.Documents, which depends onCrestApps.Core.AI— where the new source lives.
- Each instance carries its own retrieval parameters rather than reading them off the resource:
data source, retrieval model (
- Refactored the retrieval pipeline behind the profile-bound
DataSourceSearchToolinto a shared internal path both it and the new instances use, so the two honor identical thresholds, filter translation, citation numbering, and output format. Behavior of the existing tool is unchanged.
Agents as MCP server tools
- An AI agent can now be exposed to MCP clients as a callable tool, alongside plain tools and tool
instances. The client sees one tool per allow-listed agent — named after the agent, described by its
description, taking a single
prompt— and invoking it runs that agent. Previously agents reached the model only throughAgentToolRegistryProvider, which is completion-scoped, so the MCP server had no way to see them.- adds
McpServerOptions.Agents(an allow-list of agent profile names) andMcpServerOptions.ExposeAllAgents, both editable from Settings → MCP server in either host - agents are gated by their own switch rather than
ExposeAllTools, on purpose: an agent runs a whole profile with its own tools, data sources, and credentials, so a server that had already opted into exposing tools must not begin exposing agents merely by upgrading - an agent needs a description to be exposed, since that is all an MCP client has to tell agents apart.
AgentAvailabilityis ignored — it governs a completion's token budget, not who may reach an agent from outside — and an agent whose name is already taken by an exposed tool is skipped from the listing with a warning, so the listed name always matches what a call will reach - an agent's own tools are never filtered by the tool allow-list. That list governs what a client may directly invoke; what an agent uses internally is its own configuration. Intersecting them would force an operator to allow-list every internal tool, making each one directly callable — more exposure, not less, and the opposite of publishing a curated agent
- adds
- Fixed: a tool invoked over MCP ran without an AI invocation scope. The call handler now establishes one
for the duration of the call. Without it
AgentProxyTooltreated the untrackable recursion depth as unsafe and silently ran the agent with its tools disabled — a plausible-looking wrong answer rather than an error — and citation-emitting tools such as the data source search fell back to local reference numbering instead of registering their citations. An agent still runs its own tools only when its profile setsAgentMetadata.AllowToolInvocation, exactly as when the model invokes it as a tool in a chat completion.
Document ingestion pipeline
- Uploads now go through one ingestion pipeline.
IAIDocumentIngestionPipelineresolves the reader for a file, reads it, and runs every registered processor over the result. A processor enriches the document in place, so a reader stays responsible for reading and nothing else, and the chat upload path and the indexing path share one set of readers and processors instead of drifting apart.- adds
IAIDocumentIngestionPipeline,DocumentIngestionContext(the per-run options every processor receives),AIDocumentIngestionProcessor(the processor base; the library's ownProcessAsync(document, cancellationToken)overload still works and runs withDocumentIngestionContext.Default), andIIngestionDocumentReaderResolver - register a processor with
services.AddCoreAIIngestionDocumentProcessor<T>(). Processors run in registration order, so a host that registers its own beforeAddDocumentProcessing()runs first - adds
IngestionDocumentElementExtensions.GetSemanticText(), which is what flattening now uses. It is identical to readingTextfor a paragraph; an image yields its description or its caption, and a table with no text of its own is rendered from its cells DefaultAIDocumentProcessingServicetakesIAIDocumentIngestionPipelineinstead ofIServiceProvider. A file no reader can handle is still reported as a failure, now carrying the reason rather than a generic message
- adds
- PDF pages are read in reading order, not content-stream order. Pages are segmented into blocks and
ordered by geometry, so a two-column article reads down one column and then the other instead of
interleaving half sentences from both. Blocks that repeat at the edge of every page are classified as
decoration and left out.
- decoration removal is guarded, because a heuristic that silently deletes prose is worse than one that
leaves a running head in: a block is never dropped when it is the only block on its page, when it is
longer than
PdfLayoutOptions.MaxDecorationCharacters(200), or when it reads as more than one sentence - extracted text is repaired in ways that apply to any script, not only to English. Words broken
across a line are rejoined, which is the largest single source of unsearchable words in a typeset
document and worst in the languages that build long compounds; ligatures and presentation forms are
expanded back to their letters for Latin, Armenian and Arabic; accents arriving decomposed are composed
with
FormC, so a word that looks right also compares right; and invisible formatting characters are dropped, except the zero-width non-joiner and joiner, which are letters of the word in Persian and the Indic scripts. Numbers are left alone throughout: compatibility normalization over a whole string would rewrite a superscript, turning a printed coefficient into a different number - adds
TextSegmentation, which both the decoration guard and the in-text reference search use. It knows the sentence terminators of scripts that do not end sentences with a full stop, and it does not require a capital to start the next sentence — Arabic, Hebrew, CJK, Thai and the Indic scripts have no case, so a rule keyed on capitalisation never fires there at all - every emitted element now carries its own
TextandPageNumber, plus its bounds, modal font size and modal font name. Page attribution set only on a section is invisible, because content enumeration does not yield sections - adds
PdfLayoutOptions. SettingUseLayoutAnalysis = falsereproduces the previous behaviour exactly — one paragraph per page holding the raw content-stream text — as the escape hatch if segmentation misbehaves on a particular corpus
- decoration removal is guarded, because a heuristic that silently deletes prose is worse than one that
leaves a running head in: a block is never dropped when it is the only block on its page, when it is
longer than
- A PDF's figures are now read out alongside its text. Each figure carries its bytes, its page, its
bounds and a hash of its content. Artwork placed twice is recorded once, with the repeat pointing back at
the figure it duplicates, and anything smaller than
PdfLayoutOptions.MinImageSamplesis left out as a rule, a bullet or an icon. - Captions are matched to figures by scoring, not by a fixed rule (
FigureCaptionProcessor). The same publication routinely prints figure captions below the artwork and table captions above it, so a fixed rule is wrong somewhere in every issue. The processor reads the document's own habit from the captions it is sure about and falls back to a configured default until it has seen enough of them.- caption patterns are data:
CaptionPatternOptions.Patternsships defaults for English, German, Dutch, the Scandinavian languages, French, Italian, Spanish, Portuguese, Polish, Czech, Russian, Ukrainian, Hungarian, Japanese, Chinese and Korean, and a host adds a language withservices.Configure<CaptionPatternOptions>. The list decides more than tidiness: a caption matching no pattern falls to the typography heuristic, which scores below the bar a figure has to clear to be transcribed at all - a figure printed with no caption falls back to the sentence in the prose that refers to it by number
IFigureCaptionCandidateDetectorandIFigureCaptionResolverare replaceable for an unusual layout
- caption patterns are data:
- Figures are scored for salience before anything expensive happens to them
(
FigureSalienceProcessor). Most images in a publication are logos, advertising artwork or decoration; a handful carry the whole answer to a question the text cannot answer. Cheap signals — a caption, a mention by number in the prose, distinct colour count, long straight runs, repetition across pages, size, covering a page that says almost nothing — sort each figure intoSkip,CaptionOnlyorDescribe.DocumentIngestionContext.FigureModeoverrides the score entirely, andMaxFigureDescriptionsPerDocumentcaps a document's describable figures by demoting the surplus toCaptionOnlyrather than dropping it- a printed, numbered caption and "this block was in smaller type" are not worth the same. Weighting
them equally promoted every stretch of fine print beside a photograph into the same tier as a labelled
chart, so the two are scored separately through
PatternCaptionScoreandTypographyCaptionScore - salience does not look at the figure's pixels. An earlier design scored colour counts and straight-line runs, which meant decoding the image. Measured against a real trade magazine, four figures in five were JPEG, so those signals reached a fifth of the figures and decided nothing. Removing them, and weighting caption evidence by its source instead, moved the outcome from 14/61/25 to 57/19/24 across skip, caption-only and describe — close to what the design predicted, with one less thing to maintain
- A figure with something to say is flattened as a fenced block, so a chunk boundary can never separate
a description from the figure it describes. The caption paragraph is not emitted a second time, and a block
a chunker splits anyway has its opening line repeated on the continuation. Figure bytes are written through
IDocumentFileStoreand recorded on theAIDocumentas aDocumentFigureList.DefaultAIDocumentProcessingServicetakesIDocumentFileStore
- A figure from an uploaded document can now be shown in the chat. The text told the model a figure
existed; nothing let it show the picture.
AddDownloadAIDocumentFigureEndpoint()servesai/documents/{documentId}/figures/{figureId}under the same authorization as the document download, and only ever serves a picture the document lists- the new
view_document_figuresystem tool returns a figure's caption, page and link with the markdown image to embed, and answers a question about the picture with a vision deployment when one is asked;get_document_metadatanow lists a document's figures - tool results that render themselves reach a chat model as text. The function-invoking client
serializes any non-string result as JSON, so a search result arrived as an envelope around its text and
a figure result with its picture inlined as base64.
FunctionInvokingClientExtensions.UseTextToolResultsis applied by every chat, realtime and post-session client; the rich shape is kept for the MCP server
- Decoration is kept and marked, not dropped.
PdfLayoutOptions.EmitDecorationAsHeaderFooternow defaults totrue: running heads and printed page numbers stay on the page as header and footer elements markedIsDecoration, and every consumer that embeds text — the chat flattening, the knowledge builder, the caption and salience processors — skips them through the sharedIsDecoration()extension. Structure analysis reads folios and section labels from exactly these blocks, and with the elements dropped it could never see either.- line breaks inside a block now survive normalization (a run of whitespace holding one collapses to one line break rather than a space), because a table of contents is one block whose every line is a title and a page number
- a publication with no readable contents page is now split on its own headings instead of being stored as one article. A page opens an article when its topmost element is set at least half again the body size and sits in the top band of the page, and at least three such pages are needed before the split is trusted — so a subheading part way down a page cannot cut an article in two. Measured against a twenty-three page trade magazine whose contents page cannot be read at all, this is the difference between one article covering the whole issue and fourteen covering it a page range at a time
- the contents page is searched further into the document. Front matter is longer than it looks — a cover, an inside-cover advertisement, a masthead and an editor's letter routinely push the contents to page five — and stopping at page four produced a single article for every publication laid out that way
- a document that does not label its pages consistently no longer has every continuation page of every article marked as an advertisement. The absence of a running head is read as evidence only where at least 60% of the pages inside articles carry one — a magazine labels every editorial page, so an unlabelled page there is an advertisement, while a book that labels only its chapter openers is saying nothing by omitting it. Since an advertisement is stored but never indexed, getting this wrong did not return a worse answer, it dropped most of such a document out of the knowledge base
- a page whose glyph geometry defeats the segmenter keeps its text, unsegmented, instead of losing it, and a failure of the whole analysis pass falls through to the raw page reader for the document rather than failing the ingest
- structure is keyed by the page a section was read from rather than by its position, so a document containing a blank leaf no longer shifts every article boundary and printed page number after it
- a document carrying no type sizes, which is what a provider-backed reader produces, still splits into its articles: the contents page is no longer searched for the headings it lists
- a page set in columns is no longer emitted as a table. Words are grouped into lines across the page, so a three-column page produces lines that start at the same three offsets — the same evidence a whitespace-aligned table is recognized by — and a magazine page was stored as a table whose cells were the halves of sentences
- a word separator survives in scripts that have no letter case. Arabic extracted as presentation forms ends nearly every word in one, and the rule that removes a producer's stray space after a ligature was deleting the space at almost every word boundary, running whole paragraphs into one unsearchable token
- table cells and their markdown are normalized like prose, so a ligature inside a table no longer makes that word findable everywhere except in the table
- an image is only handed out as a JPEG when the JPEG filter is the whole filter chain and the bytes
actually begin with the JPEG marker. A stream filtered
[/ASCII85Decode /DCTDecode]was stored as an unopenable.jpg, hashed for the duplicate and transcription caches under the wrong bytes, and sent to a vision model as though it were a picture - dropping a figure gives its caption back to the prose. The caption is marked as belonging to its figure so it is emitted once rather than twice; leaving that mark behind on a dropped figure took the caption out of the document altogether, and a caption is often the only text naming what was pictured
- a tick label such as
1,000no longer reads as1.0. The two readings differ by a factor of a thousand, the ticks stay evenly spaced either way, and every series value was reported a thousand times too small and reported as exact. Only unambiguous labels are now accepted; an ambiguous one costs the chart its exact values and leaves it described instead - a chart whose value confidence is unknown says so rather than saying nothing. Silence is not a claim of exactness, and two of the three index providers do not carry the flag back
- A scanned document is transcribed rather than dropped. Every page of a scan is one picture that nothing
captions or cites, so every salience signal skipped it and nothing was indexed. When most pages carry no
text layer (
FigureSalienceOptions.ScannedDocumentPageRatio,ScannedPageMaxCharacters), a full-page image scoresScannedPageScoreand is described. - Drawn geometry is capped per page. Grouping lines into grids and drawings compares every line with
every other;
PdfLayoutOptions.MaxVectorSegmentsPerPage(4000) skips drawn-table and figure detection on a denser page rather than spending minutes on vector artwork. - Two ruled tables on one page are two tables. Rules are grouped into the grids they actually form before a grid is read, so a page holding two tables — or a table beside a boxed advertisement — is no longer read as one grid with a band of nonsense between them.
- artwork placed twice in a document is stored once and becomes one knowledge object; the repeat is recorded as a duplicate and skipped
- A figure worth transcribing is now read by a vision model at ingest
(
FigureDescriptionProcessor), so the values a document only prints inside a picture become text an index can find. Retrieval is a vector search over text: a coefficient rasterized into a JPEG is not text, and a question about it could never match the row holding the answer.- the new
figure-transcriptionprompt asks for a literal transcription — chart type, axis titles and units, every tick label, every legend entry, every printed number and equation verbatim, then the trend in one sentence — and forbids translating, rounding, or stating a value that is not printed. A figure whose series carry no printed labels gets its shape described and its values left alone, because a confident wrong number is worse than no number IImageAnalysisServicegains an overload takingImageAnalysisRequest, which carries the caption, the surrounding text, the language, the prompt template and the deployment. The stream overload is unchanged and delegates to it- transcriptions are cached by content hash and prompt version (
IFigureDescriptionCache, in-memory by default), so the same artwork is never described twice and a template change is never hidden behind a stale cache entry - nothing here can fail an ingest. No vision deployment, or one that turns out not to accept images: logged once at Information and the document continues as text. A call that throws: logged at Warning, that figure drops back to caption only, and the rest are still transcribed. Slot resolution falls back to the first deployment capable of the slot's feature, so the image-input capability is always verified rather than assumed
- the new
Reading document structure from a service
- Structure can now be read from Azure AI Document Intelligence instead of inferred. The new
CrestApps.Core.Azure.DocumentIntelligencepackage registers a reader whose layout model reports, for any language, paragraph roles, reading order, tables with real cell spans, and figures with the captions that belong to them. It is a separate package, so the core takes no Azure dependency.- figure images are downloaded from the service, so a vector figure or one assembled from several objects arrives as an ordinary image - neither of which a local extractor can produce
- registration is explicit (
AddDocumentIntelligence()). A keyed reader resolves to the last registration, so merely referencing the package must not change which reader serves every PDF - adds
FallbackIngestionDocumentReader: when the preferred reader is unconfigured, throttled, over its page limit, timed out or down, it logs once and the local reader produces the document. A document is never rejected because a paid service was unavailable - a caption the service reported is marked
provider; the caption processor leaves those figures alone rather than replacing a printed caption with a guess, and salience scores it as strongly as a printed numbered caption MaxPagescaps what is worth sending, since the service is priced per page
Typed knowledge from files
-
A file is now stored as separately retrievable objects, not one wall of chunks. A new
Filedata source type turns each file into typed knowledge — chunks of text, figures, charts and tables — each with its own row, page number and link back to the article and document it came from. A question about a chart now returns the chart rather than the page it happened to sit on. See File Sources.IKnowledgeIngestionServiceis the one entry point: it reads the file, stores the figures it kept, builds the objects and queues them for indexing. Reading identical bytes again replaces what the file produced last time instead of storing a second copy of it- the MVC data source form hides the key, title and content field mapping for a
Filesource and stops requiring it, as it already did forWeb. A File source derives all three from the file it read, and demanding the fields while hiding them made that data source impossible to save at all - knowledge objects are stored through the new
IKnowledgeObjectStore, implemented by both the Entity Framework Core and YesSql store packages - figures are never transcribed inline: a figure worth describing is searchable by its caption
immediately and is marked
pending-description, so a long document's text is never held back waiting for a picture - a cited figure links to
/ai/knowledge/{dataSourceId}/figures/{canonicalId}, authorized against the owning data source withAIKnowledgeOperations.ViewFigures
-
Knowledge-base rows now carry their type, parentage and page in real, filterable columns.
contentType,rootId,parentIdandpagewere added to the data-source index schema and are written byDefaultAIDataSourceIndexingService. Rows from every other source type carrycontentType = "text"explicitly, and every reader treats a missing value astext, so an index built before this change keeps working untouched.ISearchIndexManager.TryAddFieldsAsync(default-implemented, so an external provider keeps compiling) offers an existing index the current schema on the next synchronization. PostgreSQL, Elasticsearch and Azure AI Search all add the columns in place; a provider that cannot keeps serving the index it has, with typed filters unavailable until it is recreatedIDataSourceContentManager.DeleteByReferenceIdsAsync(also default-implemented) deletes a reference's rows by predicate. Removing one document used to mean guessing how many chunks it produced and asking for a thousand identifiers per referenceSourceDocument.IsPreChunkedmarks a row that was built to sit inside one chunk. It is stored as it stands, with no title prepended and no splitting, so a figure's description is never separated from the figure it describesDataSourceSearchResult.DataSourceIdandAICompletionReference.DataSourceIdwere added so a citation link can reach an endpoint scoped to a data source- a filter on a typed column now reaches the column. All three OData translators mapped every field
into the per-row filter bag, so
contentType eq 'figure'could never match a row;contentType,rootId,parentIdandpageare now translated as the columns they are, andcontentType eq null— how a caller reaches rows written before the column existed — is a null test rather than a comparison to the string'null'. Every other field keeps meaning an entry in the bag - re-ingesting a document removes what the previous ingest produced and this one did not. Deleting the store by root replaced the objects, but the index only hears about identifiers it is told about, so a chunk that no longer exists or a figure now scored as decoration stayed searchable; the vanished identifiers are now queued for removal and their stored pictures deleted
- the Entity Framework Core knowledge store filters by canonical identifier, root and type in the database instead of materializing a data source's worth of objects to pick one out
- a chart is recognized by caption or description in the common European languages, not English alone
(
KnowledgeObjectBuildOptions.ChartKeywords); a figure's storage path is no longer written into the index filters
Typed, navigable retrieval
-
A search can now return the chart, not the page it sat on.
DataSourceRetrieval.SearchDetailedAsyncreturns aDataSourceRetrievalResultcarrying the rendered text plus the figures and tables among the hits as objects.SearchAsyncreturns that result's text, so every existing caller is untouched.- figures and tables render in their own blocks after the content and before the citations, each with a label, its page and — for a figure — the address of its picture
- a chart whose values were only described says outright that they are not machine-readable
- hits are grouped by document and then by article, so a figure and the paragraph that cites it are read as related; a figure is cited under its document and page rather than under its own caption
DataSourceSearchToolSettings.ContentTypeslimits an instance to certain kinds of knowledge, editable in both sample hosts. Asking fortextalso matches rows written before the column existed- a figure line names the address the host serves the picture from (
RetrievedFigure.Link), resolved through the sameIAIReferenceLinkResolvera citation uses, and that address is what the figure's[fig:N]label is registered against (below); the logicalcrestapps://address stays onRetrievedFigure.Urifor MCP clients.get_sourceprints the same address as anImage:line
-
Fixed: a figure the answer pointed at was often one that did not exist. Retrieval handed the model each figure's absolute address and asked it to embed that address as a markdown image. A language model asked to reproduce a long opaque identifier verbatim does not copy it — it reproduces the identifier's shape and substitutes plausible ordinals, so an answer cited figures of an article that were never among the results, and filled in a genuine gap in the numbering as readily as it left one alone. Each of those addresses looked exactly like a working one and resolved to nothing, which the reader saw as a broken image inside an otherwise correct answer. The model is now handed a short label and writes that; the host turns the label into the picture, so no address passes through the model at all. See Figures from a Knowledge Base.
- the label is the one retrieval already assigned each figure —
[fig:1],[fig:2], numbered from one within the turn - adds
AICompletionReference.IsImage. A figure is registered on the invocation context under the marker text itself ("[fig:1]"), carrying the address the host serves the picture from and the caption to use as alt text — the same mechanism a[doc:n]citation has always used - the chat client replaces each such marker with
before the markdown is parsed and before citations are processed, so the existing image renderer keeps the thumbnail, the download button and the size cap it already applies to a generated image - a figure the host has no servable address for is never registered as an image, and a marker with no matching reference is left as visible text rather than rendered as an image that cannot load
- the label is the one retrieval already assigned each figure —
-
Figures left pending by an ingest are now transcribed in the background.
IFigureDescriptionBackfillService, driven by a hosted job everyKnowledgeIngestionOptions.BackfillIntervalSeconds(default 30), takes a batch per File data source and re-queues what it finished for indexing.- a figure whose transcription fails is marked failed with the error recorded on it and is never retried on its own, because retrying a picture a model cannot read spends money on the same answer
- an identical picture already transcribed under the current prompt is copied rather than paid for again
- a host with no vision deployment is a supported setup: the figures stay searchable by their captions and nothing fails
MaxConcurrentVisionCallsnow does what it says. The batch runs in three stages — store reads, then the model calls under the configured concurrency, then store writes — because only the model calls may run in parallel over a transactional session that is not safe to share
MCP clients can see the picture
- Tool results are no longer flattened to text.
McpToolResultMappermaps whatever a tool returned onto the content blocks a client receives: a string is still exactly one text block, and a result that knows it is more than prose says so through the newIAIToolContentProvider.- a search that found a figure now returns its text plus a resource link per figure, so a client can actually open the picture instead of reading that one exists
search_tool instances accept an optionalcontentTypesargument, letting a model narrow a search to figures or tables. A model may narrow what an instance searches but never widen it- the new
CrestApps.Core.AI.Mcp.Knowledgepackage publishescrestapps://datasource/{dataSourceId}/figure/{figureId}as a resource template and serves the bytes behind it, refusing any figure that does not belong to the data source in the address - a new
knowledge-objecttool instance source exposesget_source: it reads one article, figure, chart or table in full by the identifier a search reported — a figure comes back with its picture, a table with its rows as JSON. A picture larger thanChatDocumentsOptions.MaxVisionImageBytesPerFilecomes back as a link rather than inline
A document is split into the articles it is made of
- A file that holds many articles is no longer indexed as one. The new
IDocumentStructureAnalyzer, implemented byTocSeededStructureAnalyzer, uses a document's own table of contents as the answer key and matches each listed title to the page that prints it, so each article becomes a separate object with its own title, author, pages and text.- the page number printed on a page is captured alongside the page's position in the file, so a citation names the page a reader would actually turn to
- a page carrying neither a matched title nor a recurring running head is an advertisement: kept as an
object so the document stays complete, marked
Excludedso it is never indexed and can never be returned as an answer - running heads are recognized in any script, not only in the alphabet the analyzer was first written against
- every step degrades to the one before it. No table of contents, no headings big enough to be titles, or anything at all going wrong, and the document is one article — byte for byte what it was before
IPublicationMetadataExtractorspends one utility-model call reading the front matter for the publication title, publisher, issue and date, stored on the document object. Nothing found simply means a citation names the file
Tables and charts a PDF draws rather than places
-
A ruled table is now read as a table.
PdfIngestionDocumentReaderreads the rules a page draws, recovers the grid, and emits anIngestionDocumentTablewith every cell in its own row and column. Read as prose, a table is a row of numbers with nothing saying which column each belongs to, and a question about one of them is answered with whichever number happened to land nearby.- a rule that crosses only part of the grid is an underline inside a cell, not a grid line, and is ignored
- an empty cell is kept, because dropping the blanks would shift every value after them into the wrong column
-
A chart the file draws instead of placing is now a figure. A chart produced by a spreadsheet is usually not an image at all: the file contains instructions for drawing it. Clusters of vector geometry large and dense enough to be drawings are rendered to a PNG line drawing and stored like any other figure, so they can be described, cited and downloaded.
- a table's own grid is never also reported as a drawing
- rules, borders and icons are excluded by size, density and how much text covers the region
-
A vector chart's values now come from the file's own geometry. When a chart's tick labels solve a straight scale, the drawn series is converted back into the chart's units and stored with
ValueConfidence = Exact. When they do not, the chart isAxesOnlyorDescriptiveand no value is stored at all — a number read off a picture by eye looks exactly like one lifted from the geometry, and only one of them is true.- a scale that does not hold at every labelled tick — a log axis, a broken axis, a label that is not a tick — is rejected outright rather than used to read values anywhere
PdfLayoutOptions.EmitTablesandEmitVectorFiguresturn either off
Continuous intake
-
Content can now come from anywhere. The new
IIngestionConnectoranswers two questions and nothing else — what is there, and give me that one — so a folder, an FTP server, an SFTP server and a website all reach the same reader, the same enrichment and the same store. See File Source Connectors.FileSystemreads files from a folder on the host. The folder has to sit inside one the host allows (FileSystemConnectorOptions.AllowedRoots, empty by default, and resolved against the content root); a..is refused outright, containment is compared by path segment rather than by string prefix, and an identifier is re-checked when it is fetched as well as when it is listedFtpandSftpread files off a file server, in the newCrestApps.Core.AI.FtpandCrestApps.Core.AI.Sftppackages, which also carry that protocol's MCP resource type. Credentials are read through the same data protector the MCP resource handlers use, and both sample hosts now edit the connection — host, port, user, password, the FTP transport options and the SFTP key — on the record's own screen. A secret is write-only: the form is told that one is stored, never what it is, and a blank field keeps it. Changing a file source's protocol drops the connection the previous one used. A refused connection disposes its client rather than leaking the socket on every retry- moving a file without changing a byte keeps the document it produced. A document's identity comes from the bytes, so the new path produces exactly what the old one did; the run now counts the item it has just seen for the first time among the producers, instead of deleting the document it had just ingested and reporting success
- existing web crawlers are unchanged: a crawl strategy is presented as a connector by an adapter
IFileSourceRunServicedoes one run and returns anFileSourceRunSummary— seen, ingested, removed, failed, and whether the listing was completeFileSourceMetadatacarries per-sourceFigureMode,VisionDeploymentName,MaxFigureDescriptionsPerDocumentandLanguage, so a folder of scanned datasheets and a folder of meeting minutes can sit side by side under different settings
-
A partial listing never deletes anything. Removals happen only when a connector reports
IngestionDiscoveryResult.IsComplete. A dropped connection, a folder larger than one run will take on, or a paged listing whose second page failed each return what they have and say so; treated as complete, any of them would delete every item they failed to see. -
A reader is now picked by what the content is, not only by what it is called.
IIngestionDocumentReaderResolverasks the declared media type first, then the first bytes (%PDF-, PNG, JPEG, or a zip container disambiguated by extension), then the extension — which is how every reader was resolved before. The stream is always returned to where it was found, and readers are now registered by media type as well as by extension.
File sources: settings that take effect, runs that report themselves
-
A web crawl that stopped short no longer deletes what it did not reach. A sitemap walk that hit its page limit, failed to download a child sitemap, or found a graph nested deeper than the crawler follows now reports itself incomplete, and
WebCrawlerReindexPlannerperforms no removals on the strength of it. The result carries the newWebCrawlerReindexStatus.PartiallyDiscoveredand a message saying which happened. Previously each of those cases looked exactly like a site that had shrunk.IWebCrawlerStrategy.DiscoverDetailedAsyncandISitemapCrawler.DiscoverDetailedAsynccarry the completeness; both are default-implemented, so a strategy outside this repository keeps working
-
Per-source model selection is now enforced and editable.
FileSourceMetadatagainedUtilityDeploymentName,EmbeddingDeploymentNameandMaxItemsPerRunalongside the existing settings, and all of them are edited on the record's own screen in both sample hosts.- a deployment that does not accept image input is refused as the vision deployment when it is saved, and one that produces no embeddings is refused as the embedding deployment
- leaving a deployment unset means "use the application's", never "disable"; a deployment deleted later falls back to the application's rather than failing the run
-
A run now records what it did, on the record.
FileSourceRunSummaryis stored on the record - status, timings, items seen/indexed/skipped/removed/failed, figures stored and still awaiting transcription, and whether the listing was complete - and shown in the admin list and edit screens. Run now and Reset state sit beside each record; resetting forgets what has been read, not what was stored. -
A source larger than one run is now worked through across runs. A connector that could not list everything returns a discovery cursor and the next run resumes from it. A resumed run has seen only a window onto the source, so it is never complete and never removes anything.
FileSourceOptions.MaxConcurrentFetchesnow does what it says: fetches run ahead of a single-threaded ingest, which is where the time goes without putting two writers on one session. -
Each record runs on exactly one path, decided by the data source it fills. A record feeding a File data source is a file source and runs through
IFileSourceRunService; one feeding a Web data source is a web crawler and runs through the re-index planner. The file source background job, the crawler re-index service and the Sync actions in both sample hosts all apply that rule, so an existing web crawler is never also run as a file source (which rewrote its crawl state) and a file source is never also planned as a crawl.- each kind has its own catalog handler, which validates its own source and checks the pairing when a record is saved: a file source may only feed a File data source, a crawl strategy may feed a Web or a File one, and a data source that no longer exists is refused
IIngestionConnectorResolvermoved toCrestApps.Core.AI.Abstractions(namespaceCrestApps.Core.AI.Indexing) with a sharedKeyedIngestionConnectorResolver, registered by bothAddCoreWebCrawlers()andAddCoreFileSources()- the file source, backfill and crawler background jobs commit the transactional store after each pass, the way the indexing queue does. Without that, on YesSql and Entity Framework Core alike, every knowledge object, per-item state and run summary a background run wrote was discarded with its scope
- deciding what is due and running it is
IFileSourceScheduler, registered on its own and creating no scope of its own, so a host with its own scheduling — Orchard Core's background tasks, a cron job, an operator pressing a button — calls it without takingFileSourceBackgroundServiceor its timer with it. Whether a source is due is read from the run summary stored on it rather than from memory, so a restart no longer runs every source at once - an item whose content changed removes the document it produced before, unless another item of the same record still produces it; a run against a data source that is not a File one fails with a clear reason
AddCoreFileSources()registers the HTML reader for.html,.htmandtext/html, so a web page read from a folder or a server is indexed as text rather than markup
-
Tables laid out with whitespace are now read as tables. A PDF that aligns its columns without ruling them used to come out as a run of numbers with nothing saying which column each belonged to. Running prose is deliberately left alone: several consecutive lines have to start words at the same few positions, with real gaps between them, before anything is treated as a grid.
-
A value printed on a chart is now taken as the value. Data labels printed inside a plot are read and stored with
ValueConfidence = Exact- a printed number is the figure the document states, not a reading off a picture - which also recovers bar charts, whose bars draw no line to follow.
A file source is its own thing
-
The
Ingesteddata source type is renamedFile.AIDataSourceSourceTypes.Ingestedis gone andAIDataSourceSourceTypes.Filetakes its place; the source handler and the citation link resolver are renamed to match (FileAIDataSourceSourceHandler,FileReferenceLinkResolver). "Ingested" named a step in a pipeline rather than the thing an operator is configuring, and it had to be explained every time it appeared besideWeb— which names its source exactly the wayFilenow does. The stored value changes with it, so a data source created under a preview build needs its source rewritten toFile; nothing else about the record moves. See File Sources. -
A file source is its own record, in its own store, on its own screen. A folder, an FTP server and a website are not the same thing, so they are not the same record:
FileSourceis the record that reads files,WebCrawlerthe one that reads a website, and each has its own store, catalog handler and management screen in both sample hosts.- both derive from
IngestionSource— aSourcenaming what reads it, the data source it fills, whether it is enabled, how often it runs, and its own settings. That is whatIIngestionConnectoris handed, so a connector is written once and serves either kind without knowing which it was given - per-item state is separated the same way.
IngestionItemStateinIIngestionItemStateStorerecords what a run read — an item key, the connector's opaque change token, and the document the item produced — whileWebCrawlStatestays the crawl-specific state the re-index service keeps for a crawler feeding aWebdata source - register the stores with
AddCoreFileSourceStoresYesSql()orAddCoreFileSourceStoresEntityCore()beside the crawler ones - a File sources button sits beside each File data source on the Data Sources screen, so the records feeding a bucket are one click from the bucket itself
- the connector settings editors keep the keep-existing-if-blank handling for secrets, so an FTP or SFTP password survives an edit that changes only a port
- both derive from
-
The manual upload flow is retired. The Upload files action on a data source is gone from both sample hosts. A file source reads a folder or a file server on a schedule, records what it has read, and removes what an item no longer produces; an upload did none of that, so the two paths filled the same knowledge base with objects only one of them could keep in step.
IKnowledgeIngestionServiceis unchanged and is still the one entry point — it is what a run calls for each item it fetched, and what a host calls to ingest a file from its own code.
What an upload costs is now a setting
-
A document too large to index is refused at upload.
InteractionDocumentSettings.MaxIndexableCharacters(default50000) caps the extracted text a document may hold and still be indexed. Over the cap the upload is refused with a message naming the file, its measured size and the configured limit. It used to be accepted and left unsearchable: the file appeared attached, the chat listed it, and every search over it returned nothing — a failure with no symptom until someone asked a question and got an answer that did not use the document they had just uploaded.- the measuring pass runs before ingestion and with figure description off, so an oversized document is refused in seconds instead of after a vision call per figure
0means no limit. It is not a refusal of everything, and a host that sets it is accepting that a document of any length is embedded in full- the count is characters of extracted text, not bytes on disk, because that is what decides whether the document can be embedded — a 40 MB scanned PDF can carry less text than a 200 KB spreadsheet
-
Figure description can be turned off for uploads.
InteractionDocumentSettings.DescribeFiguresInUploads(defaulttrue) controls whether an uploaded document's figures are transcribed by a vision model. Turning it off does not turn off figure extraction: a figure is still pulled out, still stored, still servable and still carries its caption. Only the transcription is skipped, which is the part that costs a model call per figure and turns a seconds-long ingest into a minutes-long one.- description resolves the Vision deployment slot, not the utility deployment. With none configured nothing is described whatever this is set to, so leaving it on costs nothing on a host without one
-
Both settings take a per-profile override.
DocumentsMetadata.MaxIndexableCharacters(int?) andDocumentsMetadata.DescribeFiguresInUploads(bool?) override the site value, wherenullmeans "use the site setting". An AI template carries the same two fields, so a profile created from a template starts with them. Both appear on the AI Profile and AI Template editors in the sample hosts, under the switch that allows session document uploads.- applying a template previously carried no document settings onto the new profile, so a profile built
from a template came out with uploads switched off. It now carries
AllowSessionDocuments,AllowSessionImageUploads,DocumentTopN,RetrievalModeand the two new fields
- applying a template previously carried no document settings onto the new profile, so a profile built
from a template came out with uploads switched off. It now carries
See Document Processing.
Change Logs
-
Fixed: several references written in one bracket reached the reader as a raw marker. A reference is keyed by the literal string the model is asked to type —
[doc:1]— and the renderer replaces exactly that. Models routinely gather several into one bracket instead, and[doc:1, doc:2]matches no key, so nothing was replaced. Observed mid-sentence in a real answer: "…calculating U-values [doc:1, doc:2]." — sitting beside citations the model happened to write singly, which rendered correctly. The combined form is now split into the markers it means before anything looks for a key, in the shared marker module so all three chat surfaces do it identically, and on the clipboard path as well as the display one. -
Fixed: a search provider that was never configured reported a failed existence check instead. Creating an index profile on a host with no connection string said "Unable to validate whether the remote index 'data-sources' already exists" — on the first screen of setup. The provider had already said exactly what was wrong; the message was replaced on the way out, turning a one-line configuration fix into a hunt for a fault that was not there. A configuration failure now carries the provider's own words and names the provider to configure. Failures that are not configuration problems still report as unvalidated.
-
A chart read out of a document is now drawn as a chart, not shown as a picture of one. The numbers reached the model and nothing could render them, so a reader who asked for a chart was told the values were machine-readable and then handed the figure image — the answer and the page disagreeing about what had been retrieved. At
Exactconfidence the knowledge tool now emits the host's existing[chart:…]marker alongside the series, drawn by the same parser the chart tool's output goes through.- it is a scatter with the line shown rather than a line chart: the points carry their own x values read off the axis, and a line chart would space them evenly and quietly redraw the data
- the confidence gate is applied again at this point rather than trusted to have happened upstream. A canvas presents whatever it is given as measured data — there is no way to plot a value and have it look like an estimate
- no legend is drawn when no series was named. "Dataset 1" over unnamed series carries the authority of a printed key and says nothing
- a test sends a marker the host actually produced through the parser that has to read it. Each side had its own tests and both could pass while disagreeing about the string between them; the awkward parts — nested arrays, an axis title containing a bracket, escaped quotes in a label — are in that string
-
The SignalR client is pinned, and its bytes are checked. Both sample hosts loaded
@microsoft/signalr@latestfrom a CDN, which cannot carry an integrity hash — a floating version is by definition not the bytes you reviewed, and the hosts are a starting point others copy. All five script tags now name10.0.11, the versionlatestresolved to, with anintegrityhash andcrossorigin, so the browser refuses anything else. -
The client-side unit tests run in CI. The marker and citation rules are shared by three chat surfaces and have no .NET coverage, so nothing in the build noticed them breaking;
npm testran one file of the four and no workflow ran Node at all.npm run test:unitnow runs all of them, in both the pull-request and main workflows, and the files are listed rather than globbed because the runner is pinned to Node 20. A test guards the list: adding a*.test.jsfile without registering it fails and names the file, since a test that never runs looks exactly like a test that passes. -
A data source attached to a profile can now be restricted to kinds of knowledge.
AIDataSourceRagMetadata.ObjectTypesnarrows preemptive retrieval to particular kinds — only figures and charts, or only text — the way a tool instance could already be pinned through its own settings. The same knowledge base used to narrow one way when the model chose to search it and not at all when the profile searched it preemptively.- the restriction is part of the filter rather than applied to the results, so a narrowed search returns the requested number of what was asked for, instead of that many of everything with most then discarded
- asking for text also admits rows written before typed knowledge existed, which carry no kind at all. A clause that omitted them would make "only text" return nothing on exactly the corpus that is entirely text
- pictures are fetched by a second search of their own, since a plain search ranks prose about a subject above pictures of it. That pass re-reads the restriction rather than assuming the first search dealt with it: a profile narrowed to text is not handed pictures through a door the filter never covered
- building the clause is now one implementation shared by both paths, so a tool instance and a profile cannot disagree about what a restriction means
- both hosts' AI Profile screens edit it, as a comma-separated list beside the RAG filter
-
Fixed: one source cited several times over reads as several sources agreeing. Retrieval returns one reference per chunk, so an article that answered a question through three of its chunks arrived as three references and was numbered three times. The sentence ended with "1,2,3" and the list beneath it printed the same title three times over. Observed against a real magazine: an answer carried three citations that all read
MAGYAR ÉPÜLETGÉPÉSZET, which tells a reader three independent sources support the claim where one does — the failure is in what the reader concludes, not in what was retrieved.References that would print the same line and lead to the same place are now one citation, numbered once. The identity ignores case and surrounding whitespace, because neither is a difference the reader can act on; a different page, a different title or a link to a different figure keeps the citations apart.
- the marker also carries its source as a tooltip, so which source a number stands for is answered where the question is asked rather than by looking away and matching a number against a list. The list stays, since a tooltip is not reachable by keyboard and cannot be the only place a source appears
- the model writes its own punctuation between references, so a merge used to leave that comma standing between a number and itself and print "1,1". A repeated marker and whatever was joining it to its neighbour are removed together; the same source cited again later in the sentence keeps its own marker
- the rules live in
chat-markers.jswith the figure and chart marker readers, so the three chat surfaces — the two shared chat scripts and the MVC chat-interaction view — cannot drift about which references are the same citation. Each still assembles its own citation list, since they differ legitimately about generated files. Copying a response replaces every reference key a citation absorbed, not just the first
-
Fixed: renaming or dropping a table left the workspace permanently broken. A command batch is allowed to reshape the workspace, but the metadata it is rebuilt from on every call was never reconciled afterwards. Renaming a table left the metadata pointing at a name that no longer existed, so every later call still reported the table as loaded while every query against it failed with no such table — and the assistant concluded the uploaded file had been lost and asked the user to upload it again. A table created with
CREATE TABLE … AS SELECThad the opposite problem: it was invisible to the caller that had just created it. The metadata is now reconciled against the database after every command batch, and a table built this way is registered, listed, and kept when the attached documents change. -
Fixed: a disposed service provider could end a response with an error. Usage accounting runs as a response finishes, by which point the scope that owns the provider may already be gone — the caller navigated away, or sent again before the first answer completed. The resulting
ObjectDisposedExceptionsurfaced to the user as "an error occurred while streaming the response". The record is now dropped instead: accounting is not worth failing an answer the reader already has. -
Fixed: tables in chat rendered as unformatted text. Assistant replies are parsed as Markdown and a table becomes real table markup, but nothing styled it, so it appeared as cramped, borderless text. Chat tables now have borders, header shading, padding, banded rows, honored column alignment, and their own horizontal scroll so a wide table does not stretch the conversation.
-
Added: a generated workbook can carry several worksheets. A multi-sheet upload is loaded as one table per worksheet, but an export could only ever produce a single sheet and refused outright when more than one table was loaded. Exporting without a query now writes every loaded table as its own tab, each with its own formatting, charts, and totals; worksheet names are made unique, and asking for a single-table format such as CSV explains what to do instead.
GeneratedFileContent.Sheetscarries them, falling back to the existing single-table members when it is empty, so writers that handle one table are unaffected. Word and PDF output renders each table under its own heading. -
Added: exports inherit the number formats the uploaded file used. The reader already parsed the workbook stylesheet to recognize dates but discarded everything else, so a column that was currency or a percentage in the upload came back as a bare number. Each column's dominant source format is now carried through the import and applied on export as a default that any explicit request overrides. Formats that say nothing about presentation — the general format, and the text format, which would fight the column's own storage type — are ignored.
-
Added: merged cells, named ranges, and sheet protection.
format_tabular_dataacceptsmerged_cells,named_ranges, andprotect_sheet. A range outside the sheet, or a defined name the file format cannot accept, is dropped rather than written: an invalid reference makes the whole workbook fail to open, which costs far more than the flourish it expressed. Sheet protection discourages accidental edits and is not a security control. -
Fixed: a chat deployment declared from a connection could not use tools at all. A deployment synthesized from a connection's
ChatDeploymentNamedeclared only thetextGenerationfeature, soModelFeatureEnforcementremoved every tool from every request before it was sent. The model would describe what it was about to do and then call nothing, with the cause visible only as a warning in the log; a connection deployment is read-only in the UI, so an operator had no way to declare the missing feature by hand. Chat deployments now declaretoolCallingandstreamingalongsidetextGeneration, and the utility deployment declaresstreaming. An explicitly configured deployment still declares exactly what its own configuration says. -
Fixed: a generated file could shadow a completed tabular export. After exporting the real rows, the model would sometimes call
generate_fileas well, author its own version of the same spreadsheet from remembered values, and hand the user that one instead — with nothing to indicate the figures were invented. Creating a tabular file is now refused once an export has produced one in the same response, and the refusal returns the existing download marker. Non-tabular follow-ups, such as a PDF summary of the same analysis, are unaffected. -
Fixed: a calculated column could arrive as a header with nothing underneath it. A formula is resolved against the header the export actually produces, and a reference that did not match was dropped, leaving the column empty. An empty column reads as lost data rather than as a mistake in the request, and nothing said which name had failed. The export now stops before writing the file and names the columns the sheet does have. The surrounding cause is also addressed: exporting without a query delivers the loaded tables untouched, so a formula recorded against a joined report had nothing to point at, and the tool description, its
sqlparameter, and the agent prompt now say so plainly. -
Fixed: a request that only changed how a file looks was answered without producing one. "Freeze the header" and "shade alternating rows" read as cosmetic touches the assistant could apply itself, so it called no tool at all and the user was told the change had been made with nothing to download. Styling lives in a file only the tabular agent can rebuild, and the delegation guidance now says so.
-
Fixed: generated Word documents printed HTML and Markdown as literal text.
WordGeneratedFileWriterhad the same defect as the PDF writer, adding each line of the body as its own verbatim paragraph. It now shares the same parser, so headings, lists, quotes, code, rules, tables, and inline emphasis are rendered, and hard-wrapped prose flows into paragraphs. Validating the result also surfaced two long-standing schema faults in its table output — the border elements were written in the wrong order and the table grid was missing entirely — which a word processor is entitled to reject. -
Fixed: formatting was dropped from any export built by joining tables.
format_tabular_datarequired the formatting to belong to a single source table and refused the call when several tables were loaded. A comparison report is almost always a query across two tables, so the tool failed repeatedly and the file exported unformatted while the assistant reported success. Formatting recorded without naming a table is now kept for the export itself and applied to whatever the next export produces; naming a table still formats that table alone. -
Fixed: a computed value from SQL was exported as text. Column typing rejected any number with more than fifteen digits so that long account numbers kept every character, but that also caught ordinary arithmetic: a variance returned as
-3313.2599999999948landed in the sheet as text that could not be summed. The digit ceiling now applies only to whole numbers, where it guards identifiers; a value with a decimal point is a measurement and is stored numerically. -
Fixed: generated PDFs printed HTML and Markdown as literal text.
PdfGeneratedFileWritersplit the body on newlines and added each line as its own verbatim paragraph, so a model that answered with markup produced a PDF full of visible<div>and<strong>tags,&entities, and stray asterisks — and hard-wrapped prose arrived as a stack of one-line fragments. Body text is now parsed before it is laid out: headings, bulleted and numbered lists, block quotes, fenced code, horizontal rules, pipe tables, and inline bold, italic, code, strike-through, and links all render as real document structure, and wrapped lines join into flowing paragraphs. HTML bodies are converted rather than rejected, withscript,style, and comment content dropped entirely so it cannot leak onto the page as text. Plain prose is unaffected, and text that merely contains a comparison such asa < bis not mistaken for markup. The parser lives inCrestApps.Core.AI.Documents.Generation.RichTextso other writers can adopt it. -
Fixed: every cell in a generated spreadsheet was written as text. An exported
.xlsxwrote all of its values as inline strings, so a column of amounts arrived as text that looked right but could not be summed, sorted, or charted, and no number format applied to it. Numeric and date columns are now detected and written as real numbers and date serials. Detection is deliberately conservative: a column is only stored numerically when every populated value is a plain number, and a value with a preserved leading zero — a postal code or an account number — is kept as text so it is not silently mangled. -
Added: generated spreadsheets can be formatted. The Tabular Data Agent gained a hidden
format_tabular_datatool that records how an exported workbook should look, whichexport_tabular_datathen applies. It covers number formats (currency, accounting, number, percent, date, date/time, time, duration, scientific, text, or an explicit format code, with decimal places, a currency symbol, and negatives in red), cell styling (bold, italic, underline, font, colors, alignment, wrapping, borders, column widths), sheet layout (worksheet name, styled header, frozen header, filter dropdowns, banded rows), and conditional formatting (gradient color scales, data bars, icon sets, duplicate highlighting, and value comparisons). The specification is stored alongside the workspace data, so formatting requested in one turn still applies to an export in a later one, and a follow-up request refines it rather than replacing it. Formatting requiresAddOpenXml(); other formats such as CSV ignore it and export the data alone. -
Added: spreadsheets can carry live formulas and a total row. A calculated column is declared by naming a column that does not exist yet and giving it a formula, which references other columns by name (
={Actual}-{Planned}) and is resolved to real cell references per row, so the delivered workbook recalculates when the recipient edits it. A total row is written withSUBTOTALso it follows the recipient's filtering instead of silently reporting the unfiltered figure. A formula that references a column which does not exist is dropped rather than written, because a broken reference makes the whole workbook open with an error. -
Added: charts can be embedded in a generated spreadsheet. Column, bar, line, pie, and area charts are written as native chart parts bound to the worksheet's own cell ranges, so they redraw when the data changes rather than being a picture of it.
-
Fixed: chart requests over tabular data could not succeed.
generate_chartaccepted only a prosedata_description, which a second model then had to convert back into a chart configuration under a 2,000-token ceiling — so a chart of more than a handful of points came back truncated and unparsable, and the caller saw only "failed to generate valid chart configuration". The tool now accepts the actual values aslabelsandseriesand builds the configuration directly from them, with no second model and no size ceiling; the prose path remains as a fallback with a higher limit and error messages that say how to retry. Separately, the Tabular Data Agent could not callgenerate_chartat all — it was absent from the agent's tool list, so the participant holding the data had no way to chart it. It is now included. -
Changed: the Tabular Data Agent no longer asks for files to be re-uploaded. Its prompt now states that the workspace stays loaded for the whole conversation, including every applied change, so the agent re-exports from the workspace instead of asking the user to upload a file it generated itself. It is also instructed not to describe a file as formatted, sorted, or charted unless the tool call that does it actually succeeded, and
format_tabular_datareports column names that did not match instead of silently dropping that formatting. -
Added: uploaded knowledge files can now be downloaded again. Files attached to an AI Profile or a Chat Interaction are stored on the server, but there was previously no way to retrieve the original file after uploading it. Each attached document now shows a download button next to its remove button in both the MVC and Blazor hosts. Downloads are served by the existing
ai/documents/{documentId}/downloadendpoint, which now authorizes AI Profile documents in addition to Chat Interaction and chat-session documents. Hosts authorize profile downloads through the same resource-basedAIChatDocumentOperations.ManageDocumentsrequirement; the sample hosts register aSampleAIProfileDocumentAuthorizationHandlerthat grants it to administrators. -
Fixed: totals from an uploaded spreadsheet could come back several times too large. A worksheet that prints its own subtotals — a per-group
... Totalline beneath each group, then a grand total beneath those — used to import every one of those rows alongside the rows they summarize.SUMover the column then counted each underlying row once as itself, again inside its group subtotal, and a third time inside the grand total, returning exactly three times the true figure on a two-level sheet. The number looked plausible and matched nothing: not the sheet, and not the agent's own row-by-row breakdown.Two terms below, since the distinction is what decides whether a total is right. A rollup row is a row whose values aggregate other rows of the same table — a group subtotal, or a grand total over those subtotals. It restates figures already present, so it double-counts. A row total column is the opposite and is harmless: a column that aggregates other columns within its own row, such as a
Totalcolumn adding one record's components. Every row still represents one record, so summing that column down the rows is exactly correct. Duplication only arises along the axis aSUMtravels, which is rows — there is no such thing as a "rollup column".- Rollup rows are now separated at ingestion into a sibling
<table>_rollupstable, so the data table sums correctly with no filter at all. The previousis_subtotalflag column is gone: excluding rollups by convention only worked while every query remembered the filter, and a forgotten one failed silently. Nothing is discarded — the sheet's printed totals stay queryable in the sibling table, whichlist_tabular_datadescribes as a reconciliation aid rather than as data to add in. - A row is classified primarily by its formula rather than its label. A vertical aggregate
(
=SUM(C2:C27), covering other rows) is a rollup; a cross-column line total (=SUM(G27,E27,H27), covering only its own row) is an ordinary record and stays, since summing that column down the rows is exactly right. Criteria-based lookups (SUMIF,SUMIFS,SUMPRODUCT) and any reference into another worksheet are excluded from the decision, so a sheet that pulls actuals alongside projections on every record is not mistaken for a sheet of rollups. - Labels remain the fallback for CSV/TSV and value-only workbooks, and now require the label to be a
total word or to end in one. A client named
Total Wine & Moreis no longer treated as a subtotal. - Detection covers the whole worksheet. The streaming importer previously decided whether a sheet had rollups from only the rows it buffered for profiling, so a sheet whose first rollup fell past that window silently kept every rollup as data.
- Rollup rows are now separated at ingestion into a sibling
-
Fixed: a calculated cell lost its value when its formula was read. Cells carrying both a formula and a cached result returned empty, because reading the formula advanced the XML reader past the value element. Every computed column — row totals, ratios, variances — imported blank.
-
Repeated column headers under a merged banner row now carry the banner into their names. A sheet laying the same eight columns out under
Sep,Oct, andNovbanners imported asTotal_Revnue,Total_Revnue_2, andTotal_Revnue_3, with the banner row discarded, leaving nothing to say which month was which. They now import asTotal_Revnue_2026_09_01and so on. -
Comparing a figure across two uploaded spreadsheets is now computed instead of narrated. Asked to compare a measure between two files, the tabular agent used to run one query per file and merge the two result sets in its answer text. Nothing could observe that merge, and it failed quietly: a key present in only one file was reported as
$0.00rather than as a mismatch, so real revenue read as missing, and every total had to be summed unaided.- A new hidden
compare_tabular_datatool takes one aggregatedSELECTper side, each returning a key and a numeric measure, joins them in code, and reports each difference plus reconciling totals. Keys found on only one side are listed as unmatched, never as zero. When no keys match — which normally means the two key columns describe different things, such as office locations against company names — it refuses and returns sample keys from each side instead of a plausible-looking table. query_tabular_datanow appends the computed totals of every fully numeric column to a multi-row result, so a figure that used to be added up by hand arrives already summed.- When a turn produces a second key/measure result drawn from a different uploaded file,
query_tabular_dataappends a note pointing atcompare_tabular_data. Re-running one side to harmonize its key names is unaffected. - A failed tabular query that names a column or table that does not exist now returns the available tables and columns alongside the SQLite error, so the next attempt has something concrete to correct against.
- Both tools log their SQL and their result shape at
Debug, which previously could not be recovered from a completed request at all. list_tabular_dataandget_document_metadatanow flag a numeric column that looks like the sum of its numeric siblings (for exampleTotal_RevenuealongsideProjected_RevenueandAncillary_Revenue), so a grand-total figure is not read off a component column that only looks plausible.
- A new hidden
-
Fixed: a reasoning effort of
Nonewas rejected by newer Azure OpenAI reasoning models. The Azure completion client mappedReasoningEffort.Noneonto the SDK'sChatReasoningEffortLevel.Minimal, which sends"minimal". Models that accept onlynone,low,medium,high, andxhighfailed every request withHTTP 400 (invalid_request_error: unsupported_value). It now maps toChatReasoningEffortLevel.None, which sends the"none"those models expect. -
Improved web-crawler-backed data-source retrieval so preemptive RAG and the
search_data_sourcestool expand their candidate pool, filter out obvious non-page assets and error pages, and preserve the raw user query alongside derived search terms. This prevents high-scoring boilerplate from displacing relevant pgvector hits, and the sitemap web crawler now skips non-HTML responses such as.kmlassets during future indexing runs. -
New strategy-based web crawlers (
CrestApps.Core.AI.WebCrawlers). Public websites can now populate a knowledge base directly. A newWebAI data source acts as a target bucket, and separate web crawler records — each choosing a scraping strategy and pointing at aWebdata source — manage the sites to scrape, so many sites can map into a single knowledge base. The first strategy is sitemap discovery (flaturlset, nestedsitemapindex, gzip and plain-text sitemaps, RSS/Atom feeds, androbots.txtadvertisements); the strategy contract (IWebCrawlerStrategy) makes future strategies (for example depth-limited link following) drop-in. AWebCrawlerReindexBackgroundServicere-crawls each crawler on its own cadence and re-indexes only the pages whose<lastmod>changed (adding new pages and removing deleted ones), backed by per-crawler crawl-state stores (YesSql and EntityCore). The scraped page URL is kept for citations. Opt in withAddCoreWebCrawlers()plusAddCoreWebCrawlerStoresYesSql()orAddCoreWebCrawlerStoresEntityCore(), and both sample hosts ship a Web Crawlers management UI. -
New
CrestApps.Core.DataIngestionpackage. A reusableHtmlIngestionDocumentReaderforMicrosoft.Extensions.DataIngestionturns HTML into anIngestionDocumentfor chunking and embedding. Because scraped pages are untrusted, it parses the HTML with a standards-compliant HTML5 parser (AngleSharp) rather than pattern matching, removingscript,style, and other non-content nodes together with their contents so no markup or executable code survives into the embedded and stored text. The web crawlers use it to ingest scraped pages. -
Tool instances now support user-declared parameters. A tool instance can declare typed, described parameters instead of leaving the AI model to guess at a free-form argument bag. Each parameter declares who fills it — the model, a fixed value the user pins, or the ambient request context — plus a placement telling the owning source where the resolved value belongs.
- Parameter support is opt-in per source. A source advertises the placements it can honor via
the new
AIToolInstanceParameterCapabilitieson its registration entry. Sources that declare none hide the parameter editor and reject saved parameters, so a parameter can never be declared in the schema, filled by the model, and then silently dropped at invocation time. - The built-in HTTP API request source accepts parameters in the query string, the URL path (via a
new
PathTemplatesetting with{token}substitution), the JSON body (dotted paths such ascustomer.id), and request headers. Model-supplied header values are refused: a prompt-injected model must not be able to set arbitrary request headers. - Context-filled parameters (
user.id,user.name,user.email,resource.id,now.utc, extensible throughIAIToolParameterContextResolver) are resolved server-side and never appear in the schema, so a per-user API can be called without exposing a spoofable identifier to the model. - Values are coerced to the declared type, validated against any allowed-value set, and defaults are applied server-side. A parameter that cannot be resolved returns a tool error the model can correct and retry, rather than throwing.
- Binding a parameter to a placement closes the corresponding free-form argument, which lets an otherwise fully declared function opt into provider strict schema mode.
- Path and query values are escaped, and header values carrying line breaks are dropped, so a model-supplied value cannot traverse the URL or split the request.
- Both sample hosts ship a parameter editor with per-row fill/placement fields, inline name validation, secret handling, and a live request preview. Instances that declare no parameters are unaffected — their schema is byte-identical to before.
- Parameter support is opt-in per source. A source advertises the placements it can honor via
the new
-
Anonymous chat rate limiting is now multi-tier: both the per-message limiter and the session-start limiter evaluate a configurable list of
{ limit, window }tiers and throttle a request when it would exceed any tier. The default message tiers are 5 / 30s, 30 / 5min, 150 / hour, and 500 / day; the session-start tiers use a stricter 5-minute cap — 5 / 30s, 10 / 5min, 150 / hour, 500 / day — because a normal visitor rarely starts many sessions in a short span. This absorbs normal usage (the previous single 5-per-10-minute session-start default tripped on legitimate traffic, especially since the limit is keyed by a hash of the visitor's IP so everyone behind a shared NAT or corporate proxy shares one bucket) while still catching bursty bots. New site optionsAnonymousMessageRateLimitTiersandAnonymousSessionStartRateLimitTiersonPromptSecurityOptions, with matching nullable per-profile overrides onPromptSecurityProfileSettings, configure them, and both sample hosts expose a tier editor in the admin settings. The tiers apply to anonymous traffic only — authenticated callers keep the single-windowMaxMessagesPerWindowlimit and never hit session-start throttling. When a tier list is empty, the limiter falls back to the existing single-window values (MaxMessagesPerWindow/RateLimitWindowandMaxAnonymousSessionsPerWindow/AnonymousSessionRateLimitWindow), so existing configurations keep working. -
Authenticated message throttling is now keyed by the caller's network address in addition to their user identity (
AuthenticatedMessagePartitionsdefaults toAuthenticatedUser | NetworkAddress). The network-address bucket is shared with anonymous throttling and retained long enough to cover the anonymous tiers, so a caller can no longer reset their per-IP allowance by logging out — authenticated activity accrues against the same per-IP bucket the anonymous limiter checks. Authenticated callers keep their single-windowMaxMessagesPerWindowlimit (no new per-user ceiling); the trade-off is that authenticated users behind one NAT/proxy share an IP bucket (removeNetworkAddressfromAuthenticatedMessagePartitionsto opt out). Internally the message limiter now evaluates per-key groups so the shared IP bucket keeps a consistent retention window and is never evicted out from under the anonymous accounting. -
When a message is blocked because the caller exceeded the message rate limit, the chat hub now returns a "slow down" message with the retry-after delay instead of the generic "rephrase and try again" prompt-blocked message, so the user knows to wait rather than rewording. Other security blocks (such as injection detection) still return the generic message so detection details are not disclosed. The message is overridable via the new
GetRateLimitedMessagehook on the chat hub, and a rate-limit block is identified byPromptSecurityResult.RateLimitDetectionRule. -
Chat Interactions now wrap the user's message in input-boundary delimiters (governed by the new
EnableChatInteractionInputDelimitersoption, on by default). Chat Interactions still do not receive the AI Profile security preamble or injection blocking — the operator controls the prompt, model, and tools — but clear input boundaries keep the model from confusing the user's message with system, tool, or agent content, which matters most when many agents and tools are involved. -
Reworked multi-worksheet XLSX ingestion so each worksheet is imported as its own independent tabular table, enumerated in workbook order with its name preserved. Previously every sheet was flattened into a single table, worksheet names were lost, and a data row could be promoted to the header. Hidden and very-hidden worksheets are now skipped by default.
-
Added per-worksheet header detection that skips title and banner rows above the real header, so a workbook whose data does not begin on the first row is imported against the correct column names instead of a title row.
-
Tabular columns are now typed as
INTEGER,REAL, orTEXTfrom the row data rather than the spreadsheet cell format, so numeric comparisons, sorting, and aggregation work correctly. Numbers stored as text — currency such as$1,234.50, thousands-separated values such as1,000, and accounting-style negatives such as(100)— are recognized and normalized so aggregates are correct, while percentages and leading-zero identifiers such as zip and account numbers are kept as text. -
Embedded subtotal and total rollup rows are detected and flagged in an
is_subtotalcolumn so aggregate queries can exclude them withWHERE is_subtotal = 0instead of double-counting. The rows are kept, not dropped, so a heuristic misfire never loses data. -
Populated columns that have no header in the source are imported under a generated
column_Nname instead of being silently dropped. -
Excel date and date/time cells are converted to ISO-8601 strings on import, so dates sort chronologically and work with SQLite date functions.
-
get_document_metadatanow describes every worksheet of a multi-worksheet workbook instead of only the first, matching the table list returned bylist_tabular_data. -
Values that cannot be parsed to a numeric column are preserved as text rather than dropped, so an occasional malformed row never fails the import; the value is kept in place and aggregates coerce it to zero.
-
Updated the Tabular Data Agent guidance to select the table and columns that most directly answer a question, avoid summing granular ledger tables across periods without a filter, and keep a per-group breakdown consistent with the total it reports.
-
changes release documentation versioning so stable
vX.Y.Ztag pushes create a pull request with the generated Docusaurus version files instead of generating versioned docs inside the Pages deployment artifact; the workflow also supports manual runs forvX.Y.Z,X.Y.0,vX.Y, orX.Yinputs, creates theX.Ydocs version from patch tags only when that major/minor docs version does not already exist, and skips prerelease tags successfully after logging why no docs version PR was needed -
Fixed: one of two AI provider connections whose names differ only by case disappeared without a trace. A connection's identifier hashes its lowercased client and connection name, so
Shared-Azuredeclared underCrestApps:AI:Connectionsandshared-azuredeclared underCrestApps:AI:Providers:Azure:Connectionsresolved to the same identifier, and the second silently replaced the first — taking its endpoint and its credentials with it. Every deployment naming that connection then reached whichever resource happened to be read last, which surfaces as an unexplainedDeploymentNotFoundwhen the model is deployed only on the other one. The first definition now wins, matching how configured deployments are read, and the dropped entry is logged as a warning that names the entry it collided with. A site that has such a pair today will switch to the earlier definition; the warning names both, so the duplicate can be renamed. -
A failed Azure OpenAI completion now names the deployment and endpoint that served it. The provider's own exception names neither, so a misrouted request — a deployment bound to the wrong connection, or a model name that does not exist on the resource that connection points at — read as a bare HTTP error with nothing to say where the request went. Both the streaming and non-streaming paths now log the deployment name, the model name that forms the request URL, the connection name, and the endpoint host.
-
The anonymous visitor cookie can now survive inside a frame on another site. The cookie is written
SameSite=Lax, which a browser refuses in a third-party context and reports as "Cookie 'crestapps-ai-visitor' has been rejected because it is in a cross-site context". A chat embedded on a customer's site therefore looked like a brand new visitor on every request: the conversation did not survive a page load, and theVisitorrate-limit partition never accumulated, so it stopped contributing to abuse control and throttling fell back to the coarser network-address, session and connection keys.AIVisitorIdentityOptions.AllowCrossSiteEmbeddingwrites the cookieSameSite=None; Secure; Partitionedinstead, which is the only combination a browser keeps in a frame. It is off by default, so nothing changes for a chat served from its own site. The cookie staysHttpOnlyand identifies a visitor rather than authenticating one, so it grants no privilege of its own.UsePartitionedCookie(on by default) keeps the framed cookie in its own jar, so it cannot replace the first-party cookie of the same name and downgrade that one toSameSite=Noneas well.SameSite=Noneis only legal together withSecure, so a request that did not arrive over HTTPS keeps theLaxcookie rather than losing it altogether. -
Every type under
srcnow lives in a file named after it. Framework sources had accumulated files holding anything from two to twenty-nine top-level types —McpOptions.csalso declaredMcpResourceTypeEntry, each YesSql*Index.csalso declared its*IndexProvider, andPromptSecurityBuiltInRules.csdeclared twenty-nine rule classes behind a name that matched none of them — so finding a type meant grepping for it rather than opening the file it should have been in. 226 types moved into their own files, and a handful of files whose single type had drifted from the file name were renamed to match (OrchestratorContext.csheldOrchestrationContext,IMcpServerMetadataProvider.csheldIMcpServerMetadataCacheProvider). This is a source-layout change only: no type, namespace, member or accessibility was altered, so nothing that compiles againstCrestApps.Coretoday needs to change. Razor page models keep the*.cshtml.csnames ASP.NET requires.