Skip to content

Commit a7257a3

Browse files
committed
fix: address runtime review follow-ups
1 parent 0ef2eb1 commit a7257a3

11 files changed

Lines changed: 472 additions & 27 deletions

File tree

src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,8 @@ internal async Task<ProviderInvocationResult> ExecuteAsync(
254254
}
255255

256256
// Detect if the loop was exhausted (provider kept requesting tools every iteration).
257-
if (iteration >= options.MaxToolIterations)
257+
var toolLoopExhausted = iteration >= options.MaxToolIterations;
258+
if (toolLoopExhausted)
258259
{
259260
logger.LogWarning(
260261
"Tool-calling loop reached maximum iterations ({MaxIterations}) for session {SessionId}; output may be incomplete.",
@@ -280,10 +281,14 @@ internal async Task<ProviderInvocationResult> ExecuteAsync(
280281
TotalTokens: request.Context.Prompt.Length + output.Length,
281282
EstimatedCostUsd: null);
282283

284+
var summary = toolLoopExhausted
285+
? $"Provider response from {resolvedProviderName}/{requestedModel} is incomplete because the tool-calling loop reached the maximum of {options.MaxToolIterations} iterations."
286+
: $"Streamed provider response from {resolvedProviderName}/{requestedModel}.";
287+
283288
return new ProviderInvocationResult(
284289
Output: output,
285290
Usage: usage,
286-
Summary: $"Streamed provider response from {resolvedProviderName}/{requestedModel}.",
291+
Summary: summary,
287292
ProviderRequest: lastProviderRequest,
288293
ProviderEvents: allProviderEvents,
289294
ToolResults: allToolResults.Count > 0 ? allToolResults : null,
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using System.Collections.Concurrent;
2+
using SharpClaw.Code.Protocol.Models;
3+
4+
namespace SharpClaw.Code.Runtime.Context;
5+
6+
/// <summary>
7+
/// Caches assembled conversation history per session so follow-up turns can reuse
8+
/// in-process transcript state without rereading the full event log.
9+
/// </summary>
10+
internal static class ConversationHistoryCache
11+
{
12+
internal const int MaxHistoryTokenBudget = 100_000;
13+
private const int MaxCacheEntries = 100;
14+
private static readonly ConcurrentDictionary<string, CacheEntry> Cache = new(StringComparer.Ordinal);
15+
16+
public static bool TryGet(
17+
string workspaceRoot,
18+
string sessionId,
19+
int completedTurnSequence,
20+
out IReadOnlyList<ChatMessage> history)
21+
{
22+
if (completedTurnSequence >= 0
23+
&& Cache.TryGetValue(CreateKey(workspaceRoot, sessionId), out var entry)
24+
&& entry.CompletedTurnSequence == completedTurnSequence)
25+
{
26+
history = entry.History;
27+
return true;
28+
}
29+
30+
history = [];
31+
return false;
32+
}
33+
34+
public static void Store(
35+
string workspaceRoot,
36+
string sessionId,
37+
int completedTurnSequence,
38+
IReadOnlyList<ChatMessage> history)
39+
{
40+
Cache[CreateKey(workspaceRoot, sessionId)] = new CacheEntry(completedTurnSequence, [.. history]);
41+
EvictOverflow();
42+
}
43+
44+
public static void StoreCompletedTurn(string workspaceRoot, string sessionId, ConversationTurn completedTurn)
45+
{
46+
ArgumentNullException.ThrowIfNull(completedTurn);
47+
if (completedTurn.SequenceNumber <= 0 || string.IsNullOrWhiteSpace(completedTurn.Output))
48+
{
49+
return;
50+
}
51+
52+
var previousSequence = completedTurn.SequenceNumber - 1;
53+
IReadOnlyList<ChatMessage> priorHistory = [];
54+
if (previousSequence > 0
55+
&& !TryGet(workspaceRoot, sessionId, previousSequence, out priorHistory))
56+
{
57+
return;
58+
}
59+
60+
var updatedHistory = priorHistory
61+
.Concat(
62+
[
63+
CreateMessage("user", completedTurn.Input),
64+
CreateMessage("assistant", completedTurn.Output),
65+
])
66+
.ToArray();
67+
68+
Store(
69+
workspaceRoot,
70+
sessionId,
71+
completedTurn.SequenceNumber,
72+
ContextWindowManager.Truncate(updatedHistory, MaxHistoryTokenBudget));
73+
}
74+
75+
private static ChatMessage CreateMessage(string role, string text)
76+
=> new(role, [new ContentBlock(ContentBlockKind.Text, text, null, null, null, null)]);
77+
78+
private static string CreateKey(string workspaceRoot, string sessionId)
79+
=> $"{workspaceRoot}\u0000{sessionId}";
80+
81+
private static void EvictOverflow()
82+
{
83+
if (Cache.Count <= MaxCacheEntries)
84+
{
85+
return;
86+
}
87+
88+
var overflowKeys = Cache.Keys
89+
.OrderBy(static key => key, StringComparer.Ordinal)
90+
.Take(Cache.Count - MaxCacheEntries)
91+
.ToArray();
92+
93+
foreach (var key in overflowKeys)
94+
{
95+
Cache.TryRemove(key, out _);
96+
}
97+
}
98+
99+
private sealed record CacheEntry(int CompletedTurnSequence, ChatMessage[] History);
100+
}

src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,15 +162,19 @@ public async Task<PromptExecutionContext> AssembleAsync(
162162
// NOTE: This reads the full event log per turn, which scales linearly with session length.
163163
// For long-running sessions, consider persisting a compacted message history in session metadata
164164
// to avoid re-reading and re-assembling the full log on every prompt.
165-
const int MaxHistoryTokenBudget = 100_000;
166165
IReadOnlyList<ChatMessage> conversationHistory = [];
167166
if (turn.SequenceNumber > 1)
168167
{
169-
var sessionEvents = await eventStore
170-
.ReadAllAsync(workspaceRoot, session.Id, cancellationToken)
171-
.ConfigureAwait(false);
172-
var rawHistory = ConversationHistoryAssembler.Assemble(sessionEvents);
173-
conversationHistory = ContextWindowManager.Truncate(rawHistory, MaxHistoryTokenBudget);
168+
var targetSequence = turn.SequenceNumber - 1;
169+
if (!ConversationHistoryCache.TryGet(workspaceRoot, session.Id, targetSequence, out conversationHistory))
170+
{
171+
var sessionEvents = await eventStore
172+
.ReadAllAsync(workspaceRoot, session.Id, cancellationToken)
173+
.ConfigureAwait(false);
174+
var rawHistory = ConversationHistoryAssembler.Assemble(sessionEvents);
175+
conversationHistory = ContextWindowManager.Truncate(rawHistory, ConversationHistoryCache.MaxHistoryTokenBudget);
176+
ConversationHistoryCache.Store(workspaceRoot, session.Id, targetSequence, conversationHistory);
177+
}
174178
}
175179

176180
return new PromptExecutionContext(

src/SharpClaw.Code.Runtime/Diagnostics/WorkspaceDiagnosticsService.cs

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public async Task<WorkspaceDiagnosticsSnapshot> BuildSnapshotAsync(string worksp
5959

6060
var snapshot = new WorkspaceDiagnosticsSnapshot(workspaceRoot, systemClock.UtcNow, configuredServers, diagnostics);
6161
Cache[workspaceRoot] = snapshot;
62-
EvictStaleCacheEntries();
62+
EvictCacheEntries();
6363
return snapshot;
6464
}
6565

@@ -98,13 +98,8 @@ private static IEnumerable<WorkspaceDiagnosticItem> ParseDotnetDiagnostics(strin
9898
private static string? NullIfEmpty(string value)
9999
=> string.IsNullOrWhiteSpace(value) ? null : value;
100100

101-
private void EvictStaleCacheEntries()
101+
private void EvictCacheEntries()
102102
{
103-
if (Cache.Count <= MaxCacheEntries)
104-
{
105-
return;
106-
}
107-
108103
var now = systemClock.UtcNow;
109104
foreach (var key in Cache.Keys)
110105
{
@@ -113,6 +108,23 @@ private void EvictStaleCacheEntries()
113108
Cache.TryRemove(key, out _);
114109
}
115110
}
111+
112+
if (Cache.Count <= MaxCacheEntries)
113+
{
114+
return;
115+
}
116+
117+
var overflowKeys = Cache
118+
.OrderBy(static pair => pair.Value.GeneratedAtUtc)
119+
.ThenBy(static pair => pair.Key, StringComparer.Ordinal)
120+
.Take(Cache.Count - MaxCacheEntries)
121+
.Select(static pair => pair.Key)
122+
.ToArray();
123+
124+
foreach (var key in overflowKeys)
125+
{
126+
Cache.TryRemove(key, out _);
127+
}
116128
}
117129

118130
[GeneratedRegex(@"^(?<path>.*?)(?:\((?<line>\d+),(?<column>\d+)\))?:\s*(?<severity>error|warning)\s*(?<code>[A-Z]{2,}\d+)?\s*:?\s*(?<message>.+)$", RegexOptions.Multiline | RegexOptions.IgnoreCase)]

src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using SharpClaw.Code.Runtime.CustomCommands;
1515
using SharpClaw.Code.Runtime.Diagnostics;
1616
using SharpClaw.Code.Runtime.Export;
17+
using SharpClaw.Code.Runtime.Context;
1718
using SharpClaw.Code.Runtime.Lifecycle;
1819
using SharpClaw.Code.Runtime.Mutations;
1920
using SharpClaw.Code.Runtime.Turns;
@@ -269,6 +270,7 @@ await AppendEventAsync(
269270
CompletedAtUtc = completedAtUtc,
270271
Usage = turnRunResult.Usage,
271272
};
273+
ConversationHistoryCache.StoreCompletedTurn(workspacePath, session.Id, completedTurn);
272274

273275
await AppendRuntimeEventsAsync(
274276
workspacePath,

src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Net;
22
using System.Text;
33
using System.Text.Json;
4+
using System.Text.Json.Serialization.Metadata;
45
using Microsoft.Extensions.Logging;
56
using SharpClaw.Code.Protocol.Commands;
67
using SharpClaw.Code.Protocol.Enums;
@@ -258,11 +259,7 @@ private static async Task<int> WriteCommandResultAsync(HttpListenerResponse resp
258259
}
259260
}
260261

261-
private static readonly JsonSerializerOptions ServerJsonOptions = new(JsonSerializerDefaults.Web)
262-
{
263-
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
264-
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
265-
};
262+
private static readonly JsonSerializerOptions ServerJsonOptions = CreateServerJsonOptions();
266263

267264
private static async Task WriteJsonAsync(HttpListenerResponse response, int statusCode, object payload, CancellationToken cancellationToken)
268265
{
@@ -283,10 +280,22 @@ private Task DispatchServerHookAsync(string workspaceRoot, HttpListenerRequest r
283280
path,
284281
statusCode,
285282
succeeded,
286-
DateTimeOffset.UtcNow));
283+
DateTimeOffset.UtcNow),
284+
ServerJsonOptions);
287285
return hookDispatcher.DispatchAsync(workspaceRoot, HookTriggerKind.ServerRequestCompleted, payload, CancellationToken.None);
288286
}
289287

288+
private static JsonSerializerOptions CreateServerJsonOptions()
289+
{
290+
var options = new JsonSerializerOptions(ProtocolJsonContext.Default.Options)
291+
{
292+
TypeInfoResolver = JsonTypeInfoResolver.Combine(
293+
ProtocolJsonContext.Default,
294+
new DefaultJsonTypeInfoResolver()),
295+
};
296+
return options;
297+
}
298+
290299
private sealed record ServerCommandEnvelope(
291300
bool Succeeded,
292301
int ExitCode,

tests/SharpClaw.Code.IntegrationTests/Runtime/AgentToolPolicyIntegrationTests.cs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Runtime.CompilerServices;
22
using FluentAssertions;
33
using Microsoft.Extensions.DependencyInjection;
4+
using SharpClaw.Code.Agents.Configuration;
45
using SharpClaw.Code.Infrastructure.Abstractions;
56
using SharpClaw.Code.Infrastructure.Models;
67
using SharpClaw.Code.Permissions.Abstractions;
@@ -9,6 +10,7 @@
910
using SharpClaw.Code.Plugins.Models;
1011
using SharpClaw.Code.Protocol.Commands;
1112
using SharpClaw.Code.Protocol.Enums;
13+
using SharpClaw.Code.Protocol.Events;
1214
using SharpClaw.Code.Protocol.Models;
1315
using SharpClaw.Code.Providers.Abstractions;
1416
using SharpClaw.Code.Providers.Models;
@@ -200,14 +202,55 @@ public async Task RunPrompt_should_deny_provider_requested_tool_that_is_not_in_a
200202
shellExecutor.CallCount.Should().Be(0);
201203
}
202204

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+
203240
private static ServiceProvider CreateServiceProvider(
204241
CapturingToolPolicyProvider provider,
205242
RecordingApprovalService? approvalService = null,
206243
RecordingShellExecutor? shellExecutor = null,
207-
IPluginManager? pluginManager = null)
244+
IPluginManager? pluginManager = null,
245+
Action<AgentLoopOptions>? configureLoop = null)
208246
{
209247
var services = new ServiceCollection();
210248
services.AddSharpClawRuntime();
249+
if (configureLoop is not null)
250+
{
251+
services.Configure(configureLoop);
252+
}
253+
211254
services.AddSingleton<IProviderRequestPreflight, PassthroughPreflight>();
212255
services.AddSingleton<IAuthFlowService, AlwaysAuthenticatedAuthFlowService>();
213256
services.AddSingleton<IModelProviderResolver>(_ => new StaticModelProviderResolver(provider));
@@ -244,6 +287,7 @@ private static class ToolPolicyScenario
244287
{
245288
public const string CaptureOnly = "capture-only";
246289
public const string ToolRoundTrip = "tool-round-trip";
290+
public const string ExhaustToolLoop = "exhaust-tool-loop";
247291
}
248292

249293
private sealed class PassthroughPreflight : IProviderRequestPreflight
@@ -316,6 +360,24 @@ private static async IAsyncEnumerable<ProviderEvent> StreamEventsAsync(
316360
yield break;
317361
}
318362

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+
319381
yield return new ProviderEvent("provider-event-1", request.Id, "delta", DateTimeOffset.UtcNow, "ok", false, null);
320382
await Task.Yield();
321383
yield return new ProviderEvent("provider-event-2", request.Id, "completed", DateTimeOffset.UtcNow, null, true, new UsageSnapshot(1, 2, 0, 3, null));

0 commit comments

Comments
 (0)