Home / Docs / Agent Framework

Agent Framework

Microsoft Agent Framework

FabrCore does not replace the Microsoft Agent Framework — it runs on top of it. Your agent's reasoning loop is a standard ChatClientAgent from Microsoft.Agents.AI; what FabrCore adds is everything around it: a distributed Orleans lifecycle, durable chat history, principal-scoped identity, access control, and tool resolution. Knowing which layer owns what is the difference between fighting the framework and using it.

No extra package references needed

FabrCore.Sdk brings Microsoft.Agents.AI, Microsoft.Agents.AI.Abstractions, and Microsoft.Extensions.AI in as transitive dependencies. Upgrading them happens when you upgrade the FabrCore packages.

C# — using directives
using Microsoft.Agents.AI;      // AIAgent, AgentSession, ChatClientAgent, AgentResponse
using Microsoft.Extensions.AI; // ChatMessage, ChatRole, IChatClient, AITool, AIFunction
using FabrCore.Sdk;            // FabrCoreAgentProxy, CreateChatClientAgent

How FabrCore Wraps It

One protected method does the bridging. CreateChatClientAgent() resolves a chat client for the named model, wraps it in a ChatClientAgent carrying your system prompt and tools, and attaches a history provider so the conversation persists without any code from you:

C# — FabrCoreAgentProxy
protected async Task<ChatClientAgentResult> CreateChatClientAgent(
    string chatClientConfigName,
    string threadId,
    IList<AITool>? tools = null,
    Action<ChatClientAgentOptions>? configureOptions = null);

// Returns:
public record ChatClientAgentResult(
    AIAgent Agent,
    AgentSession Session,
    FabrCoreChatHistoryProvider? ChatHistoryProvider = null);

In order, it:

  1. Creates an IChatClient for chatClientConfigName through FabrCoreChatClientService, resolving the model and key from fabrcore.json.
  2. Wraps it in a ChatClientAgent whose ChatOptions.Instructions is your configured system prompt and whose Tools are the ones you pass.
  3. Builds an AgentSession backed by FabrCoreChatHistoryProvider, keyed on threadId — this is what makes history durable across grain deactivations.
  4. Returns all three as a ChatClientAgentResult.
threadId is the persistence key

Chat history is stored against threadId, not against the agent object. Two agents sharing a threadId share a conversation; a threadId that changes per activation silently starts a new one every time the grain wakes up. Choose it deliberately — see thread patterns below.

The Types You Actually Touch

TypeRole
AIAgentAbstract base for all agents. ChatClientAgent is the concrete one FabrCore builds.
AgentSessionConversation state carried across runs; exposes a StateBag.
ChatClientAgentOptionsConfigures instructions, tools, name, history provider, and AIContextProviderFactory.
AgentResponseResult of a non-streaming run. .Text concatenates all assistant text.
AgentResponseUpdateA single streamed chunk from RunStreamingAsync.
ChatMessage / ChatRoleMicrosoft.Extensions.AI message primitives.
AITool / AIFunctionTool abstractions; AIFunctionFactory.Create builds one from a method.

Running an Agent

Non-streaming waits for the whole answer. Use it when nothing is watching the response arrive:

C# — RunAsync
public override async Task<AgentMessage> OnMessage(AgentMessage message)
{
    var response = message.Response();

    var result = await _agent!.RunAsync(
        new ChatMessage(ChatRole.User, message.Message), _session!);

    // .Text concatenates every assistant message in the run.
    response.Message = result.Text;
    return response;
}

Streaming yields chunks as the model produces them. This is what a chat UI wants, and it is the more common choice in FabrCore agents:

C# — RunStreamingAsync
public override async Task<AgentMessage> OnMessage(AgentMessage message)
{
    var response = message.Response();

    await foreach (var update in _agent!.RunStreamingAsync(
        new ChatMessage(ChatRole.User, message.Message), _session!))
    {
        response.Message += update.Text;
    }

    return response;
}

A plain string works wherever a ChatMessage does — await _agent!.RunAsync(message.Message ?? "", _session!). When you need the Microsoft.Extensions.AI shapes instead, result.AsChatResponse() and update.AsChatResponseUpdate() convert between them.

Thread Patterns

Three shapes cover almost every agent. They differ only in what threadId is derived from, and that choice decides who shares memory with whom.

Single session — the default

One conversation per agent instance. Correct when the agent belongs to one principal already, which is the usual case for a handle like principal1:assistant.

C# — one session per agent
private AIAgent? _agent;
private AgentSession? _session;

public override async Task OnInitialize()
{
    var tools = await ResolveConfiguredToolsAsync();

    var result = await CreateChatClientAgent(
        config.Models ?? "default",
        threadId: config.Handle ?? fabrcoreAgentHost.GetHandle(),
        tools: tools);

    _agent = result.Agent;
    _session = result.Session;
}

Per-principal session

One shared agent serving several principals, each with its own history. The threadId carries the principal, so nobody sees anyone else's conversation:

C# — session per principal
private readonly Dictionary<string, (AIAgent Agent, AgentSession Session)> _principalSessions = new();

public override async Task<AgentMessage> OnMessage(AgentMessage message)
{
    var principalHandle = message.FromHandle ?? "anonymous";

    if (!_principalSessions.TryGetValue(principalHandle, out var session))
    {
        var result = await CreateChatClientAgent(
            config.Models ?? "default",
            threadId: $"{config.Handle}-{principalHandle}",
            tools: await ResolveConfiguredToolsAsync());

        session = (result.Agent, result.Session);
        _principalSessions[principalHandle] = session;
    }

    var response = message.Response();
    await foreach (var update in session.Agent.RunStreamingAsync(
        new ChatMessage(ChatRole.User, message.Message), session.Session))
    {
        response.Message += update.Text;
    }
    return response;
}

Per-message session — stateless

A fresh threadId per message means no history at all. Right for classifiers, extractors, and routers, where carrying context between calls is a bug rather than a feature. It rebuilds the agent on every message, so it costs more per call.

C# — stateless
public override async Task<AgentMessage> OnMessage(AgentMessage message)
{
    var result = await CreateChatClientAgent(
        config.Models ?? "default",
        threadId: Guid.NewGuid().ToString(),
        tools: await ResolveConfiguredToolsAsync());

    var response = message.Response();
    var aiResult = await result.Agent.RunAsync(
        new ChatMessage(ChatRole.User, message.Message), result.Session);

    response.Message = aiResult.Text;
    return response;
}

Session State

Every AgentSession carries an AgentSessionStateBag for values that belong to the conversation but are not messages — a retrieved document set, a workflow step, a resolved customer record:

C# — AgentSessionStateBag
_session!.StateBag.SetValue("customer", resolvedCustomer);

if (_session.StateBag.TryGetValue<Customer>("customer", out var customer))
{
    // ...
}

// Round-trips as JSON for persistence.
JsonElement json = _session.StateBag.Serialize();
var restored = AgentSessionStateBag.Deserialize(json);
StateBag is not agent state

The state bag lives with the session. For durable, private agent data that must survive grain deactivation, use the proxy's own state APIs — GetStateAsync<T> and TryGetStateAsync<T> — documented under Persistence.

Agent as a Tool

Any AIAgent converts to an AIFunction, which lets one agent call another as an ordinary tool:

C# — composition
var weatherAgent = new ChatClientAgent(chatClient,
    instructions: "You answer weather questions.",
    name: "WeatherAgent",
    description: "An agent that answers weather questions.",
    tools: [AIFunctionFactory.Create(GetWeather)]);

AIFunction weatherTool = weatherAgent.AsAIFunction();

var mainAgent = new ChatClientAgent(chatClient,
    instructions: "You are a helpful assistant.",
    tools: [weatherTool]);
Reach for this only for a single immediate result

Agent-as-tool is synchronous composition owned by the parent model, with no tracked client, no scoped tool risk checks, no timeout or concurrency bounds, and no lifecycle cleanup. For several private specialists inside one FabrCoreAgentProxy, use CreateInternalAgentAsync and the Harness background roster instead — those give you all of the above, and the specialists stay in-process AIAgent objects with no FabrCore handles of their own.

Middleware

AsBuilder() wraps an existing agent so you can intercept whole runs — for logging, redaction, or injecting context:

C# — run middleware
var wrapped = originalAgent
    .AsBuilder()
    .Use(
        runFunc: async (messages, session, options, innerAgent, ct) =>
        {
            var response = await innerAgent.RunAsync(messages, session, options, ct);
            return response;
        },
        // Omitting the streaming delegate reuses runFunc for both paths.
        runStreamingFunc: null)
    .Build();

A second overload intercepts individual tool invocations, which is the right seam for auditing or gating what the model is allowed to call:

C# — function-calling middleware
var agent = originalAgent
    .AsBuilder()
    .Use(async (agent, context, next, ct) =>
    {
        logger.LogInformation("Calling {Tool}", context.Function.Name);
        var result = await next(context, ct);
        return result;
    })
    .Build();

LLM Usage Tracking

FabrCore aggregates LLM metrics across every call made inside a single OnMessage — including tool loops and delegated agent calls — and attaches the totals to the response's AgentMessage.Args. No opt-in, and no Agent Monitor required to read them:

Args keyDescription
_tokens_inputTotal input tokens
_tokens_outputTotal output tokens
_tokens_reasoningThinking / reasoning tokens
_tokens_cached_inputCached input tokens
_llm_callsNumber of LLM calls, tool loops included
_llm_duration_msTotal LLM response time
_modelModel ID from the last call
_finish_reasonstop, length, tool_calls, or content_filter

Only non-zero values are set, so treat a missing key as zero rather than an error. A _finish_reason of length on responses that look truncated is usually the fastest explanation available, and _llm_calls climbing far above expectations is the signature of a tool loop that is not converging. Deeper per-call capture lives in Monitoring.

Interop: A2AAgentProxy

The bridge also runs the other way. A2AAgentProxy is itself an AIAgent that wraps a remote FabrCore agent by handle, so a distributed Orleans agent can take part in ordinary Agent Framework workflows — sequential pipelines, parallel fan-out, hand-off patterns — as though it were local. See Communication for the messaging contract underneath it.

Documentation