Skip to main content

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>.