Skip to main content

Overriding AdminBase<TInput, TOutput>

The six built-in admins (Sequential, GroupChat, Parallel, MapReduce, Reflexion, Graph) cover almost every orchestration pattern you'll need. Custom admins are the right answer when:

  • You're modelling a domain-specific workflow that doesn't map onto any built-in shape (a state machine driven by external triggers, a human-in-the-loop checkpoint, a distributed execution split across workers).
  • You want to bake fixed prompt sequencing or special tool-use rules that aren't expressible as agent prompts alone.

For everything else, prefer the built-ins — they handle event firing, cost accumulation, and budget enforcement for you.

What you get from AdminBase

public abstract class AdminBase<TInput, TOutput>
{
public abstract string Name { get; }
protected abstract LlmClientBase LlmClient { get; }
protected abstract IList<IAgent> Agents { get; }
protected virtual AdminOptions Options => new AdminOptions();

public List<LlmMessage> MessageHistory { get; } // persistent across the run

protected CostEstimate RunCost { get; } // accumulated this run
protected void ResetRunState(); // call at start of RunAsync
protected void AccumulateCost(CostEstimate cost); // add per-call cost
protected Task CheckBudgetAsync(AgentContext ctx); // fire warning / throw on cap
}

MessageHistory is the difference between agents and admins: agents reset, admins persist. That's how a GroupChatAdmin lets agent N+1 see what agents 1..N said.

A minimal custom admin

A round-robin admin that runs every agent in a fixed cycle for N turns, regardless of output content.

using LogicGrid.Core.Admins;
using LogicGrid.Core.Agents;
using LogicGrid.Core.Events;
using LogicGrid.Core.Llm;

public sealed class RoundRobinAdmin : AdminBase<string, string>
{
private readonly IList<IAgent> _agents;
private readonly LlmClientBase _llm;
private readonly int _turns;
private readonly IAgentEventBus? _bus;

public override string Name { get; }
protected override LlmClientBase LlmClient => _llm;
protected override IList<IAgent> Agents => _agents;

public RoundRobinAdmin(
string name,
LlmClientBase llm,
IList<IAgent> agents,
int turns,
IAgentEventBus? bus = null)
{
Name = name;
_llm = llm;
_agents = agents;
_turns = turns;
_bus = bus;
}

public async Task<string> RunAsync(string input, CancellationToken ct = default)
{
ResetRunState();
var ctx = new AgentContext(input) { EventBus = _bus };

if (_bus != null)
await _bus.PublishAsync(new RunStartedEvent(
ctx.RunId, Name, input, DateTimeOffset.UtcNow));

string current = input;
try
{
for (int i = 0; i < _turns; i++)
{
var agent = _agents[i % _agents.Count];
agent.EventBus = _bus;
current = await agent.RunAsync(current, ctx, ct);

AccumulateCost(/* obtained from the agent's last span — see below */);
await CheckBudgetAsync(ctx);
}

if (_bus != null)
await _bus.PublishAsync(new RunCompletedEvent(
ctx.RunId, Name, current, _agents.Count, _turns,
DateTimeOffset.UtcNow - DateTimeOffset.UtcNow));

return current;
}
catch (Exception ex)
{
if (_bus != null)
await _bus.PublishAsync(new RunFailedEvent(ctx.RunId, Name, ex, DateTimeOffset.UtcNow));
throw;
}
}
}

A few things to notice:

  • Set agent.EventBus before each call. Agents only emit events when their bus is set — that's how the framework correlates each agent's events to the run.
  • Pass the same ctx into every agent. That's the bus + run-id channel.
  • Call ResetRunState(), AccumulateCost, and CheckBudgetAsync. That's how RunCost and MaxBudgetUsd enforcement work — the base class doesn't auto-instrument; you opt in by calling these.

Reading per-call cost

AgentBase calculates and emits Cost on AgentCompletedEvent. Subscribe to it inside your admin and accumulate:

public RoundRobinAdmin(/* … */)
{
/* … */
if (_bus != null)
{
_bus.Subscribe<AgentCompletedEvent>(e =>
{
AccumulateCost(e.Cost);
CheckBudgetAsync(ctxRef).GetAwaiter().GetResult();
});
}
}

(ctxRef here is captured from the active run — in practice, hold the active context in a field cleared at the end of the run.)

Persistent MessageHistory

Append to MessageHistory every time an agent runs. The next agent gets to see the conversation when you supply it via the scoped vs admin history distinction:

foreach (var agent in _agents)
{
var input = string.Join("\n\n", MessageHistory.Select(m => $"[{m.Role}] {m.Content}"));
var output = await agent.RunAsync(input, ctx, ct);
MessageHistory.Add(LlmMessage.Assistant(output));
}

This is how GroupChatAdmin builds context: every agent's output is appended to MessageHistory, which the admin then renders into the next selection prompt.

Don't reinvent the wheel

If your admin is starting to look like a state machine:

  • For "do this, then that, then that": use SequentialAdmin.
  • For "let an LLM pick the next step": use GroupChatAdmin with the finalAgentName constructor argument set.
  • For "run these in any order, then collect": use ParallelAdmin.
  • For "process every item then summarise": use MapReduceAdmin.
  • For "actor → critic → revise": use ReflexionAdmin.
  • For everything else with explicit transitions: use GraphAdmin.

A custom admin earns its keep when it carries domain logic the built-ins genuinely don't express — most of the time, building a GraphAdmin is the right answer.