D4S.Agent 3.0.0

dotnet add package D4S.Agent --version 3.0.0
                    
NuGet\Install-Package D4S.Agent -Version 3.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="D4S.Agent" Version="3.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="D4S.Agent" Version="3.0.0" />
                    
Directory.Packages.props
<PackageReference Include="D4S.Agent" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add D4S.Agent --version 3.0.0
                    
#r "nuget: D4S.Agent, 3.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package D4S.Agent@3.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=D4S.Agent&version=3.0.0
                    
Install as a Cake Addin
#tool nuget:?package=D4S.Agent&version=3.0.0
                    
Install as a Cake Tool

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 — InvokeAgentAsync with session persistence and a bounded conversation history; returns text or an Adaptive Card, and an AgentTurnStatus saying whether the answer is complete or was cut short after partial delivery.
  • Turn delivery — AgentTurnHandler streams 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:StreamingEnabled streams 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> (base always-on generic + specific always-on customer prompts and on-demand tool/MCP instructions files), selected via Bot: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 MicrosoftFoundry config — Endpoint, ApiKey and Deployment are all required at startup. Endpoint is the resource endpoint as the portal shows it (https://<resource>.openai.azure.com/ or https://<resource>.services.ai.azure.com/); the library talks to its Azure OpenAI v1 API (/openai/v1/) through the stable OpenAI SDK;
  • set Bot:PromptsFolder to a customer folder name under Prompts/ (e.g. Customer1);
  • copy that customer's Prompts/<customer>/base/*.md and Prompts/<customer>/specific/*.md to its output directory — the composer reads them from AppContext.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. AgentTurnHandler passes StreamingResponse.QueueTextChunk as onTextChunk (a host that calls IMafAgent directly 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.Text without 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. MafAgent then returns the card whole and AgentTurnHandler delivers it as the FinalMessage; 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.) FinalMessage would 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.
  • AgentTurnHandler owns EndStreamAsync and FinalMessage: it closes the stream from a finally and checks what it returns. A channel that refuses a streamed activity (Teams answers 403 ContentStreamNotAllowed) 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 onTextChunk is 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 MaxTurnDuration timeout — 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 ITopicGuard that blocks a message without writing a redirect gets AgentConstants.OutOfScopeFallbackMessage sent 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 in agentidentity.md what 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:

  1. marks the stream as AI-generated (EnableGeneratedByAILabel);
  2. calls IMafAgent.InvokeAgentAsync with StreamingResponse.QueueTextChunk as the text sink — MafAgent opens the stream with ThinkingUx:WorkingLabel and delivers the text through it;
  3. puts an Adaptive Card in FinalMessage, or appends AgentConstants.InterruptedResponseSuffix to an Interrupted answer — streamed text cannot be recalled, so it is marked rather than replaced;
  4. closes the stream in a finally, and when EndStreamAsync reports anything but Success, AlreadyEnded or Timeout (on Timeout the 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's Prompts/<customer>/specific folder) is appended to the instructions on demand.

  • Add MCP servers (config): list them under Mcp:Servers (Http or Stdio). An optional Tools allow-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, history
    
  • Replace the whole turn loop: .WithCustomAgent<MyAgent>() with your own IMafAgent — the heaviest seam; use only for a fundamentally different flow. AgentTurnHandler delivers 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 stable OpenAI SDK), Microsoft.Agents.Builder, Azure.Identity, ModelContextProtocol.
Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.0.0 30 9/24/2026
1.1.5 76 9/2/2026
1.1.4 87 7/31/2026
1.1.3 74 7/21/2026
1.1.0 178 7/7/2026
1.0.7 137 6/23/2026
1.0.6 118 6/22/2026
1.0.5 123 6/22/2026
1.0.4 134 6/22/2026
1.0.3 2,003 6/16/2026
1.0.2 120 6/5/2026
1.0.1 113 6/4/2026
1.0.0 107 6/4/2026