A .NET Agent Orchestration Framework - The Agent

Introduction

This is part three of my series on the agent orchestration framework I'm building in .NET. If you haven't already, read Introduction and Orchestrator before this one to get the gist of it.

This time I'm going to talk about the agent, what it is, how it relates to the orchestrator, and how it uses the Microsoft.Extensions.AI abstractions.

An agent, in my framework, is something that talks to an LLM model, asks it for the resolution of some task, and is also available for reviewing other tasks, and possibly (if there are reviews to process) revises its own generated work until either there are no more reviews or the maximum limit has been reached. The agent fully encapsulates all contact with the LLM using standard Microsoft.Extensions.AI interfaces and is quite lightweight, does not maintain much state besides the name and tags (conversation ids/history and a few other properties need to be maintained, but that is totally transparent).

The Agent Contract

Without further ado, here is the definition of the IAgent interface, which all agents in the framework must implement:

public interface IAgent
{
    string Name { get; }

    AgentSpecialization Specialization { get; }

    IReadOnlyList<string> Tags { get; }

    IReadOnlyList<string> Skills { get; }

    bool IsTrained { get; }

    bool IsProcessing { get; }

    Task<AgentTrainingResult> TrainAsync(OrchestrationContext context, CancellationToken cancellationToken = default);

    Task<AgentResponse> ExecuteAsync(OrchestrationContext context, AgentTask task, CancellationToken cancellationToken = default);

    Task<ReviewResult> ReviewAsync(OrchestrationContext context, AgentResponse response, CancellationToken cancellationToken = default);

    Task<AgentResponse> ReviseAsync(OrchestrationContext context, AgentResponse response, ReviewResult review, CancellationToken cancellationToken = default);
}

Here we can see the following properties:

  • Name: must be a unique (in the context of an Orchestrator), non-empty or null name. The format or what it looks like doesn't really matter (e.g., "Oracle Agent" or "myagent007")
  • Specialization: an AgentSpecialization instance, which is essentially a unique, normalised, name. It usually comes from the training, in the default implementation (more on this later). Example: ".NET Code"
  • Tags: some optional tags that identify this agent, together with its name. Tags can include something that this agent knows more about, like "SQL Server", "MySQL", etc
  • IsTrained: set to true only when the agent has finished training, which only happens once
  • IsProcessing: is true when the agent is processing (executing, reviewing, or revising)

Then we have a few methods that implement what the agent actually does:

  • TrainAsync: typically uses a provided IAgentTrainingProfile to train the agent, using a specialisation and skills; only happens once, then the agent is considered to be trained (IsTrained returns true)
  • ExecuteAsync: it's the core of the agent, it's what it is supposed to do. Typically, it will use an LLM model to process some task; when this is called, IsProcessing is true
  • ReviewAsync: reviews the work that some other agent has done; IsProcessing is true in the process 
  • ReviseAsync: revises (fixes) the work that it has created in the execution stage, based on the reviews received; IsProcessing is also true while revising 

Let's now delve into some of the implementation details.

The Agent Implementation

The only IAgent implementation available in the framework is SpecializedAgent. It is essentially a wrapper around an IChatClient, which is connected to an actual LLM, and this needs to be passed through the constructor. An agent has a name, which must be unique amongst all the agents passed to the Orchestrator, and has some optional tags that more finely describe it.

The agent takes an IAgentTrainingProfile instance, which can be shared by other agents, but is probably unique, as it describes exactly what the agent is supposed to do, it's specialisation, skills (possibly obtained from external files), and core instructions (a system prompt). The training is started only if and when the agent is to be used (its ExecuteAsync method is called). It is the training that provides the agent's specialisation (IAgent.Specialization) and skills (IAgent.Skills).

As I said, internally, the agent wraps an IChatClient instance. Depending on its nature - completions or responses -, it needs to manage the session separately, either by storing and passing a conversation id, or the whole history. This is reset for new tasks (new calls to ExecuteAsync) and is kept for all revises (ReviseAsync). Reviews (ReviewAsync) don't need history, because they are stateless, upon request, and always for other agents' work. The construction of the IChatClient is outside the scope of this framework, it can be whatever we want, the SpecializedAgent will happily use anything, but it must know the type of API in use - completions or responses.

All operations are asynchronous and can be cancelled through the provided CancellationToken parameter. The agent should not be scheduled while still processing, if it is, an exception is thrown: the agent itself is thread-safe, but the inner IChatClient is not. Operations produce OpenTelemetry traces and logs, which can be tuned using standard .NET processes.

The Execution

The agent builds a prompt from:

  • The passed prompt from the Orchestrator (derived from the original prompt and specialised to this agent)
  • The skills from the training profile 
  • Any relevant texts from the knowledge store

All of this is passed to the LLM as messages (system and user), when the Orchestrator invokes the ExecuteAsync method. The passed chat options are also used (a copy) with any registered MCP tools.

The Review/Revise Loop

When an agent is registered to an Orchestrator, it is available for picking up a task, which is sent to the internal IChatClient. It is also available for reviewing other agent's responses, and also for revising its own work, as requested by another agent when doing a review. So an agent can both be a reviewer and a reviser, and is always an executor - when the Orchestrator so decides.

What agent actually picks what tasks, and what agent actually reviews a task is decided by the Orchestrator, or, more precisely, by its IPlanner and IAgentSelector services. Typically, an agent never reviews its own work. These were introduced in the first post on this series, but will be covered in more depth in a future post.

When an agent is asked to review a task, it does so by delegating to the internal IChatClient. After it finished, it hands it over to the Orchestrator, which decides if more work is needed based on the outcome of this review. If so, it asks the agent which handled the task to revise it, and after it finishes, another review, by some other agent is conducted. This all happens in a loop, until either no more reviews are needed, or the maximum number of reviews is reached.

Service Dependencies

The SpecializedAgent class has some dependencies, all of which can be passed in its constructor:

  • IChatClient and ChatOptions: optional options (pun intended) to be passed to the IChatClient's GetResponseAsync method
  • IAgentTrainingProfile: the mandatory trainer, used to prepare the agent's system prompt
  • IAgentKnowledgeStore: an optional store to keep the agent's history, which can be used when processing new tasks
  • ISharedStateManager: shared state by all agents, a way to communicate between them
  • IMcpToolsProvider: an optional provider of MCP tools that can be registered with the agent; some are already registered by default, but this is for anything else the developer wants to make available
  • IFileOperations: a provider of file operations (read, write, etc) for MCP. If one is not passed, a default one is used
  • IResponseFilter: filters out the agent's responses. If no filter is provided, a default pass-through is used
  • IKnowledgeStore: a global knowledge store for RAG. Gets texts from the store based on the agents’ prompt which are then passed in the prompt to use. These texts are semantically close to the prompt
  • IToolRunManager: an optional manager for approving access to MCP tools from the agent

All of these will be covered in future posts.

Conclusion

This is essentially what I have to say about the agents in my agent orchestration framework. Do let me know if you have any questions/suggestions/disagreements, and stay tuned for the next post!

Comments

Popular posts from this blog

Modern Mapping with EF Core

OpenTelemetry with ASP.NET Core

.NET 10 Validation