Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ Registration: `RuntimeServiceCollectionExtensions.AddSharpClawRuntime`.

**`DefaultTurnRunner`** is the **`ITurnRunner`** implementation used for prompt turns. It:

1. Calls **`IPromptContextAssembler.AssembleAsync`** to build **`PromptContext`** (prompt text, metadata such as resolved **`model`**).
2. Maps **`RunPromptRequest`** + session into **`AgentRunContext`** (session/turn ids, working directory, permission mode, output format, **`IToolExecutor`**, metadata).
1. Calls **`IPromptContextAssembler.AssembleAsync`** to build **`PromptContext`** (prompt text, metadata such as resolved **`model`**, and prior-turn conversation history assembled from persisted runtime events).
2. Maps **`RunPromptRequest`** + session into **`AgentRunContext`** (session/turn ids, working directory, permission mode, output format, **`IToolExecutor`**, metadata, and normalized caller interactivity).
3. Invokes **`PrimaryCodingAgent.RunAsync`**.

Before the agent runs, **`ConversationRuntime`** also layers in:
Expand All @@ -24,6 +24,17 @@ Before the agent runs, **`ConversationRuntime`** also layers in:

The agent stack is described in [agents.md](agents.md).

## Agent tool loop

The current runtime supports provider-driven tool calling through the normal agent path.

- **`AgentFrameworkBridge`** resolves the effective allowed-tool set from agent metadata.
- It advertises only that filtered tool set to the provider.
- **`ProviderBackedAgentKernel`** runs the provider loop, dispatches tool-use events through **`IToolExecutor`**, and feeds tool results back into the next provider iteration.
- Tool execution still goes through the normal permission engine, so allowlists, approval requirements, plugin trust, MCP trust, and primary-mode mutation restrictions all apply consistently.

This means the model-visible tool surface and the executor-visible tool surface are derived from the same resolved policy input.

## Lifecycle and state

- **`IRuntimeStateMachine`** (`DefaultRuntimeStateMachine`) transitions **`ConversationSession.State`**.
Expand All @@ -35,8 +46,15 @@ The agent stack is described in [agents.md](agents.md).

It also includes a compact diagnostics summary from **`IWorkspaceDiagnosticsService`**, which currently surfaces configured diagnostics sources and build-derived findings for .NET workspaces.

Prompt references are resolved before provider execution. Outside-workspace file references are evaluated through the permission engine, and the approval path now respects the normalized caller interactivity mode:

- interactive CLI and REPL callers can participate in approval prompts
- non-interactive surfaces such as ACP and the embedded HTTP server cannot

When the effective **`PrimaryMode`** is **`Spec`**, the assembler appends a structured output contract that requires the model to return machine-readable requirements, design, and task content.

Conversation history is rebuilt from persisted session events and truncated by token budget before being attached to the next provider request. Assistant history prefers the persisted final turn output and only falls back to streamed provider deltas when needed.

## Spec workflow

**`ISpecWorkflowService`** handles the post-processing path for **`spec`** mode:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,8 @@ internal async Task<ProviderInvocationResult> ExecuteAsync(
}

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

var summary = toolLoopExhausted
? $"Provider response from {resolvedProviderName}/{requestedModel} is incomplete because the tool-calling loop reached the maximum of {options.MaxToolIterations} iterations."
: $"Streamed provider response from {resolvedProviderName}/{requestedModel}.";

return new ProviderInvocationResult(
Output: output,
Usage: usage,
Summary: $"Streamed provider response from {resolvedProviderName}/{requestedModel}.",
Summary: summary,
ProviderRequest: lastProviderRequest,
ProviderEvents: allProviderEvents,
ToolResults: allToolResults.Count > 0 ? allToolResults : null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,12 @@ private static string GetUserConfigPath()
{
if (OperatingSystem.IsWindows())
{
var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var roaming = Environment.GetEnvironmentVariable("APPDATA");
if (string.IsNullOrWhiteSpace(roaming))
{
roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
}

return Path.Combine(roaming, "SharpClaw", "config.jsonc");
}

Expand Down
110 changes: 110 additions & 0 deletions src/SharpClaw.Code.Runtime/Context/ConversationHistoryCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System.Collections.Concurrent;
using System.Threading;
using SharpClaw.Code.Protocol.Models;

namespace SharpClaw.Code.Runtime.Context;

/// <summary>
/// Caches assembled conversation history per session so follow-up turns can reuse
/// in-process transcript state without rereading the full event log.
/// </summary>
internal static class ConversationHistoryCache
{
internal const int MaxHistoryTokenBudget = 100_000;
private const int MaxCacheEntries = 100;
private static readonly ConcurrentDictionary<string, CacheEntry> Cache = new(StringComparer.Ordinal);
private static long accessCounter;

public static bool TryGet(
string workspaceRoot,
string sessionId,
int completedTurnSequence,
out IReadOnlyList<ChatMessage> history)
{
var key = CreateKey(workspaceRoot, sessionId);
if (completedTurnSequence >= 0
&& Cache.TryGetValue(key, out var entry)
&& entry.CompletedTurnSequence == completedTurnSequence)
{
var touchedEntry = entry with { AccessOrder = NextAccessOrder() };
Cache.TryUpdate(key, touchedEntry, entry);
history = touchedEntry.History;
return true;
}

history = [];
return false;
}

public static void Store(
string workspaceRoot,
string sessionId,
int completedTurnSequence,
IReadOnlyList<ChatMessage> history)
{
var key = CreateKey(workspaceRoot, sessionId);
Cache[key] = new CacheEntry(completedTurnSequence, [.. history], NextAccessOrder());
EvictOverflow();
}

public static void StoreCompletedTurn(string workspaceRoot, string sessionId, ConversationTurn completedTurn)
{
ArgumentNullException.ThrowIfNull(completedTurn);
if (completedTurn.SequenceNumber <= 0 || string.IsNullOrWhiteSpace(completedTurn.Output))
{
return;
}

var previousSequence = completedTurn.SequenceNumber - 1;
IReadOnlyList<ChatMessage> priorHistory = [];
if (previousSequence > 0
&& !TryGet(workspaceRoot, sessionId, previousSequence, out priorHistory))
{
return;
}

var updatedHistory = priorHistory
.Concat(
[
CreateMessage("user", completedTurn.Input),
CreateMessage("assistant", completedTurn.Output),
])
.ToArray();

Store(
workspaceRoot,
sessionId,
completedTurn.SequenceNumber,
ContextWindowManager.Truncate(updatedHistory, MaxHistoryTokenBudget));
}

private static ChatMessage CreateMessage(string role, string text)
=> new(role, [new ContentBlock(ContentBlockKind.Text, text, null, null, null, null)]);

private static string CreateKey(string workspaceRoot, string sessionId)
=> $"{workspaceRoot}\u0000{sessionId}";

private static void EvictOverflow()
{
if (Cache.Count <= MaxCacheEntries)
{
return;
}

var overflowKeys = Cache
.OrderBy(static pair => pair.Value.AccessOrder)
.ThenBy(static pair => pair.Key, StringComparer.Ordinal)
.Take(Cache.Count - MaxCacheEntries)
.Select(static pair => pair.Key)
.ToArray();

foreach (var key in overflowKeys)
{
Cache.TryRemove(key, out _);
}
}

private static long NextAccessOrder() => Interlocked.Increment(ref accessCounter);

private sealed record CacheEntry(int CompletedTurnSequence, ChatMessage[] History, long AccessOrder);
}
23 changes: 13 additions & 10 deletions src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,19 +158,22 @@ public async Task<PromptExecutionContext> AssembleAsync(

sections.Add($"User request:\n{refResolution.ExpandedPrompt}");

// Assemble prior-turn conversation history for multi-turn context.
// NOTE: This reads the full event log per turn, which scales linearly with session length.
// For long-running sessions, consider persisting a compacted message history in session metadata
// to avoid re-reading and re-assembling the full log on every prompt.
const int MaxHistoryTokenBudget = 100_000;
// Prefer cached history for the previous turn when available; on a cache miss,
// fall back to reading the full event log and re-assembling the history for caching.
// The fallback path still scales linearly with session length for long-running sessions.
IReadOnlyList<ChatMessage> conversationHistory = [];
if (turn.SequenceNumber > 1)
{
var sessionEvents = await eventStore
.ReadAllAsync(workspaceRoot, session.Id, cancellationToken)
.ConfigureAwait(false);
var rawHistory = ConversationHistoryAssembler.Assemble(sessionEvents);
conversationHistory = ContextWindowManager.Truncate(rawHistory, MaxHistoryTokenBudget);
var targetSequence = turn.SequenceNumber - 1;
if (!ConversationHistoryCache.TryGet(workspaceRoot, session.Id, targetSequence, out conversationHistory))
{
var sessionEvents = await eventStore
.ReadAllAsync(workspaceRoot, session.Id, cancellationToken)
.ConfigureAwait(false);
var rawHistory = ConversationHistoryAssembler.Assemble(sessionEvents);
conversationHistory = ContextWindowManager.Truncate(rawHistory, ConversationHistoryCache.MaxHistoryTokenBudget);
ConversationHistoryCache.Store(workspaceRoot, session.Id, targetSequence, conversationHistory);
}
}

return new PromptExecutionContext(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public async Task<WorkspaceDiagnosticsSnapshot> BuildSnapshotAsync(string worksp

var snapshot = new WorkspaceDiagnosticsSnapshot(workspaceRoot, systemClock.UtcNow, configuredServers, diagnostics);
Cache[workspaceRoot] = snapshot;
EvictStaleCacheEntries();
EvictCacheEntries();
return snapshot;
}

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

private void EvictStaleCacheEntries()
private void EvictCacheEntries()
{
if (Cache.Count <= MaxCacheEntries)
{
return;
}

var now = systemClock.UtcNow;
foreach (var key in Cache.Keys)
{
Expand All @@ -113,6 +108,23 @@ private void EvictStaleCacheEntries()
Cache.TryRemove(key, out _);
}
}

if (Cache.Count <= MaxCacheEntries)
{
return;
}

var overflowKeys = Cache
.OrderBy(static pair => pair.Value.GeneratedAtUtc)
.ThenBy(static pair => pair.Key, StringComparer.Ordinal)
.Take(Cache.Count - MaxCacheEntries)
.Select(static pair => pair.Key)
.ToArray();

foreach (var key in overflowKeys)
{
Cache.TryRemove(key, out _);
}
}

[GeneratedRegex(@"^(?<path>.*?)(?:\((?<line>\d+),(?<column>\d+)\))?:\s*(?<severity>error|warning)\s*(?<code>[A-Z]{2,}\d+)?\s*:?\s*(?<message>.+)$", RegexOptions.Multiline | RegexOptions.IgnoreCase)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using SharpClaw.Code.Runtime.CustomCommands;
using SharpClaw.Code.Runtime.Diagnostics;
using SharpClaw.Code.Runtime.Export;
using SharpClaw.Code.Runtime.Context;
using SharpClaw.Code.Runtime.Lifecycle;
using SharpClaw.Code.Runtime.Mutations;
using SharpClaw.Code.Runtime.Turns;
Expand Down Expand Up @@ -269,6 +270,7 @@ await AppendEventAsync(
CompletedAtUtc = completedAtUtc,
Usage = turnRunResult.Usage,
};
ConversationHistoryCache.StoreCompletedTurn(workspacePath, session.Id, completedTurn);

await AppendRuntimeEventsAsync(
workspacePath,
Expand Down
21 changes: 15 additions & 6 deletions src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Extensions.Logging;
using SharpClaw.Code.Protocol.Commands;
using SharpClaw.Code.Protocol.Enums;
Expand Down Expand Up @@ -258,11 +259,7 @@ private static async Task<int> WriteCommandResultAsync(HttpListenerResponse resp
}
}

private static readonly JsonSerializerOptions ServerJsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private static readonly JsonSerializerOptions ServerJsonOptions = CreateServerJsonOptions();

private static async Task WriteJsonAsync(HttpListenerResponse response, int statusCode, object payload, CancellationToken cancellationToken)
{
Expand All @@ -283,10 +280,22 @@ private Task DispatchServerHookAsync(string workspaceRoot, HttpListenerRequest r
path,
statusCode,
succeeded,
DateTimeOffset.UtcNow));
DateTimeOffset.UtcNow),
ServerJsonOptions);
return hookDispatcher.DispatchAsync(workspaceRoot, HookTriggerKind.ServerRequestCompleted, payload, CancellationToken.None);
}

private static JsonSerializerOptions CreateServerJsonOptions()
{
var options = new JsonSerializerOptions(ProtocolJsonContext.Default.Options)
{
TypeInfoResolver = JsonTypeInfoResolver.Combine(
ProtocolJsonContext.Default,
new DefaultJsonTypeInfoResolver()),
};
return options;
}

private sealed record ServerCommandEnvelope(
bool Succeeded,
int ExitCode,
Expand Down
Loading
Loading