Overriding AgentBase<T>
Agent<T> covers the everyday case. When you need behaviour the
constructor doesn't expose — async system prompt rendering, custom
output validation, custom deserialisation, retry telemetry — subclass
AgentBase<T> and override the relevant protected virtual method.
This page walks each override with a real reason to use it.
What you can override
public abstract class AgentBase<TOutput> : IAgent
{
public abstract string Name { get; }
public abstract string Description { get; }
protected abstract string SystemPrompt { get; }
protected abstract LlmClientBase LlmClient { get; }
protected virtual IList<ToolBase> Tools => …;
protected virtual LlmOptions LlmOptions => …;
protected virtual RetryPolicy RetryPolicy => …;
protected virtual IToolCallingStrategy ToolCallingStrategy => …;
protected virtual string RenderSystemPrompt(string input, AgentContext ctx);
protected virtual Task<string> RenderSystemPromptAsync(string input, AgentContext ctx, CancellationToken ct);
protected virtual string? ValidateOutput(string raw);
protected virtual TOutput DeserializeOutput(string raw);
protected virtual void OnRetry(int attempt, string rawResponse, string error);
}
RenderSystemPromptAsync — async prompt building
The system prompt the LLM sees can depend on data you only have
asynchronously: a RAG retrieval, a profile lookup, a recent-events
query. Override RenderSystemPromptAsync.
public sealed class PersonalisedAgent : AgentBase<string>
{
private readonly SemanticMemory _memory;
public override string Name => "Personalised";
public override string Description => "Remembers across runs.";
protected override LlmClientBase LlmClient { get; }
protected override string SystemPrompt => """
You are a helpful assistant.
What you remember about this user:
{{memories}}
""";
public PersonalisedAgent(LlmClientBase llm, SemanticMemory memory)
{
LlmClient = llm;
_memory = memory;
}
protected override async Task<string> RenderSystemPromptAsync(
string input, AgentContext ctx, CancellationToken ct)
{
var hits = await _memory.RecallAsync(input, topK: 5, ct: ct);
var memories = hits.Count == 0
? "(nothing yet)"
: string.Join("\n", hits.Select(h => $"- {h.Document.Text}"));
var template = new LogicGrid.Core.Prompt.PromptTemplate(SystemPrompt);
return template.Render(input, new Dictionary<string, string>
{
["memories"] = memories,
});
}
}
The default RenderSystemPromptAsync delegates to the synchronous
RenderSystemPrompt, so override the async one when you need to do
I/O and the sync one when you don't.
ValidateOutput — custom retry triggers
Returns null if the raw response is valid, otherwise a string
describing the problem. The framework re-calls the LLM on every
non-null result, up to RetryPolicy.MaxRetries.
The default validates JSON shape when TOutput is not string.
Override to add domain rules.
public sealed class CountedAgent : AgentBase<string>
{
public override string Name => "Counted";
public override string Description => "Replies with exactly three bullets.";
protected override string SystemPrompt =>
"Reply with exactly three bullet lines starting with '- '.";
protected override LlmClientBase LlmClient { get; }
public CountedAgent(LlmClientBase llm) { LlmClient = llm; }
protected override string? ValidateOutput(string raw)
{
var bullets = raw
.Split('\n')
.Count(l => l.TrimStart().StartsWith("- "));
return bullets == 3
? null
: $"Expected exactly 3 bullets, got {bullets}.";
}
}
When validation fails, the framework fires LlmCallRetryingEvent
with your error message and re-prompts the LLM with the failure
context.
DeserializeOutput — custom output parsing
The default extracts JSON and deserialises with
JsonExtractor.Deserialize<TOutput>. Override when your output
format isn't JSON or your TOutput needs custom construction.
public sealed class CsvRowAgent : AgentBase<string[]>
{
public override string Name => "CsvRow";
public override string Description => "Returns a CSV row.";
protected override string SystemPrompt =>
"Reply with a single CSV row of values, no header.";
protected override LlmClientBase LlmClient { get; }
public CsvRowAgent(LlmClientBase llm) { LlmClient = llm; }
protected override string[] DeserializeOutput(string raw)
=> raw.Trim().Split(',').Select(c => c.Trim()).ToArray();
protected override string? ValidateOutput(string raw)
=> raw.Contains(',') ? null : "Expected a CSV row.";
}
OnRetry — retry telemetry
The framework already fires LlmCallRetryingEvent. Override OnRetry
when you want to do something that isn't an event subscription —
update a counter, write to a side database, mutate the agent's
internal state.
protected override void OnRetry(int attempt, string rawResponse, string error)
{
_retryCounter.Add(1, new KeyValuePair<string, object?>("agent", Name));
}
Don't put expensive work here — OnRetry runs synchronously inside
the retry loop.
Computed Tools / LlmOptions / RetryPolicy
The defaults make these simple constants. Override when the value depends on state that isn't available at construction time:
public sealed class GreedyAgent : AgentBase<string>
{
private readonly bool _includeWebSearch;
public override string Name => "Greedy";
public override string Description => "Sometimes uses search.";
protected override string SystemPrompt => "…";
protected override LlmClientBase LlmClient { get; }
public GreedyAgent(LlmClientBase llm, bool includeWebSearch)
{
LlmClient = llm;
_includeWebSearch = includeWebSearch;
}
protected override IList<ToolBase> Tools => _includeWebSearch
? new ToolBase[] { new CalculatorTool(), new WebSearchTool(_searchOpts) }
: new ToolBase[] { new CalculatorTool() };
}
When not to subclass
If your override is just to wrap a stable name + description + system
prompt, you don't need a subclass — Agent<T> already does that. The
useful overrides are:
RenderSystemPromptAsync(async prompt build)ValidateOutput(domain-specific validation)DeserializeOutput(non-JSON parsing)OnRetry(custom side effects)- Computed
Tools/LlmOptions
Anything else, stay on Agent<T>.