Skip to content

Commit f27eb0d

Browse files
feat: Task 2.11 - Implement Agent Loop (LLM ↔ Tool orchestration)
- Add AgentLoop class with full LLM ↔ tool calling orchestration - Implement AgentRequest, AgentResult, ToolExecution records - Add event publishing for ToolStartedEvent and ToolCompletedEvent - Support configurable max iterations (default: 20) - Handle tool execution errors gracefully - Add comprehensive unit tests (7 tests, all passing) - Follows TDD methodology: RED -> GREEN -> REFACTOR Features: - Loops until LLM returns final text response - Executes tools requested by LLM - Publishes events to MessageBus for observability - Handles unknown tools with error messages - Stops gracefully at max iteration limit - Captures and reports tool exceptions Tests: - ✅ RunAsync_SimpleResponse_ReturnsContent - ✅ RunAsync_WithToolCall_ExecutesToolAndContinues - ✅ RunAsync_UnknownTool_ReturnsErrorResult - ✅ RunAsync_MaxIterations_StopsGracefully - ✅ RunAsync_ToolException_CapturedAsError - ✅ RunAsync_PublishesToolEvents - ✅ RunAsync_PassesToolsToProvider Technical notes: - Converts between ClawSharp.Core.Tools.ToolSpec and ClawSharp.Core.Providers.ToolSpec - Uses InProcessMessageBus for event publishing - Returns both LlmMessage and ToolExecution from tool execution
1 parent 2e8bffb commit f27eb0d

3 files changed

Lines changed: 439 additions & 0 deletions

File tree

src/ClawSharp.Agent/AgentLoop.cs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
using System.Text.Json;
2+
using ClawSharp.Core.Channels;
3+
using ClawSharp.Core.Providers;
4+
using ClawSharp.Core.Tools;
5+
using Microsoft.Extensions.Logging;
6+
using ProviderToolSpec = ClawSharp.Core.Providers.ToolSpec;
7+
8+
namespace ClawSharp.Agent;
9+
10+
/// <summary>
11+
/// The core agent loop that orchestrates LLM ↔ tool calling.
12+
/// </summary>
13+
public class AgentLoop
14+
{
15+
private readonly ILlmProvider _provider;
16+
private readonly IToolRegistry _tools;
17+
private readonly IMessageBus _messageBus;
18+
private readonly ILogger<AgentLoop> _logger;
19+
private readonly int _maxIterations;
20+
21+
/// <summary>
22+
/// Request for running the agent loop.
23+
/// </summary>
24+
public record AgentRequest(
25+
string Model,
26+
IReadOnlyList<LlmMessage> InitialMessages
27+
);
28+
29+
/// <summary>
30+
/// Result of running the agent loop.
31+
/// </summary>
32+
public record AgentResult(
33+
string Content,
34+
IReadOnlyList<ToolExecution> ToolExecutions
35+
);
36+
37+
/// <summary>
38+
/// Record of a tool execution.
39+
/// </summary>
40+
public record ToolExecution(
41+
string ToolCallId,
42+
string ToolName,
43+
string ArgumentsJson,
44+
ToolResult Result
45+
);
46+
47+
/// <summary>
48+
/// Event published when a tool starts execution.
49+
/// </summary>
50+
public record ToolStartedEvent(
51+
string ToolCallId,
52+
string ToolName,
53+
string ArgumentsJson
54+
);
55+
56+
/// <summary>
57+
/// Event published when a tool completes execution.
58+
/// </summary>
59+
public record ToolCompletedEvent(
60+
string ToolCallId,
61+
string ToolName,
62+
ToolResult Result
63+
);
64+
65+
/// <summary>
66+
/// Creates a new AgentLoop.
67+
/// </summary>
68+
public AgentLoop(
69+
ILlmProvider provider,
70+
IToolRegistry tools,
71+
IMessageBus messageBus,
72+
ILogger<AgentLoop> logger,
73+
int maxIterations = 20)
74+
{
75+
_provider = provider;
76+
_tools = tools;
77+
_messageBus = messageBus;
78+
_logger = logger;
79+
_maxIterations = maxIterations;
80+
}
81+
82+
/// <summary>
83+
/// Run the agent loop with the given request.
84+
/// </summary>
85+
public async Task<AgentResult> RunAsync(AgentRequest request, CancellationToken ct = default)
86+
{
87+
var messages = new List<LlmMessage>(request.InitialMessages);
88+
var toolExecutions = new List<ToolExecution>();
89+
var toolSpecs = _tools.GetSpecifications();
90+
91+
// Convert from Tools.ToolSpec to Providers.ToolSpec
92+
var providerToolSpecs = toolSpecs.Count > 0
93+
? toolSpecs.Select(t => new ProviderToolSpec(t.Name, t.Description, t.ParametersSchema)).ToList()
94+
: null;
95+
96+
for (int iteration = 0; iteration < _maxIterations; iteration++)
97+
{
98+
var llmRequest = new LlmRequest
99+
{
100+
Model = request.Model,
101+
Messages = messages,
102+
Tools = providerToolSpecs
103+
};
104+
105+
var response = await _provider.CompleteAsync(llmRequest, ct);
106+
107+
// If no tool calls, we're done
108+
if (response.ToolCalls.Count == 0)
109+
{
110+
return new AgentResult(response.Content, toolExecutions);
111+
}
112+
113+
// Process tool calls
114+
foreach (var toolCall in response.ToolCalls)
115+
{
116+
var (toolMessage, execution) = await ExecuteToolAsync(toolCall, ct);
117+
toolExecutions.Add(execution);
118+
messages.Add(toolMessage);
119+
}
120+
121+
// Add assistant message with tool calls to history
122+
messages.Add(new LlmMessage("assistant", response.Content, response.ToolCalls));
123+
}
124+
125+
// Max iterations reached
126+
return new AgentResult(
127+
$"Maximum iteration limit ({_maxIterations}) reached. Consider increasing the limit or optimizing your tool usage.",
128+
toolExecutions
129+
);
130+
}
131+
132+
private async Task<(LlmMessage Message, ToolExecution Execution)> ExecuteToolAsync(ToolCallRequest toolCall, CancellationToken ct)
133+
{
134+
// Publish started event
135+
await _messageBus.PublishAsync(new ToolStartedEvent(
136+
toolCall.Id,
137+
toolCall.Name,
138+
toolCall.ArgumentsJson), ct);
139+
140+
var tool = _tools.Get(toolCall.Name);
141+
ToolResult result;
142+
143+
if (tool == null)
144+
{
145+
result = new ToolResult(false, string.Empty, $"Tool '{toolCall.Name}' not found");
146+
}
147+
else
148+
{
149+
try
150+
{
151+
var args = JsonSerializer.Deserialize<JsonElement>(toolCall.ArgumentsJson);
152+
result = await tool.ExecuteAsync(args, ct);
153+
}
154+
catch (Exception ex)
155+
{
156+
result = new ToolResult(false, string.Empty, $"Error executing tool: {ex.Message}");
157+
}
158+
}
159+
160+
var execution = new ToolExecution(
161+
toolCall.Id,
162+
toolCall.Name,
163+
toolCall.ArgumentsJson,
164+
result
165+
);
166+
167+
// Publish completed event
168+
await _messageBus.PublishAsync(new ToolCompletedEvent(
169+
toolCall.Id,
170+
toolCall.Name,
171+
result), ct);
172+
173+
// Return tool result message and execution
174+
return (
175+
new LlmMessage(
176+
"tool",
177+
result.Success ? result.Output : $"Error: {result.Error}",
178+
ToolCallId: toolCall.Id,
179+
Name: toolCall.Name
180+
),
181+
execution
182+
);
183+
}
184+
}

0 commit comments

Comments
 (0)