Skip to content

Commit 10137db

Browse files
franklinicclaude
andcommitted
feat(agentic): Phase 2 — IAgent + native tool-calling AgentExecutor (#1544)
Foundational single-model agent for the multi-agent layer: - IAgent<T>: uniform named/described/RunAsync contract that both leaf agents and coordinators (supervisor/swarm) implement, so agents nest and any agent can be surfaced as a tool to another. - AgentExecutor<T>: drives an IChatClient<T> in a provider-native function-calling loop (call model -> run requested tools -> feed ToolResult messages back -> repeat) until a final answer or the iteration cap. Replaces the legacy prompt-parsed ReAct loop. Aggregates token usage; returns the full transcript. - AgentExecutorOptions (nullable defaults: name/description/system-prompt/max-iterations/sampling/tool-choice) + AgentRunResult (FinalText/Messages/Iterations/Completed/Usage). - Tests (10, green net10.0 + net471) via a deterministic ScriptedChatClient + RecordingTool: plain answer, tool round-trip with arg + result-passthrough assertions, system-prompt prepend, tool advertisement on/off, iteration-cap -> Completed=false, usage aggregation, empty/null guards. No null-forgiving. Stacked on Phase 0 (#1545); part of epic #1544 (Phase 2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 597097a commit 10137db

6 files changed

Lines changed: 665 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
using AiDotNet.Agentic.Models;
2+
using AiDotNet.Agentic.Tools;
3+
4+
// Disambiguate from the legacy AiDotNet.PromptEngineering.Templates.ChatMessage (global using).
5+
using ChatMessage = AiDotNet.Agentic.Models.ChatMessage;
6+
7+
namespace AiDotNet.Agentic.Agents;
8+
9+
/// <summary>
10+
/// A single-model agent that drives an <see cref="IChatClient{T}"/> in a native tool-calling loop:
11+
/// it calls the model, runs any tools the model requests, feeds the results back, and repeats until the
12+
/// model returns a final answer (or the iteration cap is hit).
13+
/// </summary>
14+
/// <typeparam name="T">The numeric type shared with the underlying <see cref="IChatClient{T}"/>.</typeparam>
15+
/// <remarks>
16+
/// <para>
17+
/// This replaces the legacy prompt-parsed ReAct loop with provider-native function calling: tool requests
18+
/// arrive as structured <see cref="ToolCallContent"/> rather than scraped from prose, and tool results go
19+
/// back as <see cref="ChatRole.Tool"/> messages. It is the leaf <see cref="IAgent{T}"/> that the multi-agent
20+
/// coordinators (supervisor/swarm) compose.
21+
/// </para>
22+
/// <para><b>For Beginners:</b> Give it a model and (optionally) a toolbox. Ask it something. Internally it
23+
/// loops: "model, here's the task and your tools" → if the model asks to use a tool, the executor runs it
24+
/// and tells the model the result → repeat → until the model just answers. You get that final answer back,
25+
/// plus the full transcript of what happened.
26+
/// </para>
27+
/// </remarks>
28+
public sealed class AgentExecutor<T> : IAgent<T>
29+
{
30+
private readonly IChatClient<T> _client;
31+
private readonly ToolCollection _tools;
32+
private readonly AgentExecutorOptions _options;
33+
34+
/// <summary>
35+
/// Initializes a new <see cref="AgentExecutor{T}"/>.
36+
/// </summary>
37+
/// <param name="client">The chat model the agent drives.</param>
38+
/// <param name="tools">The tools the agent may use. <c>null</c> means the agent has no tools.</param>
39+
/// <param name="options">Agent settings. <c>null</c> uses defaults.</param>
40+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is <c>null</c>.</exception>
41+
public AgentExecutor(IChatClient<T> client, ToolCollection? tools = null, AgentExecutorOptions? options = null)
42+
{
43+
Guard.NotNull(client);
44+
_client = client;
45+
_tools = tools ?? new ToolCollection();
46+
_options = options ?? new AgentExecutorOptions();
47+
}
48+
49+
/// <inheritdoc/>
50+
public string Name =>
51+
_options.Name is { } name && name.Trim().Length > 0 ? name : "agent";
52+
53+
/// <inheritdoc/>
54+
public string Description => _options.Description ?? string.Empty;
55+
56+
/// <summary>
57+
/// Runs the agent against a single user message.
58+
/// </summary>
59+
/// <param name="userMessage">The user's request.</param>
60+
/// <param name="cancellationToken">Token used to cancel the run.</param>
61+
/// <returns>A task producing the agent's <see cref="AgentRunResult"/>.</returns>
62+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="userMessage"/> is <c>null</c>.</exception>
63+
public Task<AgentRunResult> RunAsync(string userMessage, CancellationToken cancellationToken = default)
64+
{
65+
Guard.NotNull(userMessage);
66+
return RunAsync(new[] { ChatMessage.User(userMessage) }, cancellationToken);
67+
}
68+
69+
/// <inheritdoc/>
70+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="messages"/> is <c>null</c>.</exception>
71+
/// <exception cref="ArgumentException">Thrown when <paramref name="messages"/> is empty.</exception>
72+
public async Task<AgentRunResult> RunAsync(
73+
IReadOnlyList<ChatMessage> messages,
74+
CancellationToken cancellationToken = default)
75+
{
76+
Guard.NotNull(messages);
77+
if (messages.Count == 0)
78+
{
79+
throw new ArgumentException("The conversation must contain at least one message.", nameof(messages));
80+
}
81+
82+
var transcript = new List<ChatMessage>(messages.Count + 4);
83+
var systemPrompt = _options.SystemPrompt;
84+
if (systemPrompt is { } prompt && prompt.Trim().Length > 0)
85+
{
86+
transcript.Add(ChatMessage.System(prompt));
87+
}
88+
89+
transcript.AddRange(messages);
90+
91+
var hasTools = _tools.Count > 0;
92+
var requestOptions = new ChatOptions
93+
{
94+
Temperature = _options.Temperature,
95+
MaxOutputTokens = _options.MaxOutputTokens,
96+
Tools = hasTools ? _tools.GetDefinitions() : null,
97+
ToolChoice = hasTools ? (_options.ToolChoice ?? ToolChoiceMode.Auto) : null,
98+
};
99+
100+
var maxIterations = _options.MaxIterations is { } configured && configured > 0
101+
? configured
102+
: AgentExecutorOptions.DefaultMaxIterations;
103+
104+
var inputTokens = 0;
105+
var outputTokens = 0;
106+
var sawUsage = false;
107+
108+
for (var iteration = 1; iteration <= maxIterations; iteration++)
109+
{
110+
cancellationToken.ThrowIfCancellationRequested();
111+
112+
var response = await _client.GetResponseAsync(transcript, requestOptions, cancellationToken)
113+
.ConfigureAwait(false);
114+
transcript.Add(response.Message);
115+
116+
if (response.Usage is { } usage)
117+
{
118+
sawUsage = true;
119+
inputTokens += usage.InputTokens;
120+
outputTokens += usage.OutputTokens;
121+
}
122+
123+
var toolCalls = response.Message.ToolCalls;
124+
var wantsTools = response.FinishReason == ChatFinishReason.ToolCalls || toolCalls.Count > 0;
125+
126+
if (hasTools && wantsTools && toolCalls.Count > 0)
127+
{
128+
foreach (var call in toolCalls)
129+
{
130+
cancellationToken.ThrowIfCancellationRequested();
131+
var toolMessage = await _tools.InvokeToToolMessageAsync(call, cancellationToken)
132+
.ConfigureAwait(false);
133+
transcript.Add(toolMessage);
134+
}
135+
136+
continue;
137+
}
138+
139+
return AgentRunResult.Finished(
140+
response.Message.Text,
141+
transcript,
142+
iteration,
143+
sawUsage ? new ChatUsage(inputTokens, outputTokens) : null);
144+
}
145+
146+
var lastText = transcript.Count > 0 ? transcript[transcript.Count - 1].Text : string.Empty;
147+
return AgentRunResult.Stopped(
148+
lastText,
149+
transcript,
150+
maxIterations,
151+
sawUsage ? new ChatUsage(inputTokens, outputTokens) : null);
152+
}
153+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using AiDotNet.Agentic.Models;
2+
3+
namespace AiDotNet.Agentic.Agents;
4+
5+
/// <summary>
6+
/// Settings for an <see cref="AgentExecutor{T}"/>: identity, system prompt, the tool-loop budget, and the
7+
/// sampling knobs forwarded to each model call.
8+
/// </summary>
9+
/// <remarks>
10+
/// <para>
11+
/// Every value is nullable and defaults to a sensible behavior when left <c>null</c>, following the
12+
/// library-wide options pattern (zero-config by default, fully overridable). The executor applies the
13+
/// documented defaults internally, so the common case is <c>new AgentExecutor&lt;float&gt;(client)</c>.
14+
/// </para>
15+
/// <para><b>For Beginners:</b> These are the dials for one agent. The most useful ones are
16+
/// <see cref="SystemPrompt"/> (the agent's standing instructions / persona) and <see cref="MaxIterations"/>
17+
/// (how many think→use-tool→think rounds it may take before it must answer).
18+
/// </para>
19+
/// </remarks>
20+
public sealed class AgentExecutorOptions
21+
{
22+
/// <summary>
23+
/// The default maximum number of model calls in a single run when <see cref="MaxIterations"/> is unset.
24+
/// </summary>
25+
public const int DefaultMaxIterations = 10;
26+
27+
/// <summary>
28+
/// Gets or sets the agent's name (used by coordinators and when the agent is surfaced as a tool).
29+
/// <c>null</c> or empty falls back to <c>"agent"</c>.
30+
/// </summary>
31+
public string? Name { get; set; }
32+
33+
/// <summary>
34+
/// Gets or sets the agent's one-line specialty description. <c>null</c> falls back to an empty string.
35+
/// </summary>
36+
public string? Description { get; set; }
37+
38+
/// <summary>
39+
/// Gets or sets the system prompt prepended to every run (the agent's standing instructions/persona).
40+
/// <c>null</c> or whitespace means no system message is added.
41+
/// </summary>
42+
public string? SystemPrompt { get; set; }
43+
44+
/// <summary>
45+
/// Gets or sets the maximum number of model calls in one run (each tool round costs one call).
46+
/// <c>null</c> or a non-positive value uses <see cref="DefaultMaxIterations"/>. When the cap is reached
47+
/// before a final answer, the run returns with <see cref="AgentRunResult.Completed"/> set to <c>false</c>.
48+
/// </summary>
49+
public int? MaxIterations { get; set; }
50+
51+
/// <summary>
52+
/// Gets or sets the sampling temperature forwarded to each model call. <c>null</c> uses the connector default.
53+
/// </summary>
54+
public double? Temperature { get; set; }
55+
56+
/// <summary>
57+
/// Gets or sets the per-call output-token cap forwarded to each model call. <c>null</c> uses the connector default.
58+
/// </summary>
59+
public int? MaxOutputTokens { get; set; }
60+
61+
/// <summary>
62+
/// Gets or sets how eagerly the model may use the agent's tools when tools are present. <c>null</c> is
63+
/// treated as <see cref="ToolChoiceMode.Auto"/>. Ignored when the agent has no tools.
64+
/// </summary>
65+
public ToolChoiceMode? ToolChoice { get; set; }
66+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using AiDotNet.Agentic.Models;
2+
3+
// Disambiguate from the legacy AiDotNet.PromptEngineering.Templates.ChatMessage (global using).
4+
using ChatMessage = AiDotNet.Agentic.Models.ChatMessage;
5+
6+
namespace AiDotNet.Agentic.Agents;
7+
8+
/// <summary>
9+
/// The outcome of an agent run: the final text answer, the full conversation transcript it produced,
10+
/// how many model calls it took, whether it finished cleanly, and the aggregate token usage.
11+
/// </summary>
12+
/// <remarks>
13+
/// <para>
14+
/// <see cref="Messages"/> is the complete transcript the agent worked with (system prompt, the inbound
15+
/// conversation, every assistant turn, and every tool-result message), so a caller can inspect the
16+
/// agent's reasoning, persist the thread, or feed it into another agent. <see cref="Completed"/> is
17+
/// <c>false</c> only when the agent hit its iteration cap before producing a final (non-tool) answer.
18+
/// </para>
19+
/// <para><b>For Beginners:</b> After an agent runs, this is what you get back. <see cref="FinalText"/> is
20+
/// the answer most callers want; <see cref="Messages"/> is the full play-by-play if you want to see how it
21+
/// got there; <see cref="Completed"/> tells you whether it actually finished or ran out of allowed steps.
22+
/// </para>
23+
/// </remarks>
24+
public sealed class AgentRunResult
25+
{
26+
private AgentRunResult(
27+
string finalText,
28+
IReadOnlyList<ChatMessage> messages,
29+
int iterations,
30+
bool completed,
31+
ChatUsage? usage)
32+
{
33+
FinalText = finalText;
34+
Messages = messages;
35+
Iterations = iterations;
36+
Completed = completed;
37+
Usage = usage;
38+
}
39+
40+
/// <summary>
41+
/// Gets the agent's final text answer. When <see cref="Completed"/> is <c>false</c> this is the text of
42+
/// the last message produced before the iteration cap was hit (possibly empty).
43+
/// </summary>
44+
public string FinalText { get; }
45+
46+
/// <summary>
47+
/// Gets the complete conversation transcript the agent produced, including the system prompt (if any),
48+
/// the inbound messages, every assistant turn, and every tool-result message.
49+
/// </summary>
50+
public IReadOnlyList<ChatMessage> Messages { get; }
51+
52+
/// <summary>
53+
/// Gets the number of model calls the run made (each tool round is one call).
54+
/// </summary>
55+
public int Iterations { get; }
56+
57+
/// <summary>
58+
/// Gets a value indicating whether the agent produced a final answer (<c>true</c>) or stopped because it
59+
/// reached its iteration cap (<c>false</c>).
60+
/// </summary>
61+
public bool Completed { get; }
62+
63+
/// <summary>
64+
/// Gets the aggregate token usage across every model call in the run, or <c>null</c> when no call
65+
/// reported usage.
66+
/// </summary>
67+
public ChatUsage? Usage { get; }
68+
69+
/// <summary>
70+
/// Creates a result for a run that produced a final answer.
71+
/// </summary>
72+
internal static AgentRunResult Finished(
73+
string finalText,
74+
IReadOnlyList<ChatMessage> messages,
75+
int iterations,
76+
ChatUsage? usage) =>
77+
new(finalText, messages, iterations, completed: true, usage);
78+
79+
/// <summary>
80+
/// Creates a result for a run that hit its iteration cap before producing a final answer.
81+
/// </summary>
82+
internal static AgentRunResult Stopped(
83+
string lastText,
84+
IReadOnlyList<ChatMessage> messages,
85+
int iterations,
86+
ChatUsage? usage) =>
87+
new(lastText, messages, iterations, completed: false, usage);
88+
}

src/Agentic/Agents/IAgent.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using AiDotNet.Agentic.Models;
2+
3+
// Disambiguate from the legacy AiDotNet.PromptEngineering.Templates.ChatMessage, which is in scope
4+
// project-wide via a global using in AiModelBuilder.cs. The agentic subsystem uses the Models type.
5+
using ChatMessage = AiDotNet.Agentic.Models.ChatMessage;
6+
7+
namespace AiDotNet.Agentic.Agents;
8+
9+
/// <summary>
10+
/// A named, runnable agent: given a conversation, it produces a final answer (after optionally using
11+
/// tools and/or delegating to other agents along the way).
12+
/// </summary>
13+
/// <typeparam name="T">
14+
/// The numeric type shared with the underlying <see cref="IChatClient{T}"/> (e.g., <see cref="float"/>
15+
/// or <see cref="double"/>), so an agent and the model it drives agree on one tensor element type.
16+
/// </typeparam>
17+
/// <remarks>
18+
/// <para>
19+
/// This is the single abstraction the multi-agent layer composes. A leaf agent (<see cref="AgentExecutor{T}"/>)
20+
/// drives a chat model in a tool-calling loop; a coordinator (supervisor/swarm) is itself an
21+
/// <see cref="IAgent{T}"/> that routes work to other <see cref="IAgent{T}"/> members. Because the contract is
22+
/// uniform, agents nest: a supervisor can supervise supervisors, and any agent can be exposed to another as a
23+
/// callable tool.
24+
/// </para>
25+
/// <para><b>For Beginners:</b> An agent is a worker with a <see cref="Name"/> and a one-line
26+
/// <see cref="Description"/> of what it's good at. You hand it the conversation so far and it hands back a
27+
/// result. Whether it solved the task alone or quietly asked teammates for help is its own business — callers
28+
/// only see the final answer.
29+
/// </para>
30+
/// </remarks>
31+
public interface IAgent<T>
32+
{
33+
/// <summary>
34+
/// Gets the unique, stable name other agents and routers use to reference this agent.
35+
/// </summary>
36+
string Name { get; }
37+
38+
/// <summary>
39+
/// Gets a short natural-language description of the agent's specialty, used by coordinators (and by
40+
/// models, when the agent is surfaced as a tool) to decide when to route work here.
41+
/// </summary>
42+
string Description { get; }
43+
44+
/// <summary>
45+
/// Runs the agent over the supplied conversation and returns its result.
46+
/// </summary>
47+
/// <param name="messages">The ordered conversation so far. Must be non-null and non-empty.</param>
48+
/// <param name="cancellationToken">Token used to cancel the run.</param>
49+
/// <returns>A task producing the agent's <see cref="AgentRunResult"/>.</returns>
50+
Task<AgentRunResult> RunAsync(
51+
IReadOnlyList<ChatMessage> messages,
52+
CancellationToken cancellationToken = default);
53+
}

0 commit comments

Comments
 (0)