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.
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.
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:
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:
- Creates an
IChatClientforchatClientConfigNamethroughFabrCoreChatClientService, resolving the model and key fromfabrcore.json. - Wraps it in a
ChatClientAgentwhoseChatOptions.Instructionsis your configured system prompt and whoseToolsare the ones you pass. - Builds an
AgentSessionbacked byFabrCoreChatHistoryProvider, keyed onthreadId— this is what makes history durable across grain deactivations. - Returns all three as a
ChatClientAgentResult.
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
| Type | Role |
|---|---|
AIAgent | Abstract base for all agents. ChatClientAgent is the concrete one FabrCore builds. |
AgentSession | Conversation state carried across runs; exposes a StateBag. |
ChatClientAgentOptions | Configures instructions, tools, name, history provider, and AIContextProviderFactory. |
AgentResponse | Result of a non-streaming run. .Text concatenates all assistant text. |
AgentResponseUpdate | A single streamed chunk from RunStreamingAsync. |
ChatMessage / ChatRole | Microsoft.Extensions.AI message primitives. |
AITool / AIFunction | Tool 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:
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:
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.
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:
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.
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:
_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);
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:
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]);
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:
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:
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 key | Description |
|---|---|
_tokens_input | Total input tokens |
_tokens_output | Total output tokens |
_tokens_reasoning | Thinking / reasoning tokens |
_tokens_cached_input | Cached input tokens |
_llm_calls | Number of LLM calls, tool loops included |
_llm_duration_ms | Total LLM response time |
_model | Model ID from the last call |
_finish_reason | stop, 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.