D4S.Agent
3.0.0
dotnet add package D4S.Agent --version 3.0.0
NuGet\Install-Package D4S.Agent -Version 3.0.0
<PackageReference Include="D4S.Agent" Version="3.0.0" />
<PackageVersion Include="D4S.Agent" Version="3.0.0" />
<PackageReference Include="D4S.Agent" />
paket add D4S.Agent --version 3.0.0
#r "nuget: D4S.Agent, 3.0.0"
#:package D4S.Agent@3.0.0
#addin nuget:?package=D4S.Agent&version=3.0.0
#tool nuget:?package=D4S.Agent&version=3.0.0
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 —Endpoint,ApiKeyandDeploymentare all required at startup.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 stable OpenAI SDK; - 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 / ApiKey / Deployment for the main chat client. |
MicrosoftFoundryGuardrail |
Smaller deployment for the guardrail. Endpoint/ApiKey fall back to MicrosoftFoundry when empty; Deployment does not, and is required when Bot:TopicGuardEnabled is true — leave it empty with the toggle off and nothing is constructed. |
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). |
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.
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.21.0)
- Microsoft.Agents.Builder (>= 1.8.77)
- Microsoft.Extensions.AI (>= 10.10.0)
- Microsoft.Extensions.AI.OpenAI (>= 10.10.0)
- 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.