Persistence
Persistence
FabrCore provides built-in persistence through Orleans grain state, enabling agents to maintain conversation history and custom data across activations, cluster restarts, and silo migrations.
FabrCoreChatHistoryProvider
FabrCoreChatHistoryProvider extends Microsoft's ChatMessageStore with Orleans-backed persistence. It's automatically wired when using CreateChatClientAgent.
Messages are held in memory until flush — fast, no I/O per message.
History is loaded from Orleans state on first access, not on initialization.
Automatic Persistence (Recommended)
[AgentAlias("PersistentAgent")]
public class PersistentAgent : FabrCoreAgentProxy
{
private AIAgent? agent;
private AgentThread? thread;
public override async Task OnInitialize()
{
(agent, thread) = await CreateChatClientAgent(
"default",
threadId: config.Handle);
}
public override async Task<AgentMessage> OnMessage(AgentMessage message)
{
var response = message.Response();
var result = await agent!.RunAsync(message.Message, thread);
response.Message = result.Text;
return response;
// No manual FlushAsync() needed!
}
}
After each OnMessage completes, the Orleans grain automatically calls FlushAsync() on all tracked stores. On grain deactivation, any remaining pending messages are flushed to ensure no data loss.
Thread ID Strategies
| Strategy | Thread ID | Use Case |
|---|---|---|
| One per agent | config.Handle | Personal assistant, single-user agents |
| Per-user | message.FromHandle | Multi-user agents |
| Per-channel | message.Channel ?? "default" | Channel-based chat |
| Combined | $"{fromHandle}:{channel}" | User-specific within channels |
API Reference
| Method | Description |
|---|---|
AddMessagesAsync(messages) | Add messages to in-memory buffer (fast, no I/O) |
GetMessagesAsync() | Get all messages (persisted + pending), lazy-loads from Orleans |
FlushAsync() | Persist pending messages to Orleans grain state |
HasPendingMessages | Returns true if there are unsaved messages |
ThreadId | The unique thread identifier |
Custom State
Persist arbitrary typed data (user info, counters, preferences) that survives grain deactivation:
| Method | Description |
|---|---|
GetStateAsync<T>(key) | Get a strongly-typed value by key |
GetStateOrCreateAsync<T>(key, factory) | Get existing value or create with factory |
HasStateAsync(key) | Check if a key exists |
SetState<T>(key, value) | Set a value (buffered until flush) |
RemoveState(key) | Remove a key (buffered until flush) |
FlushStateAsync() | Persist all pending changes to Orleans |
public override async Task<AgentMessage> OnMessage(AgentMessage message)
{
var response = message.Response();
// Get or create pattern
var stats = await GetStateOrCreateAsync(
"stats", () => new ConversationStats());
stats.MessageCount++;
stats.LastMessage = DateTime.UtcNow;
SetState("stats", stats);
// Process message...
var result = await agent!.RunAsync(message.Message, thread);
response.Message = result.Text;
await FlushStateAsync();
return response;
}
Group related state updates and call FlushStateAsync() once at the end of OnMessage. On grain deactivation, pending changes are auto-flushed to prevent data loss.
Message Compaction
Long conversations fill the model's context window. Compaction automatically summarizes older messages when the conversation grows too large.
How It Works
- Estimate — Token count of all stored messages is estimated before each message
- Threshold — If the estimate exceeds the configured threshold (default 75%), compaction triggers
- Summarize — Older messages are sent to the LLM for summarization, preserving key decisions, facts, and context
- Replace — Older messages are replaced with a single
[Compacted History]summary. The most recent messages (default 20) are always kept intact
Configuration
| Setting | Default | Description |
|---|---|---|
CompactionEnabled | true | Enable or disable compaction |
CompactionKeepLastN | 20 | Recent messages to always preserve |
CompactionThreshold | 0.75 | Trigger at this ratio of context window |
Using TryCompactAsync
Call TryCompactAsync() before model invocations to ensure the history fits within the context window:
public override async Task<AgentMessage> OnMessage(AgentMessage message)
{
// Compact before invoking the model
var compaction = await TryCompactAsync();
if (compaction?.WasCompacted == true)
{
logger.LogInformation(
"Compacted: {Original} -> {Compacted} messages",
compaction.OriginalMessageCount,
compaction.CompactedMessageCount);
}
// Now invoke the model with compacted history
var response = await agent!.RunAsync(message.Message, thread);
// ...
}
Compaction invokes an LLM call for summarization, which adds latency. Explicit TryCompactAsync() calls give you control over when this happens — typically before model invocations, not during message addition.
Compaction requires ContextWindowTokens set on your model in fabrcore.json. Without it, the agent can't determine when the context window is filling up.
Orleans Storage Providers
Message threads and custom state are stored in Orleans grain state. Supported providers:
- In-memory — Development only, data lost on restart
- SQL Server — Production, durable
- Azure Table Storage — Cloud-native
- ADO.NET providers — PostgreSQL, MySQL, etc.