|
1 | 1 | using System.Runtime.CompilerServices; |
2 | 2 | using FluentAssertions; |
3 | 3 | using Microsoft.Extensions.DependencyInjection; |
| 4 | +using SharpClaw.Code.Agents.Configuration; |
4 | 5 | using SharpClaw.Code.Infrastructure.Abstractions; |
5 | 6 | using SharpClaw.Code.Infrastructure.Models; |
6 | 7 | using SharpClaw.Code.Permissions.Abstractions; |
|
9 | 10 | using SharpClaw.Code.Plugins.Models; |
10 | 11 | using SharpClaw.Code.Protocol.Commands; |
11 | 12 | using SharpClaw.Code.Protocol.Enums; |
| 13 | +using SharpClaw.Code.Protocol.Events; |
12 | 14 | using SharpClaw.Code.Protocol.Models; |
13 | 15 | using SharpClaw.Code.Providers.Abstractions; |
14 | 16 | using SharpClaw.Code.Providers.Models; |
@@ -200,14 +202,55 @@ public async Task RunPrompt_should_deny_provider_requested_tool_that_is_not_in_a |
200 | 202 | shellExecutor.CallCount.Should().Be(0); |
201 | 203 | } |
202 | 204 |
|
| 205 | + /// <summary> |
| 206 | + /// Ensures callers can distinguish an incomplete provider result when the tool loop hits its iteration cap. |
| 207 | + /// </summary> |
| 208 | + [Fact] |
| 209 | + public async Task RunPrompt_should_surface_tool_loop_exhaustion() |
| 210 | + { |
| 211 | + var workspacePath = CreateTemporaryWorkspace(); |
| 212 | + await File.WriteAllTextAsync(Path.Combine(workspacePath, "README.md"), "SharpClaw"); |
| 213 | + |
| 214 | + var provider = new CapturingToolPolicyProvider(ToolPolicyScenario.ExhaustToolLoop); |
| 215 | + using var serviceProvider = CreateServiceProvider(provider, configureLoop: options => options.MaxToolIterations = 2); |
| 216 | + var runtime = serviceProvider.GetRequiredService<IConversationRuntime>(); |
| 217 | + |
| 218 | + var result = await runtime.RunPromptAsync( |
| 219 | + new RunPromptRequest( |
| 220 | + Prompt: "keep reading the file", |
| 221 | + SessionId: null, |
| 222 | + WorkingDirectory: workspacePath, |
| 223 | + PermissionMode: PermissionMode.WorkspaceWrite, |
| 224 | + OutputFormat: OutputFormat.Text, |
| 225 | + Metadata: new Dictionary<string, string> |
| 226 | + { |
| 227 | + ["provider"] = TestProviderName, |
| 228 | + ["model"] = "tool-policy-model", |
| 229 | + [SharpClawWorkflowMetadataKeys.AgentAllowedToolsJson] = """["read_file"]""", |
| 230 | + [ScenarioMetadataKey] = ToolPolicyScenario.ExhaustToolLoop, |
| 231 | + }, |
| 232 | + IsInteractive: true), |
| 233 | + CancellationToken.None); |
| 234 | + |
| 235 | + result.FinalOutput.Should().Contain("maximum of 2 iterations"); |
| 236 | + var completedEvent = result.Events.OfType<AgentCompletedEvent>().Should().ContainSingle().Subject; |
| 237 | + completedEvent.Summary.Should().Contain("maximum of 2 iterations"); |
| 238 | + } |
| 239 | + |
203 | 240 | private static ServiceProvider CreateServiceProvider( |
204 | 241 | CapturingToolPolicyProvider provider, |
205 | 242 | RecordingApprovalService? approvalService = null, |
206 | 243 | RecordingShellExecutor? shellExecutor = null, |
207 | | - IPluginManager? pluginManager = null) |
| 244 | + IPluginManager? pluginManager = null, |
| 245 | + Action<AgentLoopOptions>? configureLoop = null) |
208 | 246 | { |
209 | 247 | var services = new ServiceCollection(); |
210 | 248 | services.AddSharpClawRuntime(); |
| 249 | + if (configureLoop is not null) |
| 250 | + { |
| 251 | + services.Configure(configureLoop); |
| 252 | + } |
| 253 | + |
211 | 254 | services.AddSingleton<IProviderRequestPreflight, PassthroughPreflight>(); |
212 | 255 | services.AddSingleton<IAuthFlowService, AlwaysAuthenticatedAuthFlowService>(); |
213 | 256 | services.AddSingleton<IModelProviderResolver>(_ => new StaticModelProviderResolver(provider)); |
@@ -244,6 +287,7 @@ private static class ToolPolicyScenario |
244 | 287 | { |
245 | 288 | public const string CaptureOnly = "capture-only"; |
246 | 289 | public const string ToolRoundTrip = "tool-round-trip"; |
| 290 | + public const string ExhaustToolLoop = "exhaust-tool-loop"; |
247 | 291 | } |
248 | 292 |
|
249 | 293 | private sealed class PassthroughPreflight : IProviderRequestPreflight |
@@ -316,6 +360,24 @@ private static async IAsyncEnumerable<ProviderEvent> StreamEventsAsync( |
316 | 360 | yield break; |
317 | 361 | } |
318 | 362 |
|
| 363 | + if (string.Equals(scenario, ToolPolicyScenario.ExhaustToolLoop, StringComparison.Ordinal)) |
| 364 | + { |
| 365 | + yield return new ProviderEvent( |
| 366 | + "provider-event-loop", |
| 367 | + request.Id, |
| 368 | + "tool_use", |
| 369 | + DateTimeOffset.UtcNow, |
| 370 | + null, |
| 371 | + false, |
| 372 | + null, |
| 373 | + BlockType: "tool_use", |
| 374 | + ToolUseId: $"toolu_read_{request.Messages?.Count ?? 0:D3}", |
| 375 | + ToolName: "read_file", |
| 376 | + ToolInputJson: """{"path":"README.md"}"""); |
| 377 | + yield return new ProviderEvent("provider-event-loop-terminal", request.Id, "completed", DateTimeOffset.UtcNow, null, true, new UsageSnapshot(1, 2, 0, 3, null)); |
| 378 | + yield break; |
| 379 | + } |
| 380 | + |
319 | 381 | yield return new ProviderEvent("provider-event-1", request.Id, "delta", DateTimeOffset.UtcNow, "ok", false, null); |
320 | 382 | await Task.Yield(); |
321 | 383 | yield return new ProviderEvent("provider-event-2", request.Id, "completed", DateTimeOffset.UtcNow, null, true, new UsageSnapshot(1, 2, 0, 3, null)); |
|
0 commit comments