D4S.Agent
3.1.3
dotnet add package D4S.Agent --version 3.1.3
NuGet\Install-Package D4S.Agent -Version 3.1.3
<PackageReference Include="D4S.Agent" Version="3.1.3" />
<PackageVersion Include="D4S.Agent" Version="3.1.3" />
<PackageReference Include="D4S.Agent" />
paket add D4S.Agent --version 3.1.3
#r "nuget: D4S.Agent, 3.1.3"
#:package D4S.Agent@3.1.3
#addin nuget:?package=D4S.Agent&version=3.1.3
#tool nuget:?package=D4S.Agent&version=3.1.3
D4S.Agent
Reusable Microsoft Agent Framework implementation for Microsoft 365 / Teams bots. Add one DI
call, call AgentTurnHandler from your message route, and the package owns the agent turn, its
delivery over the Teams stream, guardrail, middlewares, tools, and prompt composition — your host
keeps only auth, storage, and bot wiring.
Features
- Single-call setup —
AddMafAgent(configuration)wires chat clients, middlewares, plugins, guardrail, and MCP tools. - Agent turn —
InvokeAgentAsyncwith session persistence and a bounded conversation history; returns text or an Adaptive Card, and anAgentTurnStatussaying whether the answer is complete or was cut short after partial delivery. - Turn delivery —
AgentTurnHandlerstreams the answer, delivers cards, marks interrupted answers, closes the stream on every path and re-sends an answer the channel refused to stream. - Streaming (opt-in) —
Bot:StreamingEnabledstreams a text answer as the model writes it; off by default. - Topic guardrail — off-topic messages get a polite redirect, no main-model call. Scope is judged
against the customer's
agentidentity.md. - Middlewares — thinking-UX labels, audit logging, citation capture, loop cap, Teams keep-alive heartbeat.
- Citations — retrieval sources are captured from tool results and appended as a short source list.
- Tools — built-in Adaptive Card tool, host-contributed tools, and MCP server tools.
- Prompt composer — one folder per customer under
Prompts/<customer>(basealways-on generic +specificalways-on customer prompts and on-demand tool/MCP instructions files), selected viaBot:PromptsFolder.
Install
dotnet add package D4S.Agent
Upgrading from 1.x? 3.0 is a breaking change — see Upgrading from v1 below.
Quick start
Register everything the agent needs in one call:
builder.Services.AddMafAgent(builder.Configuration);
Depend on AgentTurnHandler from your bot and hand it every message. It runs the agent, delivers
the text (one block by default, progressive chunks with streaming on) or the Adaptive Card, and closes
the stream. Everything around that call stays the bot's:
public class Bot : AgentApplication
{
private readonly ILogger<Bot> _logger;
private readonly AgentTurnHandler _turnHandler;
public Bot(ILogger<Bot> logger, AgentApplicationOptions options, AgentTurnHandler turnHandler) : base(options)
{
_logger = logger;
_turnHandler = turnHandler;
OnActivity(ActivityTypes.Message, MessageActivityAsync, rank: RouteRank.Last);
}
protected async Task MessageActivityAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
// Before the agent runs: handle commands that must not reach the model and return early, set
// per-turn stream options (e.g. StreamingResponse.FeedbackLoopEnabled), pick per-turn ChatOptions.
ChatOptions? agentChatOptions = null;
ChatOptions? guardChatOptions = null;
var response = await _turnHandler.HandleAsync(
turnContext, turnState, agentChatOptions, guardChatOptions, cancellationToken);
// After delivery: the answer is already on screen. Use the response for telemetry or follow-ups.
_logger.LogInformation("Message turn ended: {ContentType}, {Status}.", response.ContentType, response.Status);
}
}
| Where | What the bot can do |
|---|---|
Before HandleAsync |
Answer commands or unsupported messages itself and return early; set per-turn stream options (FeedbackLoopEnabled, SensitivityLabel, …) — the handler only sets the AI-generated label and, for a card, FinalMessage; build per-turn ChatOptions for the agent and the topic guard. |
HandleAsync |
Nothing to add: the opening label, text delivery, the card, the interrupted notice and the resend when the channel refuses the stream all happen here. |
After HandleAsync |
Telemetry and follow-up activities. AgentTurnResponse.Status says whether the answer is complete, was cut short after partial delivery, or failed; the answer is already delivered, so this is not the place to change it. |
The template's Bot.cs has this method with the extension points marked. A host that needs its own
delivery can call IMafAgent.InvokeAgentAsync directly — see
What the turn handler does.
Your host must:
- provide the
MicrosoftFoundryconfig —EndpointandDeploymentare required at startup;ApiKeyis required only in the default API-key mode.Endpointis the resource endpoint as the portal shows it (https://<resource>.openai.azure.com/orhttps://<resource>.services.ai.azure.com/); the library talks to its Azure OpenAI v1 API (/openai/v1/) through the OpenAI SDK (see the experimental Entra API note under Foundry authentication); - set
Bot:PromptsFolderto a customer folder name underPrompts/(e.g.Customer1); - copy that customer's
Prompts/<customer>/base/*.mdandPrompts/<customer>/specific/*.mdto its output directory — the composer reads them fromAppContext.BaseDirectory.
Configuration
| Section | Purpose |
|---|---|
MicrosoftFoundry |
Azure OpenAI Endpoint / Deployment and Auth for the main chat client; ApiKey is required in API-key mode. |
MicrosoftFoundryGuardrail |
Smaller deployment for the guardrail. Empty Endpoint inherits the main endpoint; omitted Auth inherits the entire main authentication block. Empty ApiKey inherits the main key only when the main client uses API-key authentication. Deployment never inherits and is required when Bot:TopicGuardEnabled is true. |
Bot |
Feature toggles + tuning (topic guard, Adaptive Cards, streaming, heartbeat + HeartbeatInterval, MaxTurnDuration, AgentHistoryMessageCount, loop cap…). |
ThinkingUx |
WorkingLabel (the update every turn opens with), per-tool Labels, FallbackLabel, SilentTools, MinUpdateInterval. |
Mcp |
MCP servers whose tools are exposed to the agent, connected at the start of every turn. |
Citations |
Source-list settings: Enabled, ToolNames (default ["search", "ask"]; a configured list replaces it), Header (default Sources), MaxCitations (0 = unlimited), CollapseByDocument (default false), OnlyUsedSources (default false; see Citations). |
Tuning defaults worth knowing:
| Setting | Default | Why |
|---|---|---|
Bot:MaxTurnDuration |
00:01:50 |
The longest turn that still ends inside the two minutes Teams allows a streamed message. A longer turn still gets its answer, but the Agents SDK stops streaming first and sends it as a plain message (at 1m45s on msteams:COPILOT). |
Bot:AgentHistoryMessageCount |
20 |
User + assistant messages kept in the conversation history. 0 keeps everything. |
Bot:HeartbeatInterval |
00:00:07 |
Silence before the keep-alive repeats the last label. |
Bot:MaxRequestIterations |
15 |
Tool calls per turn before the loop is cut. |
MaxTurnDuration, MaxRequestIterations and (with the heartbeat on) HeartbeatInterval must be
greater than zero; AddMafAgent refuses to start otherwise, naming the setting.
Tool-call arguments and results, and the messages the topic guard classifies, carry user data, so they are logged at Debug only; Information logs name the tool, its outcome and its duration.
Foundry authentication
Existing configurations keep working: omitting Auth on MicrosoftFoundry selects ApiKey.
Authentication is independent of the model API: both clients still use Chat Completions.
Auth setting |
Default | Meaning |
|---|---|---|
Type |
ApiKey |
ApiKey, ManagedIdentity, or DefaultAzureCredential. |
ClientId |
Empty | In token modes, optional user-assigned managed identity client ID (GUID). In ManagedIdentity mode, empty selects the host's system-assigned identity. Rejected in API-key mode. |
Scope |
https://cognitiveservices.azure.com/.default |
Token scope for the target Azure OpenAI resource. Override explicitly if the service requires a different audience, for example https://ai.azure.com/.default. Ignored in API-key mode. |
For an Azure host with a system-assigned identity, use:
{
"MicrosoftFoundry": {
"Endpoint": "https://<resource>.openai.azure.com/",
"Deployment": "<main-deployment>",
"ApiKey": "",
"Auth": {
"Type": "ManagedIdentity"
}
},
"MicrosoftFoundryGuardrail": {
"Deployment": "<guardrail-deployment>"
}
}
The identity must be attached to the host and authorized to perform inference on the target
resource. For a user-assigned identity, also set MicrosoftFoundry:Auth:ClientId to its client ID.
ManagedIdentity uses ManagedIdentityCredential exclusively: it does not try developer accounts.
Keep API-key values empty in tracked configuration and supply any real key through your secret provider.
For local development, explicitly override MicrosoftFoundry:Auth:Type to
DefaultAzureCredential in local configuration or the process environment. This uses Azure Identity's
credential chain, including eligible developer credentials; it is not a guarantee of managed identity.
Its optional ClientId selects only the managed-identity member of that chain. Local credentials
also need access to the target resource. The application does not sign in or assign roles for you.
Guardrail rules:
- Omit its
Authblock to inherit the main client's type, client ID and scope together. - An explicit guardrail
Authblock replaces the main block; omitted fields use their own defaults. Always includeTypein an override. This allows switching from a user-assigned main identity to a system-assigned guardrail identity without retaining the main client ID. - Set
Endpointindependently if the guardrail uses another resource, and check that resource's permissions and scope.Deploymentis always independent. - To use a key for the guardrail when the main client uses tokens, set both
MicrosoftFoundryGuardrail:Auth:Type=ApiKeyand a guardrail key in the secret provider. A main key left over from an older configuration is not inherited in this case. - With the guardrail enabled, invalid configuration fails at registration. With it disabled, its validation and construction occur only if a consumer explicitly resolves the keyed client.
Token modes require an absolute HTTPS endpoint, a nonempty scope and a valid client ID when supplied.
An API key present in token mode is unused. Token acquisition errors and HTTP authentication errors
are propagated; there is no retry with an API key. Credentials and BearerTokenPolicy live with each
chat client, so token caching and renewal are handled by the SDK instead of a token captured at startup.
MCP user context (opt-in per HTTP server)
For a .NET server that receives this contract, see D4S.Mcp.Server. It validates the application bearer/caller before reading the versioned user-context headers and keeps application and asserted person separate. The client does not depend on that server package; its opt-in, credential cache, scope and transport behavior are unchanged.
Set Mcp:Servers:<index>:UserContext:Enabled=true only for a trusted HTTP MCP server
that needs the actor and conversation of the current turn. Omit the block, or set Enabled=false,
to keep the existing transport, authentication and static headers unchanged.
{
"Mcp": {
"Servers": [
{
"Name": "workspace-tools",
"Type": "Http",
"Endpoint": "https://mcp.example.invalid/mcp",
"Auth": {
"Type": "ManagedIdentity",
"Scope": "api://<mcp-app-id>/.default",
"ClientId": "<host-user-assigned-client-id>"
},
"UserContext": { "Enabled": true }
},
{
"Name": "reference-tools",
"Type": "Http",
"Endpoint": "https://reference.example.invalid/mcp"
}
]
}
}
The host must authenticate the inbound request, restrict it to trusted channels/callers, and ensure
the activity describes that channel's actor. ITurnContext.Identity.IsAuthenticated is required;
it alone does not attest to arbitrary activity fields supplied by a caller. An anonymous
Playground turn cannot use an opted-in server. Other channels can participate when their trusted
host supplies the same Entra and conversation fields; no application-specific role or domain is assumed.
The fixed version-1 contract is:
| Header | Source and encoding |
|---|---|
X-D4S-User-Context-Version |
1 |
X-D4S-User-Object-Id |
Activity.From.AadObjectId, a nonempty GUID, canonical lowercase D format. From.Id is never a substitute. |
X-D4S-Tenant-Id |
Activity.Conversation.TenantId, same GUID rules. Only msteams can fall back to JSON ChannelData.tenant.id; if both exist they must agree. Malformed or ambiguous tenant data is rejected. |
X-D4S-Conversation-Id-B64Url |
Activity.Conversation.Id, UTF-8 then Base64url without padding; at most 1,024 bytes before encoding. |
X-D4S-Channel-Id |
Activity.ChannelId.Channel[:SubChannel]; at most 128 ASCII characters, with letters, digits, ., _, - in each component. |
X-D4S-User-Name-B64Url |
Optional Activity.From.Name, UTF-8 then Base64url without padding; at most 256 bytes before encoding. Missing/empty/blank names are omitted. |
GUID input must use the 36-character D format (uppercase is accepted). Strings are not trimmed or
truncated. Control characters (including CR/LF, U+0000-U+001F and U+007F-U+009F), Unicode line/paragraph separators
and invalid surrogate sequences are rejected. Base64url is an encoding, not encryption. To decode,
replace - with + and _ with /, restore Base64 padding, decode the bytes and read them as UTF-8.
The server should validate the version, required fields and size limits before using the context.
Missing/invalid required context skips only that opted-in server, with a diagnostic reason;
it never falls back to a previous user or to context-free discovery. The same applies to an opted-in
Stdio server, which is unsupported. All six header names are reserved case-insensitively, even
when the display name is absent. Conflicts, duplicate static header names ignoring case, and a
static Host override are rejected. Other static headers are copied without modifying configuration.
The toolkit captures the turn once before discovery's first asynchronous operation. Each opted-in
server gets its own copy for discovery, pagination, tool calls, SSE GET/reconnect and session DELETE.
Tool schemas, arguments and model instructions do not receive these identity fields. Clients and
their HTTP transports are disposed at the end of the turn, including unsuccessful discovery.
Custom toolkit consumers must call DisposeMcpToolsetAsync; MafAgent already does this in finally.
Opted-in endpoints require HTTPS; HTTP is accepted only for loopback tests. Userinfo and URI fragments are forbidden. Automatic redirects are disabled, including redirects on the same origin. Every request must keep the configured scheme, IDN host and effective port. Legacy SSE may advertise a different path on that same origin; a different origin is blocked before token acquisition.
MCP authentication is independent of this context. Its existing ManagedIdentity setting keeps
using DefaultAzureCredential when ClientId is empty, and a user-assigned
ManagedIdentityCredential when it is supplied. The scope, credential cache and per-request bearer
acquisition are unchanged. This is the application's identity, not an on-behalf-of user token.
Auth=None is supported for local/trusted setups; these headers do not authenticate anyone.
The MCP server must authenticate and authorize the calling application before trusting its user
headers, then apply its own access rules. Display names and conversation IDs are not authorization
evidence. Avoid recording these headers in host/proxy logs. Discovery diagnostics on the opt-in path
omit raw HTTP/SDK exceptions, response bodies and identity values.
The local suite exercises the real MCP SDK over loopback Streamable HTTP and legacy SSE with synthetic identities/tokens. Deployment-specific Teams/Copilot provenance, Entra validation and server authorization require a separate environment test.
Citations
Citations are captured from the results of the Citations:ToolNames tools and appended as a source
list once the answer completes. By default the list holds everything retrieval returned, best score
first. Relevance is the retrieval side's job: with the knowledge retrieval MCP, its
RerankerThreshold keeps weak matches out of both the model's context and the list.
OnlyUsedSourceslists only the sources the answer used. When one of theToolNamestools is available, the agent gets a built-inReportSourcestool and is told to call it, before writing the answer, with thesourceIdof the passages it relies on; an empty report means none of them. It costs one more model round trip per answer, and needs citations that carry the passage'ssourceId— with the knowledge retrieval MCP, leave itsCollapseCitationsByDocumentoff and collapse here withCollapseByDocumentinstead.The narrowing errs towards showing a source rather than hiding it:
- A report covers what was retrieved before it. Sources retrieved after the last report, or in a turn with no report at all, are all listed.
- A report naming only ids that were never retrieved is ignored, and the model is told so, so it can report again. Ids it got partly wrong are left out, and the model is told which.
- An
ask-style answer settles its own sources: the references it cites inline as[ref_id:N]are listed, the rest are not, and no report changes that.
When sources are left out, an Information log says how many.
ReportSources does not count towards Bot:MaxRequestIterations, and shows the
ThinkingUx:Labels:ReportSources label (default ✍️ Preparing the answer...).
Streaming
Bot:StreamingEnabled (default false) makes the agent forward a text answer to the client in
chunks as the model produces it. With the toggle off, the same callback receives the completed
answer once. The returned AgentTurnResponse always retains the complete payload in both modes.
- One text path.
AgentTurnHandlerpassesStreamingResponse.QueueTextChunkasonTextChunk(a host that callsIMafAgentdirectly must do the same); the toggle changes only whether it is called once or progressively. - Text is direct. The main agent always returns plain text, so streaming forwards
update.Textwithout a JSON envelope or incremental decoder. - Adaptive Cards are turn artifacts. The optional card tool generates and validates the JSON,
stores it in the current turn, and returns only a short acknowledgement to the model.
MafAgentthen returns the card whole andAgentTurnHandlerdelivers it as theFinalMessage; card JSON is never streamed. - A card only wins while nothing has been said. Its prompt requires the tool to run before any
user-visible text. If the model has already written prose, the tool refuses the card and tells the
model to present the data as text, so the turn completes as text; a second card in the same turn is
refused the same way. (A card stored in the very instant the first chunk goes out is dropped with a
warning.)
FinalMessagewould replace the streamed text, and rewriting an answer the user is already reading is worse than losing the card — if refusals are frequent, tighten the card instructions. AgentTurnHandlerownsEndStreamAsyncandFinalMessage: it closes the stream from afinallyand checks what it returns. A channel that refuses a streamed activity (Teams answers 403ContentStreamNotAllowed) leaves the SDK unable to send anything else, so the handler re-sends the answer as an ordinary message.- A card turn still puts text on the wire. The model's short acknowledgement is queued before the
card replaces it: the Agents SDK stamps a final activity with an empty text buffer as
StreamResults.Error, which would report every successful card turn as a failed stream. - Forgetting
onTextChunkis logged. With the toggle on and no sink the answer is delivered as one block and the library warns once, instead of silently ignoring the setting. - Every turn opens with
ThinkingUx:WorkingLabel, and the keep-alive repeats the last label shown. - "Thinking" labels stop as soon as the first words appear — informative updates are only valid before the first text chunk. The heartbeat is stopped and drained before that chunk is queued. A model that writes a lead-in before a slow tool call therefore leaves the stream without keep-alive during that call; tell the model in the prompts to call tools before writing.
- Citations are appended as a final chunk once the answer completes, and are kept out of the topic-guard history — a source list is noise for a topic classifier. Library notices (the loop-cap and empty-answer messages) and interrupted answers never carry one.
- The tool-call cap after streamed text marks the turn
Interrupted: what streamed was a lead-in to tool calls that never produced an answer, and the handler says so instead of presenting it as complete. - Non-streaming channels need no special handling: the Agents SDK buffers the chunks and sends
one normal message at
EndStreamAsync.
Chunking is left to StreamingResponse, which throttles outbound updates on its own Interval
(Teams 1s). Do not lower it — Teams rejects faster streams.
Conversation history
The AgentSession is serialized into conversation state after every successful turn and restored on
the next one. It keeps the last Bot:AgentHistoryMessageCount user and assistant messages (default
20); tool calls and their results are dropped once the turn that made them ends, because they only
matter to that turn and are most of what a stored session weighs.
Sessions live under the conversation.agentSession.v3 key. A session stored by 1.x (key
conversation.agentSession) is deleted instead of loaded: its assistant turns are the JSON envelope
1.x made the model answer in, and a history of JSON replies would teach the model to answer in JSON
again. Each conversation therefore starts fresh on its first 3.0 turn that reaches the agent (a
topic-guard redirect does not load the session).
The answer never depends on the save: a session that cannot be serialized costs that turn its place in the history, and the user still gets the answer. Two failure modes are handled for you:
- A stored session that can no longer be deserialized (schema change, truncation) is discarded and the turn starts from a fresh one. The user sees nothing, so it is logged as a warning.
- A turn that throws leaves the history intact and only increments a failure counter; the history is
dropped after two consecutive failed turns. One failure is almost always transient — a tool
error, a rate limit, a
MaxTurnDurationtimeout — and throwing away the conversation over it costs the user more than the retry it saves. A repeat suggests the history itself is being rejected, and losing it beats leaving the user permanently stuck.
Citation tool contract
For each tool listed in Citations:ToolNames, the citation middleware reads a citations collection
from the result root or from the MCP structuredContent object. Every item can contain title,
sourceUrl, and score; at least title or sourceUrl must be present. A #page=N fragment in
sourceUrl is displayed as the source page, and score controls source ordering.
Sources are deduplicated on sourceUrl, falling back to title when there is no URL, and the entry
kept is always the highest-scoring one. Citations:CollapseByDocument decides how much the key
covers:
false(the default) — the whole URL, fragment included, so a retrieval that projects page anchors lists the same document once per cited page. On a long document that is the useful shape: the reader gets the sections that answered the question, not one link to the document.true— the URL without its fragment, so a document is listed once, at the page it ranked best.
Turning it on is also the way to shorten the list for ask, whose citations the retrieval side never
collapses — one per [ref_id:N] reference by design.
The D4S Knowledge Retrieval MCP conforms to this contract for both search and ask. Its search
response still contains passages for grounding and compatibility, but the Agent reads citations
only from the common citations collection.
Topic guard
With Bot:TopicGuardEnabled, every message is classified by a smaller model before the agent runs.
The classifier receives the customer's agentidentity.md (base and specific, like the main prompt)
as the assistant's description and judges scope against it, so describe the bot's domain there. When
no identity file ships, it falls back to inferring the domain from the conversation.
- It fails open: an unreadable verdict or a failed call lets the message through. Two exceptions: a cancelled turn is not a failure and is not let through, and a refusal by the model's content filter (Azure OpenAI flags jailbreak attempts this way) is treated as out of scope — letting through exactly the messages flagged as attacks would defeat the guard.
- A mixed message is let through. "How many vacation days do I get? And the weather?" reaches the agent, which answers the in-domain part and declines the rest.
- The redirect says what the bot covers, in a few general words drawn from
agentidentity.md, without quoting it, in the user's language. - A custom
ITopicGuardthat blocks a message without writing a redirect getsAgentConstants.OutOfScopeFallbackMessagesent for it.
The guard is only as good as
agentidentity.md. It blocks what falls outside the domain that file describes. With a generic identity, such as the template's "You are a conversational assistant", there is no domain to fall outside of, and in our evaluation it let through most plainly off-topic questions (weather, jokes, code, recipes, requests for the system prompt). Before turning the guard on, say inagentidentity.mdwhat the bot is for and what it covers.
What the turn handler does
AgentTurnHandler.HandleAsync is what a bot's message route should call. In order, it:
- marks the stream as AI-generated (
EnableGeneratedByAILabel); - calls
IMafAgent.InvokeAgentAsyncwithStreamingResponse.QueueTextChunkas the text sink —MafAgentopens the stream withThinkingUx:WorkingLabeland delivers the text through it; - puts an Adaptive Card in
FinalMessage, or appendsAgentConstants.InterruptedResponseSuffixto anInterruptedanswer — streamed text cannot be recalled, so it is marked rather than replaced; - closes the stream in a
finally, and whenEndStreamAsyncreports anything butSuccess,AlreadyEndedorTimeout(onTimeoutthe SDK still sends the final message itself), re-sends the answer as an ordinary message.
A host that calls IMafAgent directly takes on those four steps.
Architecture
MafAgent is a thin orchestrator. Each turn it delegates to three focused services, every one behind
an interface and registered as a replaceable default:
| Interface | Default | Responsibility |
|---|---|---|
IAgentBuilder |
AgentBuilderService |
Composes the instructions, tools, and middleware pipeline into the AIAgent. |
IMcpToolkit |
McpToolkitService |
Discovers MCP server tools (per turn) and disposes their clients. |
ITopicGuard |
TopicGuardService, or NoOpTopicGuard when Bot:TopicGuardEnabled is false |
Off-topic classification, redirect generation, and guard history. |
The whole turn loop itself is IMafAgent (default MafAgent), and AgentTurnHandler delivers
whatever it returns. The turn deadline, the keep-alive and the label throttle all run on the
TimeProvider registered in DI (TimeProvider.System unless the host registers another).
MafAgent is transient, so all three are built on every turn. That is why ITopicGuard resolves to
NoOpTopicGuard while Bot:TopicGuardEnabled is false: registering the classifying implementation
would construct its keyed chat client — and demand a guardrail deployment — for a bot that never
classifies anything.
Extending
AddMafAgent returns a MafAgentBuilder, so overrides chain off it. Each WithCustomXxx replaces
the matching default registration, so call order doesn't matter and your implementation always wins.
Add a tool (code):
AddMafAgent(config, o => o.AdditionalTools.Add(new AgentTool(sp => AIFunctionFactory.Create(...), InstructionsFile: "my-tool.md"))). The optional.md(shipped in the customer'sPrompts/<customer>/specificfolder) is appended to the instructions on demand.Add MCP servers (config): list them under
Mcp:Servers(HttporStdio). An optionalToolsallow-list selects a subset; unreachable servers are logged and skipped (fail-soft).Replace a single concern: chain the matching builder method, passing your implementation of that seam's interface:
builder.Services .AddMafAgent(builder.Configuration) .WithCustomAgentBuilder<MyAgentBuilder>() // IAgentBuilder — instruction/tool/middleware composition .WithCustomMcpToolkit<MyMcpToolkit>() // IMcpToolkit — MCP discovery + disposal .WithCustomTopicGuard<MyTopicGuard>(); // ITopicGuard — classification, redirect, historyReplace the whole turn loop:
.WithCustomAgent<MyAgent>()with your ownIMafAgent— the heaviest seam; use only for a fundamentally different flow.AgentTurnHandlerdelivers its responses unchanged.
Upgrading from v1
Three things change for every host, and most of it is not caught by the compiler. The long
form, including the table of changed members and the settings to review, ships as MIGRATION.md
inside this package.
1. The model no longer returns a JSON envelope
It writes plain text, and the Adaptive Card comes from a dedicated tool instead. In every
Prompts/<customer>/** file, delete the { "contentType": …, "content": … } output contract and
replace it with:
- Return ordinary plain text or light Markdown. Never wrap the final response in JSON.
- No triple backticks for code blocks unless the content is actually code.
- Adaptive Cards, when available, are generated and delivered by their dedicated tool. Never copy the card JSON into the final response.
Then check for leftovers with grep -rn "contentType" Prompts/. Skip this step and the model keeps
emitting the v1 envelope, nothing unwraps it any more, and your users see raw JSON in the chat.
2. The library delivers the text, not you
Replace the agent call in your message handler with AgentTurnHandler.HandleAsync, and remove your
own QueueTextChunk(Content), FinalMessage and EndStreamAsync code, or every answer arrives twice.
Whatever your handler did before or after the agent call stays where it is:
protected async Task MessageActivityAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
// Before the agent runs: handle commands that must not reach the model and return early, set
// per-turn stream options (e.g. StreamingResponse.FeedbackLoopEnabled), pick per-turn ChatOptions.
ChatOptions? agentChatOptions = null;
ChatOptions? guardChatOptions = null;
var response = await _turnHandler.HandleAsync(
turnContext, turnState, agentChatOptions, guardChatOptions, cancellationToken);
// After delivery: the answer is already on screen. Use the response for telemetry or follow-ups.
_logger.LogInformation("Message turn ended: {ContentType}, {Status}.", response.ContentType, response.Status);
}
Inject AgentTurnHandler instead of IMafAgent; AddMafAgent registers it. The library also sends
the opening informative update, so remove the QueueInformativeUpdateAsync call your bot made before
invoking the agent, and put its text in ThinkingUx:WorkingLabel instead.
AgentResponse and AgentResponseContentType are now AgentTurnResponse and
AgentTurnContentType (the old name clashed with Microsoft.Agents.AI.AgentResponse); a 1.x handler's
switch on the content type goes away with the rest of its delivery code.
Custom integrations that call IMafAgent directly: onTextChunk is the third positional
parameter, where v1 had agentChatOptions — use named arguments.
3. Conversation history starts fresh
1.x stored the model's JSON-envelope replies in each conversation's session. 3.0 stores sessions under a new key and deletes the 1.x one, so every conversation loses its earlier context once, on its first 3.0 turn that reaches the agent. Nothing to do, but tell your users if they rely on long-running conversations.
Requirements
net10.0- Key dependencies:
Microsoft.Agents.AI,Microsoft.Extensions.AI(.OpenAI)(and through it the stableOpenAISDK),Microsoft.Agents.Builder,Azure.Identity,ModelContextProtocol.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Azure.Identity (>= 1.21.0)
- Microsoft.Agents.AI (>= 1.23.0)
- Microsoft.Agents.Builder (>= 1.8.77)
- Microsoft.Extensions.AI (>= 10.10.0)
- Microsoft.Extensions.AI.OpenAI (>= 10.10.1)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- ModelContextProtocol (>= 2.2.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.1.3 | 58 | 11/11/2026 |
| 3.1.0 | 146 | 10/9/2026 |
| 3.0.0 | 125 | 9/24/2026 |
| 1.1.5 | 102 | 9/2/2026 |
| 1.1.4 | 110 | 7/31/2026 |
| 1.1.3 | 97 | 7/21/2026 |
| 1.1.0 | 195 | 7/7/2026 |
| 1.0.7 | 158 | 6/23/2026 |
| 1.0.6 | 135 | 6/22/2026 |
| 1.0.5 | 144 | 6/22/2026 |
| 1.0.4 | 154 | 6/22/2026 |
| 1.0.3 | 2,021 | 6/16/2026 |
| 1.0.2 | 143 | 6/5/2026 |
| 1.0.1 | 128 | 6/4/2026 |
| 1.0.0 | 125 | 6/4/2026 |