Skip to main content
Version: Latest

Version 2.0.0 Release Notes

Package version: 2.0.0

info

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.FileSources referenced CrestApps.Core.AI.Documents for one reason — the knowledge ingestion contract — but the only way to register it was AddCoreAIDocumentProcessing(), 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 and IKnowledgeIngestionService now 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.Ingestion namespace — IDocumentFileStore, FileSystemFileStore, DocumentFileSystemFileStoreOptions, IImageAnalysisService, ChatDocumentsOptions and ExtractorExtension. Update the using and 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() and AddCoreFileSources() both opt into all three, so a host calling either behaves exactly as before
    • AddCoreFileSources() now registers the ingestion path itself. A file source host needs nothing from CrestApps.Core.AI.Documents. A host that wants chat document processing as well still calls AddDocumentProcessing() before it, because keyed readers resolve to the last registration and the HTML reader has to win .html over the plain-text one
    • the Files data source entry moves to AddCoreFileSources(), matching how the web crawlers package already registers its own Web entry. 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.Pdf into CrestApps.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, and AddCoreAIPdfDocumentProcessing() 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, .xlsx and .pptx are read by CrestApps.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
  • CrestApps.Core.AI.Indexers is renamed to CrestApps.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 shapes FileSourceMetadata, FileSystemFileSourceMetadata, RemoteFileSourceMetadata and FileSourceRunSummary.

    • the configuration section follows the package, from CrestApps:Indexers to CrestApps: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 deployed appsettings.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 reads CrestApps:AI:FileSources and falls back to CrestApps:Indexers when the new section is absent. One section wins outright rather than the two being layered: AllowedLocalRoots is 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, Ftp or Sftp
  • The FTP and SFTP packages are consolidated, one per protocol. CrestApps.Core.AI.Mcp.Ftp and CrestApps.Core.AI.Mcp.Sftp are replaced by CrestApps.Core.AI.Ftp and CrestApps.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, FtpConnectionMetadata lived 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] becomes CrestApps.Core.AI.Ftp[.Models|.Handlers], and the ingestion connectors move off CrestApps.Core.AI.FileSources.FileTransfer.Ftp onto CrestApps.Core.AI.Ftp. The shared CrestApps.Core.AI.FileSources.FileTransfer base (IRemoteFileClient, RemoteFileIngestionConnector) is unchanged and stays in CrestApps.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 strings Ftp and Sftp
  • AIDeploymentPurpose is 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, and AIDeployment.CanServeTextCompletion()
    • removed from IAIDeploymentManager: GetByPurposeAsync, GetAllByPurposeAsync, the purpose and type overloads of ResolveOrDefaultAsync and GetDefaultAsync, GetByTypeAsync, and GetAllByTypeAsync. Use ResolveSlotAsync and GetAllBySlotAsync
    • AIDeploymentManagerExtensions.ResolveAsync(purpose, …) becomes ResolveSlotOrThrowAsync(slotName, …)
    • AIDeploymentConfigurationEntry.Purpose / .Type become LegacyPurposes (string[])
    • AICompletionServiceBase.ResolveDeploymentAsync takes a slot name instead of a purpose
    • Stored data needs no migration. A record that still carries Purpose, Capability, or Type is projected onto capabilities every time it is read
  • ChatMode.Realtime and AIProfile.RealtimeDeploymentName are removed. Whether a profile or interaction is a speech-to-speech conversation now follows from its chat deployment declaring the realtime capability, 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.RealtimeDeploymentName is [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.DefaultRealtimeDeploymentName still backs the realtime slot 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 RealtimeDeploymentName folds it onto ChatDeploymentName when read, and its old chat deployment moves to UtilityDeploymentName when the profile has not named one — so background work keeps running on the model it always did. A stored ChatMode of "Realtime" reads as TextInput instead of failing the settings load
  • Tool authorization now depends on how the AI is invoked, and IAIToolAccessEvaluator is applied in a narrower, more correct place:

    • AI Sessions no longer apply a per-user tool gate. Previously FunctionInvocationAICompletionServiceHandler called IAIToolAccessEvaluator.IsAuthorizedAsync for 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 IAIToolAccessEvaluator and excludes any the caller is not authorized for (reported in a single Warning). System tools and hidden/dependency tools are never checked; a null caller (trusted server-side invocation) skips the check.
    • IAIToolAccessEvaluator and 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.

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 an AIDeployment to name the three deployments to chain.
  • adds CascadedRealtimeClient, composed by IAIClientFactory.CreateRealtimeClientAsync when 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.
  • DefaultRealtimeVoiceResolver now lists the text-to-speech leg's voices for a cascaded deployment, since that is the deployment that actually speaks.
  • DefaultRealtimeVoiceResolver takes an additional IAIDeploymentStore constructor 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/L16 or audio/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.
  • AzureSpeechServiceTextToSpeechClient honors TextToSpeechOptions.AudioFormat instead 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.js now 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, and streaming) and model parameters (configurable options carrying kind, allowed values, ranges, and defaults), registered through the new AddAIModelFeature and AddAIModelParameter service-collection extensions
  • adds AddCoreAIModelCapabilities(), chained automatically by AddCoreAIServices(), 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, and realtime), and the built-in reasoningEffort parameter. 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 realtime capability. A realtime deployment is one whose model declares the realtime feature, and it fills the realtime slot. IRealtimeCapabilityResolver resolves 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.EnabledByDefault so a registered feature can be pre-selected on newly created deployments, and flags toolCalling and streaming as enabled by default
  • adds AIModelParameterDescriptor.RequiredFeature so a parameter can declare a dependency on a trained feature. The built-in reasoningEffort parameter now requires the reasoning feature. IAIModelCapabilityService.GetCapabilities excludes 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 and ToolMode when toolCalling is not declared, removes a JSON response format when structuredOutputs is not declared, removes reasoning options when reasoning is not declared, and when reasoning is declared removes the reasoning effort if the reasoningEffort parameter is not exposed or coerces an unsupported effort to the deployment default. Deployments without metadata stay unconstrained
  • adds a CapabilityEnforcingChatClient that IAIClientFactory wraps 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 an IChatClient and 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 streaming feature at the call site: when a deployment does not declare streaming, a streaming request is transparently completed as a single non-streaming response and replayed as one streaming update. This applies to both CapabilityEnforcingChatClient (the client-factory path) and AzureOpenAICompletionClient, which streams through the Azure SDK directly
  • clears ChatOptions.ToolMode whenever toolCalling is 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.AdditionalProperties to their underlying primitive (Integer, Number, or Boolean) 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 AIDeploymentModelMetadata so 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 AIModelParametersMetadata so AI profiles, AI profile templates, and chat interactions store the selected parameter values, and adds AICompletionContext.ModelParameters plus ApplyModelParameters to 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 matching IAIModelParameterBinder, and otherwise writes the value to ChatOptions.AdditionalProperties
  • adds IAIModelParameterBinder with a built-in reasoning-effort binder that sets ChatOptions.Reasoning.Effort, and updates AzureOpenAICompletionClient to translate the resolved effort onto ChatCompletionOptions.ReasoningEffortLevel so both request paths behave the same. The ExtraHigh effort 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 AzureOpenAICompletionClient path: because that client projects a fixed set of options onto the Azure SDK's ChatCompletionOptions, a custom parameter written only to ChatOptions.AdditionalProperties cannot be forwarded and is logged rather than dropped silently. The built-in reasoningEffort parameter is unaffected because it binds through ChatOptions.Reasoning.Effort
  • adds a ModelParameters front-matter key to the markdown profile template parser, accepting name=value pairs separated by ; or by a new line
  • adds CompletionServiceConfigureContext.Deployment so handlers can resolve the deployment that is being configured. DeploymentName continues to carry the model name
  • updates CrestApps.Core.Mvc.Web with 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-select searchable 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.Web with the equivalent ModelCapabilitiesEditor and ModelParametersEditor components, 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-select searchable multi-select as the MVC editor through an isolated BootstrapMultiSelect component 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 AIDeploymentFeatureNames and to AddCoreAIDeploymentCapabilities(): textEmbedding, speechToText, and textToSpeech. These are deliberately distinct from audioInput and audioOutput, which mean "this chat model accepts or emits audio inline" rather than "this is a transcription or synthesis endpoint" — conflating them would offer gpt-4o-audio in the Whisper slot and Whisper in the chat picker
  • adds a deployment slot registry: AIDeploymentSlotNames, AIDeploymentSlotDescriptor, AIDeploymentSlotOptions, and the AddAIDeploymentSlot service-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 by AddCoreAIServices()) registers chat, utility, embedding, image, vision, speechToText, textToSpeech, and realtime
  • adds IAIDeploymentManager.ResolveSlotAsync and GetAllBySlotAsync. 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 that DefaultRealtimeOrchestrator, AIChatHubCore, and ChatInteractionHubBase each re-implemented is gone
  • ResolveUtilityOrDefaultAsync and AICompletionServiceBase.ResolveRequestDeploymentAsync are 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 returning null. Now that both slots filter on the same textGeneration capability, 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 model
  • DefaultImageAnalysisService now tests the imageInput capability rather than the vision purpose flag, so a genuinely vision-capable model is no longer rejected merely because nobody ticked the flag
  • ConfigurationAIDeploymentSource emits 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 declare realtime as 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, and TextToSpeech add textEmbedding, imageOutput, imageInput, speechToText, and textToSpeech respectively, merging into whatever capability metadata the deployment already declares
  • Chat (or Utility) adds textGeneration only when the deployment does not declare realtime. A speech-to-speech-only deployment is Purpose.Chat with the realtime feature and deliberately no textGeneration; 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:

  1. Code that referenced the removed API — the compiler finds all of it. The mapping is mechanical: ResolveOrDefaultAsync(AIDeploymentPurpose.X, …) becomes ResolveSlotAsync(AIDeploymentSlotNames.X, …), and GetByPurposeAsync / GetAllByPurposeAsync become GetAllBySlotAsync.
  2. 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 existing Values for the chat deployment. Existing stored metadata keeps working unchanged; the new dictionary is simply empty
  • adds AICompletionContext.UtilityModelParameters and AICompletionContext.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 with IsUtilityCompletion
  • resolves the utility deployment (falling back to the chat deployment) in NamedAICompletionClient when IsUtilityCompletion is 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 ModelParametersAICompletionServiceHandler into the new IAIDeploymentParameterApplier / DefaultAIDeploymentParameterApplier, which takes an AIDeploymentParameterScope (Chat or Utility) to select which stored values to read. The handler is now a thin wrapper that picks the scope from the completion context
  • adds ChatClientBuilder.UseModelParameters and UseUtilityModelParameters so background completions that resolve an IChatClient directly from IAIClientFactory — orchestration planning, data extraction, and post-session processing — apply the same operator selections as the completion pipeline
  • adds a UtilityModelParameters front-matter key to the markdown profile template parser, accepting the same name=value pairs as ModelParameters
  • renders a second Utility model parameters editor on the AI profile, AI profile template, and chat interaction screens in both CrestApps.Core.Mvc.Web and CrestApps.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.Web editors passing the literal string "_model.ChatDeploymentName" to ModelParametersEditor.DeploymentName. Razor treats an unprefixed attribute value as a literal for a string parameter, 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. IAIClientFactory gains CreateRealtimeClientAsync(AIDeployment), which returns a Microsoft.Extensions.AI IRealtimeClient for 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 new IAIClientProvider.GetRealtimeClientAsync(connection, deploymentName); providers without a realtime API surface NotSupportedException.
    • OpenAI is implemented via the packaged OpenAIRealtimeClient from Microsoft.Extensions.AI.OpenAI.
    • Azure OpenAI connects directly to the GA realtime WebSocket endpoint (/openai/v1/realtime, the GA session.update schema, 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 pinned Azure.AI.OpenAI targets an older OpenAI SDK than Microsoft.Extensions.AI.OpenAI requires, so the SDK's AzureOpenAIClient.GetRealtimeClient() throws MissingMethodException at runtime; it will be replaced by the SDK path once a compatible Azure.AI.OpenAI ships.
  • Realtime is a model capability. A realtime deployment is one whose model declares the realtime feature, 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 as SpeechVoice[] (mirroring GetSpeechVoicesAsync), resolved through the new IRealtimeVoiceResolver (registered by default, mirroring ISpeechVoiceResolver). OpenAI and Azure OpenAI return the fixed gpt-realtime voice set (alloy, ash, ballad, cedar, coral, echo, marin, sage, shimmer, verse); the realtime API has no enumeration endpoint, so the set is declared in OpenAIRealtimeVoices and 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: IRealtimeOrchestrator builds 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 StreamBufferCapacity so 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_flush to 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), carries session_ready, speech_started, playback_flush, and session_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 AudioWorklet instead of on a requestAnimationFrame loop, 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 failed or incomplete now 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/communications aliases (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_pending event 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 (default true) 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-micconnectinglistening rather 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.
  • UpdateRealtimeSettings applies 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, 0 to 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 — reporting session_ended: idle. RealtimeTransportOptions.MaxSessionDurationSeconds (default 300, 0 to disable) ends one after that long however busy it has been, reporting session_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 presetsHeadset, 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_vad by 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 / TurnDetectionEagerness configure 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_detection is 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 uses CoreAIRealtime.attach like 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.
  • 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 a sitemapindex into 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 in robots.txt when only a base URL is configured. Previously every <loc> ending in .xml was 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 HttpClient now sends an identifiable User-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 query parameter 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/search with _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 (Chunk by default, or Hierarchical), 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 withSearchIndexProfile.EmbeddingDeploymentName, optionally overridden by DataSourceIndexProfileMetadata — 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.
    • Hierarchical retrieval 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 DataSourceRetrievalMode was added alongside the existing DocumentRetrievalMode. The two are separate because DocumentRetrievalMode lives in CrestApps.Core.AI.Documents, which depends on CrestApps.Core.AI — where the new source lives.
  • Refactored the retrieval pipeline behind the profile-bound DataSourceSearchTool into 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 through AgentToolRegistryProvider, which is completion-scoped, so the MCP server had no way to see them.
    • adds McpServerOptions.Agents (an allow-list of agent profile names) and McpServerOptions.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. AgentAvailability is 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
  • 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 AgentProxyTool treated 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 sets AgentMetadata.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. IAIDocumentIngestionPipeline resolves 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 own ProcessAsync(document, cancellationToken) overload still works and runs with DocumentIngestionContext.Default), and IIngestionDocumentReaderResolver
    • register a processor with services.AddCoreAIIngestionDocumentProcessor<T>(). Processors run in registration order, so a host that registers its own before AddDocumentProcessing() runs first
    • adds IngestionDocumentElementExtensions.GetSemanticText(), which is what flattening now uses. It is identical to reading Text for a paragraph; an image yields its description or its caption, and a table with no text of its own is rendered from its cells
    • DefaultAIDocumentProcessingService takes IAIDocumentIngestionPipeline instead of IServiceProvider. A file no reader can handle is still reported as a failure, now carrying the reason rather than a generic message
  • 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 Text and PageNumber, 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. Setting UseLayoutAnalysis = false reproduces 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
  • 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.MinImageSamples is 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.Patterns ships 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 with services.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
    • IFigureCaptionCandidateDetector and IFigureCaptionResolver are replaceable for an unusual layout
  • 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 into Skip, CaptionOnly or Describe.
    • DocumentIngestionContext.FigureMode overrides the score entirely, and MaxFigureDescriptionsPerDocument caps a document's describable figures by demoting the surplus to CaptionOnly rather 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 PatternCaptionScore and TypographyCaptionScore
    • 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 IDocumentFileStore and recorded on the AIDocument as a DocumentFigureList.
    • DefaultAIDocumentProcessingService takes IDocumentFileStore
  • 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() serves ai/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_figure system 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_metadata now 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.UseTextToolResults is 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.EmitDecorationAsHeaderFooter now defaults to true: running heads and printed page numbers stay on the page as header and footer elements marked IsDecoration, and every consumer that embeds text — the chat flattening, the knowledge builder, the caption and salience processors — skips them through the shared IsDecoration() 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,000 no longer reads as 1.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 scores ScannedPageScore and 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-transcription prompt 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
    • IImageAnalysisService gains an overload taking ImageAnalysisRequest, 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

Reading document structure from a service

  • Structure can now be read from Azure AI Document Intelligence instead of inferred. The new CrestApps.Core.Azure.DocumentIntelligence package 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
    • MaxPages caps 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 File data 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.

    • IKnowledgeIngestionService is 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 File source and stops requiring it, as it already did for Web. 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 with AIKnowledgeOperations.ViewFigures
  • Knowledge-base rows now carry their type, parentage and page in real, filterable columns. contentType, rootId, parentId and page were added to the data-source index schema and are written by DefaultAIDataSourceIndexingService. Rows from every other source type carry contentType = "text" explicitly, and every reader treats a missing value as text, 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 recreated
    • IDataSourceContentManager.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 reference
    • SourceDocument.IsPreChunked marks 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 describes
    • DataSourceSearchResult.DataSourceId and AICompletionReference.DataSourceId were 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, parentId and page are now translated as the columns they are, and contentType 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.SearchDetailedAsync returns a DataSourceRetrievalResult carrying the rendered text plus the figures and tables among the hits as objects. SearchAsync returns 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.ContentTypes limits an instance to certain kinds of knowledge, editable in both sample hosts. Asking for text also matches rows written before the column existed
    • a figure line names the address the host serves the picture from (RetrievedFigure.Link), resolved through the same IAIReferenceLinkResolver a citation uses, and that address is what the figure's [fig:N] label is registered against (below); the logical crestapps:// address stays on RetrievedFigure.Uri for MCP clients. get_source prints the same address as an Image: 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 ![Title](Link) 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
  • Figures left pending by an ingest are now transcribed in the background. IFigureDescriptionBackfillService, driven by a hosted job every KnowledgeIngestionOptions.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
    • MaxConcurrentVisionCalls now 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. McpToolResultMapper maps 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 new IAIToolContentProvider.
    • 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 optional contentTypes argument, 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.Knowledge package publishes crestapps://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-object tool instance source exposes get_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 than ChatDocumentsOptions.MaxVisionImageBytesPerFile comes 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 by TocSeededStructureAnalyzer, 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 Excluded so 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
    • IPublicationMetadataExtractor spends 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. PdfIngestionDocumentReader reads the rules a page draws, recovers the grid, and emits an IngestionDocumentTable with 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 is AxesOnly or Descriptive and 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.EmitTables and EmitVectorFigures turn either off

Continuous intake

  • Content can now come from anywhere. The new IIngestionConnector answers 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.

    • FileSystem reads 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 listed
    • Ftp and Sftp read files off a file server, in the new CrestApps.Core.AI.Ftp and CrestApps.Core.AI.Sftp packages, 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
    • IFileSourceRunService does one run and returns an FileSourceRunSummary — seen, ingested, removed, failed, and whether the listing was complete
    • FileSourceMetadata carries per-source FigureMode, VisionDeploymentName, MaxFigureDescriptionsPerDocument and Language, 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. IIngestionDocumentReaderResolver asks 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 WebCrawlerReindexPlanner performs no removals on the strength of it. The result carries the new WebCrawlerReindexStatus.PartiallyDiscovered and a message saying which happened. Previously each of those cases looked exactly like a site that had shrunk.

    • IWebCrawlerStrategy.DiscoverDetailedAsync and ISitemapCrawler.DiscoverDetailedAsync carry the completeness; both are default-implemented, so a strategy outside this repository keeps working
  • Per-source model selection is now enforced and editable. FileSourceMetadata gained UtilityDeploymentName, EmbeddingDeploymentName and MaxItemsPerRun alongside 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. FileSourceRunSummary is 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.MaxConcurrentFetches now 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
    • IIngestionConnectorResolver moved to CrestApps.Core.AI.Abstractions (namespace CrestApps.Core.AI.Indexing) with a shared KeyedIngestionConnectorResolver, registered by both AddCoreWebCrawlers() and AddCoreFileSources()
    • 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 taking FileSourceBackgroundService or 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, .htm and text/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 Ingested data source type is renamed File. AIDataSourceSourceTypes.Ingested is gone and AIDataSourceSourceTypes.File takes 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 beside Web — which names its source exactly the way File now does. The stored value changes with it, so a data source created under a preview build needs its source rewritten to File; 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: FileSource is the record that reads files, WebCrawler the one that reads a website, and each has its own store, catalog handler and management screen in both sample hosts.

    • both derive from IngestionSource — a Source naming what reads it, the data source it fills, whether it is enabled, how often it runs, and its own settings. That is what IIngestionConnector is 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. IngestionItemState in IIngestionItemStateStore records what a run read — an item key, the connector's opaque change token, and the document the item produced — while WebCrawlState stays the crawl-specific state the re-index service keeps for a crawler feeding a Web data source
    • register the stores with AddCoreFileSourceStoresYesSql() or AddCoreFileSourceStoresEntityCore() 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
  • 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. IKnowledgeIngestionService is 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 (default 50000) 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
    • 0 means 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 (default true) 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?) and DocumentsMetadata.DescribeFiguresInUploads (bool?) override the site value, where null means "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, RetrievalMode and the two new fields

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 Exact confidence 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@latest from 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 name 10.0.11, the version latest resolved to, with an integrity hash and crossorigin, 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 test ran one file of the four and no workflow ran Node at all. npm run test:unit now 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.js file 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.ObjectTypes narrows 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.js with 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 SELECT had 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 ObjectDisposedException surfaced 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.Sheets carries 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_data accepts merged_cells, named_ranges, and protect_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 ChatDeploymentName declared only the textGeneration feature, so ModelFeatureEnforcement removed 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 declare toolCalling and streaming alongside textGeneration, and the utility deployment declares streaming. 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_file as 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 sql parameter, 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. WordGeneratedFileWriter had 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_data required 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.2599999999948 landed 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. PdfGeneratedFileWriter split 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, &amp; 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, with script, 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 as a < b is not mistaken for markup. The parser lives in CrestApps.Core.AI.Documents.Generation.RichText so other writers can adopt it.

  • Fixed: every cell in a generated spreadsheet was written as text. An exported .xlsx wrote 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_data tool that records how an exported workbook should look, which export_tabular_data then 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 requires AddOpenXml(); 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 with SUBTOTAL so 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_chart accepted only a prose data_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 as labels and series and 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 call generate_chart at 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_data reports 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}/download endpoint, which now authorizes AI Profile documents in addition to Chat Interaction and chat-session documents. Hosts authorize profile downloads through the same resource-based AIChatDocumentOperations.ManageDocuments requirement; the sample hosts register a SampleAIProfileDocumentAuthorizationHandler that 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 ... Total line beneath each group, then a grand total beneath those — used to import every one of those rows alongside the rows they summarize. SUM over 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 Total column 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 a SUM travels, which is rows — there is no such thing as a "rollup column".

    • Rollup rows are now separated at ingestion into a sibling <table>_rollups table, so the data table sums correctly with no filter at all. The previous is_subtotal flag 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, which list_tabular_data describes 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 & More is 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.
  • 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, and Nov banners imported as Total_Revnue, Total_Revnue_2, and Total_Revnue_3, with the banner row discarded, leaving nothing to say which month was which. They now import as Total_Revnue_2026_09_01 and 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.00 rather than as a mismatch, so real revenue read as missing, and every total had to be summed unaided.

    • A new hidden compare_tabular_data tool takes one aggregated SELECT per 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_data now 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_data appends a note pointing at compare_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_data and get_document_metadata now flag a numeric column that looks like the sum of its numeric siblings (for example Total_Revenue alongside Projected_Revenue and Ancillary_Revenue), so a grand-total figure is not read off a component column that only looks plausible.
  • Fixed: a reasoning effort of None was rejected by newer Azure OpenAI reasoning models. The Azure completion client mapped ReasoningEffort.None onto the SDK's ChatReasoningEffortLevel.Minimal, which sends "minimal". Models that accept only none, low, medium, high, and xhigh failed every request with HTTP 400 (invalid_request_error: unsupported_value). It now maps to ChatReasoningEffortLevel.None, which sends the "none" those models expect.

  • Improved web-crawler-backed data-source retrieval so preemptive RAG and the search_data_sources tool 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 .kml assets during future indexing runs.

  • New strategy-based web crawlers (CrestApps.Core.AI.WebCrawlers). Public websites can now populate a knowledge base directly. A new Web AI data source acts as a target bucket, and separate web crawler records — each choosing a scraping strategy and pointing at a Web data source — manage the sites to scrape, so many sites can map into a single knowledge base. The first strategy is sitemap discovery (flat urlset, nested sitemapindex, gzip and plain-text sitemaps, RSS/Atom feeds, and robots.txt advertisements); the strategy contract (IWebCrawlerStrategy) makes future strategies (for example depth-limited link following) drop-in. A WebCrawlerReindexBackgroundService re-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 with AddCoreWebCrawlers() plus AddCoreWebCrawlerStoresYesSql() or AddCoreWebCrawlerStoresEntityCore(), and both sample hosts ship a Web Crawlers management UI.

  • New CrestApps.Core.DataIngestion package. A reusable HtmlIngestionDocumentReader for Microsoft.Extensions.DataIngestion turns HTML into an IngestionDocument for chunking and embedding. Because scraped pages are untrusted, it parses the HTML with a standards-compliant HTML5 parser (AngleSharp) rather than pattern matching, removing script, 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 AIToolInstanceParameterCapabilities on 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 PathTemplate setting with {token} substitution), the JSON body (dotted paths such as customer.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 through IAIToolParameterContextResolver) 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.
  • 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 options AnonymousMessageRateLimitTiers and AnonymousSessionStartRateLimitTiers on PromptSecurityOptions, with matching nullable per-profile overrides on PromptSecurityProfileSettings, 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-window MaxMessagesPerWindow limit and never hit session-start throttling. When a tier list is empty, the limiter falls back to the existing single-window values (MaxMessagesPerWindow / RateLimitWindow and MaxAnonymousSessionsPerWindow / AnonymousSessionRateLimitWindow), so existing configurations keep working.

  • Authenticated message throttling is now keyed by the caller's network address in addition to their user identity (AuthenticatedMessagePartitions defaults to AuthenticatedUser | 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-window MaxMessagesPerWindow limit (no new per-user ceiling); the trade-off is that authenticated users behind one NAT/proxy share an IP bucket (remove NetworkAddress from AuthenticatedMessagePartitions to 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 GetRateLimitedMessage hook on the chat hub, and a rate-limit block is identified by PromptSecurityResult.RateLimitDetectionRule.

  • Chat Interactions now wrap the user's message in input-boundary delimiters (governed by the new EnableChatInteractionInputDelimiters option, 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, or TEXT from 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 as 1,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_subtotal column so aggregate queries can exclude them with WHERE is_subtotal = 0 instead 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_N name 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_metadata now describes every worksheet of a multi-worksheet workbook instead of only the first, matching the table list returned by list_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.Z tag 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 for vX.Y.Z, X.Y.0, vX.Y, or X.Y inputs, creates the X.Y docs 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-Azure declared under CrestApps:AI:Connections and shared-azure declared under CrestApps:AI:Providers:Azure:Connections resolved 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 unexplained DeploymentNotFound when 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 the Visitor rate-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.AllowCrossSiteEmbedding writes the cookie SameSite=None; Secure; Partitioned instead, 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 stays HttpOnly and 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 to SameSite=None as well. SameSite=None is only legal together with Secure, so a request that did not arrive over HTTPS keeps the Lax cookie rather than losing it altogether.

  • Every type under src now lives in a file named after it. Framework sources had accumulated files holding anything from two to twenty-nine top-level types — McpOptions.cs also declared McpResourceTypeEntry, each YesSql *Index.cs also declared its *IndexProvider, and PromptSecurityBuiltInRules.cs declared 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.cs held OrchestrationContext, IMcpServerMetadataProvider.cs held IMcpServerMetadataCacheProvider). This is a source-layout change only: no type, namespace, member or accessibility was altered, so nothing that compiles against CrestApps.Core today needs to change. Razor page models keep the *.cshtml.cs names ASP.NET requires.