• An agent is a loop, but the loop needs structure: memory, modes, task lists, approvals, and the ability to delegate work.
• MAF's Harness providers are pluggable AIContextProvider components that inject these capabilities via tools and system instructions — without changing the core agent.
• Each provider solves one well-defined problem: FileMemory gives the agent working memory, AgentMode enforces plan-before-execute discipline, SubAgentsProvider enables parallel delegation, and ToolApprovalAgent implements 'don't ask again' approval persistence.
Inside the ReAct loop ended with a claim: the model is just a function; the harness is where the engineering value lives. This post is the follow-up. Specifically: what does a production harness need to provide, why, and how does the Microsoft Agents Framework (MAF) implement it?
The short answer is that six problems keep surfacing, in every language and every framework, whenever you try to run an agent on real work for more than a few minutes:
- The context window is finite and expensive — the agent needs a working memory outside of it
- Agents need access to files that outlive any single session
- Unconstrained autonomy is dangerous — you want a planning gate before execution
- Long runs lose track of what’s done and what’s left, especially after compaction
- Some work is embarrassingly parallel — one agent polling sequentially is too slow
- Some tool calls need a human sign-off, but asking every time creates friction that breaks the workflow
MAF’s Harness folder in src/Microsoft.Agents.AI/Harness is a set of pluggable AIContextProvider implementations — one per problem. They inject tools and instructions into the agent’s invocation context. The agent picks them up like any other tool. The session state bag persists whatever state survives across calls.
The architecture: AIContextProvider
Before going through each component it helps to understand the hook they all use. AIContextProvider is an abstract base class with a single override point:
protected override ValueTask<AIContext> ProvideAIContextAsync(
InvokingContext context,
CancellationToken cancellationToken = default);
The returned AIContext carries three things: system instructions, tools, and synthetic messages to inject into the next call. Providers are composed at agent build time and are called on every agent invocation. They can read and write per-session state via AgentSessionStateBag — the persistent state blob that travels with the session.
This means a provider can be completely self-contained. The TodoProvider manages its own todo list state. The AgentModeProvider manages its own mode state. Neither knows about the other. The agent just sees more tools and more instructions on each call.
Problem 1: Working memory — FileMemoryProvider
The problem
The model’s context window is its working memory, and it’s finite. A research agent doing three hours of work will hit the limit. MAF’s ContextWindowCompactionStrategy handles this by evicting old tool results and summarising, but anything evicted is gone. If the agent downloaded a 40KB web page early in the session and referenced it five times, and compaction evicted it, the agent has to re-download it.
More fundamentally: even before compaction, pasting large API responses directly into the transcript bloats the context fast. You want a place to save things that can be read back on demand — not something that takes up permanent space in the prompt.
The solution
FileMemoryProvider gives the agent file-based session memory via five tools: FileMemory_SaveFile, FileMemory_ReadFile, FileMemory_DeleteFile, FileMemory_ListFiles, and FileMemory_SearchFiles.
Each session gets its own working folder (the path is part of the serializable FileMemoryState). Files are named by the agent — plan.md, research-results.md, page_cache_cnn.md — and the agent is prompted to use descriptive names so it can find things later.
The feature that makes this more than just file tools is the memory index. After every save or delete, the provider rebuilds a memories.md file listing all files with their optional description sidecars. On the next invocation, ProvideAIContextAsync reads this index and injects it as a synthetic user message:
aiContext.Messages =
[
new ChatMessage(ChatRole.User,
"The following is your memory index — a list of files you have previously saved. " +
"You can read any of these files using the FileMemory_ReadFile tool.\n\n" +
indexContent),
];
The agent starts every new invocation already knowing what it has saved. If compaction wiped the tool call that originally downloaded a web page, the index still tells the agent the file exists and what it contains. Working memory survives context overflow.
For large files, the agent can save a short description alongside the content:
await fileMemory.SaveFileAsync(
"cnn_article_20260501.md",
articleContent,
description: "CNN article on Q1 earnings, published 2026-05-01. Covers MSFT, AAPL, GOOG.");
The description goes into a _description.md sidecar. It’s included in the index but hidden from list and search results — the agent sees cnn_article_20260501.md: CNN article on Q1 earnings... in the index without needing to read the full file to know if it’s relevant.
The file backend is swappable. FileSystemAgentFileStore persists to disk; InMemoryAgentFileStore keeps everything in a ConcurrentDictionary for tests. Both enforce path safety — no .. traversal, no rooted paths, all normalised through StorePaths.NormalizeRelativePath.
Problem 2: Shared, persistent data — FileAccessProvider
The problem
Working memory is session-scoped and agent-private. But agents often need to work with files that have a different lifecycle: CSV datasets uploaded by the user, configuration files, output reports that should persist after the session ends and be readable by other agents or by humans directly.
This is the wrong job for FileMemoryProvider. Session isolation is a feature there — you don’t want one session’s research contaminating another’s. But for input data and output artifacts, you want the opposite: one shared folder, visible across sessions.
The solution
FileAccessProvider is the same set of five file tools (FileAccess_SaveFile, ReadFile, DeleteFile, ListFiles, SearchFiles) but without session isolation. The AgentFileStore it wraps is pointed at a shared folder from the start:
var fileStore = new FileSystemAgentFileStore(
Path.Combine(AppContext.BaseDirectory, "data"));
new FileAccessProvider(fileStore)
There’s no working-folder indirection. The agent operates directly against the root of the store. The instructions explicitly frame this as a shared space:
These files persist beyond the current session and may be shared across sessions or agents. Use these tools to read input data provided by the user, write output artifacts, and manage any files the user has asked you to work with.
One important difference from FileMemoryProvider: SaveFile defaults to no overwrite and returns a message telling the agent to pass overwrite: true if it really intends to replace a file. The prompt “File ‘report.md’ already exists. To replace it, save again with overwrite set to true” gives the agent a chance to reconsider before clobbering user data.
The data processing sample (Step03) puts a sales.csv in the data/ folder and points FileAccessProvider at it. The agent reads it, performs analysis, and writes a summary back — all in the shared folder, persisted after the session ends.
Problem 3: Plan before you act — AgentModeProvider
The problem
Autonomous execution is useful precisely because it removes the human from each step. But “remove the human and execute” without first agreeing on what to execute is how you get agents that confidently do the wrong thing very quickly. The longer and more capable the agent, the more expensive the mistake.
The pattern that works in practice is: plan interactively, execute autonomously. The agent proposes a plan, the human reviews and approves, then the agent switches into a mode where it stops asking questions and just works. This is not a UI convention — it needs to be enforced structurally, or the LLM will find reasons to skip the planning step.
The solution
AgentModeProvider gives the agent a named mode stored in session state. The default modes are "plan" and "execute", but they’re fully configurable. On each invocation, the provider injects:
- Instructions describing what each mode means and which one is currently active
- Two tools:
AgentMode_GetandAgentMode_Set
The instructions for the default modes are deliberately behavioural:
“plan”: Use this mode when analyzing requirements, breaking down tasks, and creating plans. This is the interactive mode — ask clarifying questions, discuss options, and get user approval before proceeding.
“execute”: Use this mode when carrying out approved plans. Work autonomously using your best judgement — do not ask the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, useful result when the user returns.
The mode is persisted in the session’s state bag as AgentModeState. External code (a console command, a UI button, an observer callback) can change it via SetMode(). When the mode is changed externally, a PreviousModeForNotification flag is set and the next invocation injects a synthetic user message:
new ChatMessage(ChatRole.User,
$"[Mode changed: The operating mode has been switched from \"{previousMode}\" to \"{state.CurrentMode}\". " +
$"You must now adjust your behavior to match the \"{state.CurrentMode}\" mode.]")
This is important. System instructions are for context the agent sees at the start of the conversation. A mid-session mode change can get lost if only the instructions are updated — the model’s attention is on the recent transcript, not the preamble. The injected message ensures the mode change lands where the model is looking.
The console sample (Step01) shows this wired end-to-end. The PlanningOutputObserver uses structured output (JSON schema response format) to parse the agent’s plan-mode responses into typed PlanningResponse objects with Clarification and Approval variants. When the user approves, the observer calls modeProvider.SetMode(session, "execute") directly — mode switch in two lines, no agent re-invocation needed.
Problem 4: Tracking what’s done — TodoProvider
The problem
A research task with 12 sub-questions. A data processing job with 8 files. A multi-hour coding task with 20 steps. Without explicit task tracking, the agent has two options: keep all pending work in the transcript (context bloat and compaction risk) or reconstruct the task list from scratch on every invocation (slow, unreliable). Neither is acceptable.
More subtly: during compaction, tool call results get evicted. If “Step 4: Download AAPL data” exists only as a tool call and its result in the transcript, compaction can erase the evidence that it was done. The agent then re-does it.
The solution
TodoProvider gives the agent a session-persistent todo list via five tools: TodoList_Add (batch), TodoList_Complete (batch), TodoList_Remove (batch), TodoList_GetRemaining, and TodoList_GetAll.
The state — a List<TodoItem> with IDs, titles, descriptions, and IsComplete flags — lives in the session’s state bag:
internal sealed class TodoState
{
public List<TodoItem> Items { get; set; } = [];
public int NextId { get; set; } = 1;
}
State bag persistence means todo items survive compaction. Even if the tool call that marked item 4 complete was evicted from the context, the fact of its completion is still in the serialised session state. The agent can check TodoList_GetRemaining at any point and get an accurate picture of what’s left.
The batch API is deliberate. At the start of a planning session the agent might create 10 todos in one tool call. After a complex work unit, it might complete 3 at once. The batching keeps tool call count down — important because every tool call round-trips to the LLM.
TodoProvider also exposes GetAllTodos and GetRemainingTodos as C# methods for external callers, so a /todos console command can display the current list without re-invoking the agent.
Problem 5: Parallel work — SubAgentsProvider
The problem
Consider looking up the closing price on December 31, 2025 for 10 stock tickers. If you do this sequentially — start a web search, wait, record the result, start the next — you’re waiting on network round-trips 10 times in series. Each web search might take 3–5 seconds. That’s 30–50 seconds of sequential waiting for what is fundamentally parallel work.
More generally, many real-world agentic tasks have independent sub-problems that can be farmed out: research 5 companies at once, process 8 CSV files concurrently, generate 4 report sections in parallel. A single-agent sequential loop wastes wall-clock time.
The solution
SubAgentsProvider gives a parent agent six tools for asynchronous task delegation: SubAgents_StartTask, SubAgents_WaitForFirstCompletion, SubAgents_GetTaskResults, SubAgents_GetAllTasks, SubAgents_ContinueTask, and SubAgents_ClearCompletedTask.
StartTask is non-blocking. It creates a dedicated AgentSession for the sub-task, fires Task.Run(() => agent.RunAsync(input, subSession)), and returns immediately with the task ID. The parent can start all 10 ticker lookups before waiting on any of them:
// Parent agent calls SubAgents_StartTask for each ticker — no awaiting yet.
// Then calls SubAgents_WaitForFirstCompletion with all 10 IDs.
// Then retrieves each result as tasks complete.
The Task.Run wrapper is load-bearing. AIAgent.RunAsync synchronously sets a static AsyncLocal<RunContext> to track the current running agent. Without the Task.Run, calling it from inside a parent agent’s tool handler would overwrite the parent’s CurrentRunContext, corrupting subsequent tool invocations in the same FICC pipeline. Forking the ExecutionContext prevents this.
State is split across two objects. SubAgentState is JSON-serialisable and tracks task metadata (ID, status, agent name, description, result text, error text). SubAgentRuntimeState holds the live Task<AgentResponse> and AgentSession references — neither is JSON-serialisable, both are [JsonIgnore]. After a restart, SubAgentRuntimeState starts empty and tasks that had been Running are marked Lost when the provider tries to find their in-flight tasks.
ContinueTask re-runs the sub-agent on its original session, so the sub-agent retains conversational context. This is useful when the first response was incomplete and the parent has a follow-up: “The price you gave was $183.21 but that looks like it might be the open, not the close. Can you verify?”
Problem 6: Approval without friction — ToolApprovalAgent
The problem
Some tool calls should require explicit human approval before execution — especially tools that modify external state (send emails, book calendar slots, post to external APIs). But “ask the user every time” creates severe friction in long runs. If the agent sends 40 summary emails and the user has approved 39 of them, re-presenting the approval prompt for the 40th is useless ceremony.
The canonical UX pattern is “don’t ask again”: approve once, save the rule, auto-approve from then on. The question is where to enforce this. Doing it in system instructions (“remember that the user said always approve SendEmail”) is unreliable — instructions get compacted, the model forgets. It needs to live at the execution layer.
The solution
ToolApprovalAgent is a DelegatingAIAgent middleware — it wraps the inner agent and intercepts the approval flow at the message layer, before tool execution.
The middleware handles two directions:
Outbound (agent → caller): When the inner agent returns ToolApprovalRequestContent items (approval requests), the middleware checks each one against stored ToolApprovalRule entries. Rules are either tool-level (approve any call to SendEmail) or tool+arguments (approve SendEmail only when recipient=hr@example.com). Matching requests are auto-approved and collected as ToolApprovalResponseContent objects to inject back to the inner agent. Non-matching requests go to the caller.
If the inner agent returns multiple approval requests at once and some are unapproved, the middleware returns only the first to the caller and queues the rest. This avoids presenting the user with a wall of approval dialogs and lets “always approve” rules applied to the first item propagate to the queue before the next one is shown.
Inbound (caller → agent): When the caller approves a request using AlwaysApproveToolApprovalResponseContent (created via the extension methods), the middleware unwraps it, persists the rule, and forwards only the plain ToolApprovalResponseContent to the inner agent:
// In the console UI, after the user picks "Always approve this tool (any arguments)":
AIContent response = request.CreateAlwaysApproveToolResponse("User chose to always approve this tool");
// The ToolApprovalAgent unwraps this, records:
// ToolApprovalRule { ToolName = "SendEmail", Arguments = null }
// ...and forwards the plain ToolApprovalResponseContent to the inner agent.
Rules are stored in ToolApprovalState in the session state bag. They survive across runs within the same session. Arguments are stored as their JSON-serialised string representations for reliable equality comparison — the same JsonSerializerOptions used elsewhere in the pipeline.
The middleware is registered via a builder extension:
agent.AsBuilder()
.UseToolApproval()
.Build();
This wraps whatever agent came out of BuildAIAgent(...) with the ToolApprovalAgent middleware. The inner agent never knows approvals are being intercepted.
Wiring it all together
The research assistant sample (Step01) shows the full composition:
AIAgent agent =
openAIClient
.GetResponsesClient()
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsBuilder()
.UseFunctionInvocation()
.UsePerServiceCallChatHistoryPersistence()
.UseAIContextProviders(new CompactionProvider(compactionStrategy))
.BuildAIAgent(new ChatClientAgentOptions
{
AIContextProviders =
[
new TodoProvider(),
new AgentModeProvider(),
new FileMemoryProvider(
new FileSystemAgentFileStore("agent-files"),
(_) => new FileMemoryState {
WorkingFolder = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") + "_" + Guid.NewGuid()
}),
],
ChatOptions = new ChatOptions
{
Tools = [ webSearchTool, webBrowsingTool ],
},
})
.AsBuilder()
.UseToolApproval()
.Build();
Read outward from the inside: the model is a stateless function. UseFunctionInvocation adds the tool-call loop. UsePerServiceCallChatHistoryPersistence saves the transcript on every service call rather than only at the end of a run. CompactionProvider trims the context before each LLM call when it would overflow. TodoProvider, AgentModeProvider, and FileMemoryProvider inject their tools and instructions into every invocation. UseToolApproval wraps the whole thing and intercepts approvals at the outer edge.
Each layer is independent. You can run TodoProvider without AgentModeProvider. You can use FileAccessProvider instead of FileMemoryProvider. SubAgentsProvider takes any IEnumerable<AIAgent> — those inner agents can have their own context providers, their own file stores, their own modes.
Why this matters
The ReAct loop post argued that the harness is where the engineering value lives. These providers are that argument made concrete. None of them are novel ideas:
- Working memory outside the context window: every serious agent framework has something like this
- Plan-then-execute modes: every serious production deployment enforces this
- Task tracking: every developer who has run a long agent job reaches for a todo list within two hours
- Parallel sub-agents: standard pattern for anything with independent sub-problems
- “Don’t ask again” approvals: borrowed directly from OS permission models
What MAF’s implementation does is make them composable, session-aware, and independent of any specific model or provider. The providers don’t care whether the backend is Azure AI Foundry, Anthropic’s API, or a local Ollama instance. They talk to the AIContext abstraction. The agent talks to its tools. Everything else is plumbing.
The samples in samples/02-agents/Harness show the full loop: a console harness that picks up TodoProvider and AgentModeProvider from the agent via agent.GetService<T>(), renders structured plan-mode output, handles approval prompts with a ToolApprovalObserver, and drives the full plan→execute workflow without any direct coupling to the agent’s internal state.
An LLM in a loop is not enough. A loop with memory, modes, task tracking, parallel delegation, and approval persistence — that’s a production agent.
Loading comments...