diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..7ca383a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,35 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ build-and-test:
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+ - name: Restore
+ run: dotnet restore SharpClawCode.sln
+ - name: Build
+ run: dotnet build SharpClawCode.sln --no-restore --configuration Release
+ - name: Test
+ run: dotnet test SharpClawCode.sln --no-build --configuration Release --collect:"XPlat Code Coverage" --results-directory ./coverage
+ - name: Upload coverage
+ if: matrix.os == 'ubuntu-latest'
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-report
+ path: ./coverage/**/coverage.cobertura.xml
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..d83e7f3
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,29 @@
+name: Release
+
+on:
+ push:
+ tags: ['v*']
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+ - name: Restore
+ run: dotnet restore SharpClawCode.sln
+ - name: Build
+ run: dotnet build SharpClawCode.sln --no-restore --configuration Release
+ - name: Test
+ run: dotnet test SharpClawCode.sln --no-build --configuration Release
+ - name: Pack
+ run: dotnet pack SharpClawCode.sln --no-build --configuration Release --output ./nupkg
+ - name: Push to NuGet
+ run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
diff --git a/Directory.Build.props b/Directory.Build.props
index 8883f05..34f6fc1 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -10,5 +10,12 @@
true
false
1591;$(NoWarn)
+ clawdotnet
+ clawdotnet
+ MIT
+ https://github.com/clawdotnet/SharpClawCode
+ https://github.com/clawdotnet/SharpClawCode
+ git
+ Copyright (c) 2025 clawdotnet
diff --git a/docs/agent-framework-integration.md b/docs/agent-framework-integration.md
new file mode 100644
index 0000000..4492b3d
--- /dev/null
+++ b/docs/agent-framework-integration.md
@@ -0,0 +1,474 @@
+# Microsoft Agent Framework Integration
+
+SharpClaw Code is built **on top of** the [Microsoft Agent Framework](https://github.com/microsoft/agents) (`Microsoft.Agents.AI` NuGet package). This guide explains how SharpClaw leverages the framework and what production capabilities SharpClaw adds.
+
+## Overview
+
+**Microsoft Agent Framework** provides:
+- Abstract agent interfaces (`AIAgent`, `AgentSession`, `AgentResponse`)
+- Session lifecycle management
+- Chat message and tool-calling abstractions
+- A foundation for building multi-turn agent systems
+
+**SharpClaw Code** complements the framework by adding:
+- Production-grade agent orchestration for coding tasks
+- Provider abstraction layer with auth preflight and streaming adapters
+- Permission-aware tool execution with approval gates
+- Durable session snapshots and NDJSON event logs
+- MCP (Model Context Protocol) server supervision
+- Plugin system with trust levels and out-of-process execution
+- Structured telemetry with ring buffer and usage tracking
+- REPL and CLI with spec mode
+
+## Quick comparison
+
+| Layer | Agent Framework Provides | SharpClaw Adds |
+|-------|--------------------------|---|
+| **Agent abstractions** | `AIAgent`, `AgentSession`, `AgentResponse` | Coding-agent orchestration, turns, context assembly |
+| **Provider integration** | Multi-provider interfaces | Resilience, auth preflight, streaming adapters, tool-use extraction |
+| **Tool execution** | — | Permission-aware tools, approval gates, workspace boundaries |
+| **Sessions** | In-memory | Durable snapshots, NDJSON event logs, checkpoints, undo/redo |
+| **MCP support** | — | Server registration, supervision, health checks |
+| **Plugins** | — | Manifest discovery, trust levels, out-of-process execution |
+| **Telemetry** | Standard logging | Structured events, ring buffer, usage tracking |
+| **CLI & REPL** | — | REPL, slash commands, JSON output, spec mode |
+
+## Architecture
+
+The integration is layered. Each layer builds on the one below:
+
+```
+Microsoft Agent Framework (AIAgent, AgentSession, AgentResponse)
+ ↓
+SharpClawFrameworkAgent (implements AIAgent)
+ ↓
+AgentFrameworkBridge (orchestration layer)
+ ↓
+ProviderBackedAgentKernel (provider + tool-calling loop)
+ ↓
+IModelProvider (Anthropic, OpenAI-compatible)
+```
+
+### Layer 1: SharpClawFrameworkAgent
+
+**File:** `src/SharpClaw.Code.Agents/Internal/SharpClawFrameworkAgent.cs`
+
+A concrete implementation of `AIAgent` that adapts SharpClaw's agent model to the framework:
+
+```csharp
+internal sealed class SharpClawFrameworkAgent(
+ string agentId,
+ string name,
+ string description,
+ Func, AgentSession, AgentRunOptions, CancellationToken, Task> runAsync)
+ : AIAgent
+```
+
+Responsibilities:
+- Provides framework-required properties (`Id`, `Name`, `Description`)
+- Creates and deserializes `AgentSession` instances (backed by `SharpClawAgentSession`)
+- Delegates core execution to a caller-provided delegate
+- Implements streaming semantics by converting `AgentResponse` to `AgentResponseUpdate` sequences
+
+The session state is serialized/deserialized via `StateBag`, allowing framework-level session persistence.
+
+### Layer 2: AgentFrameworkBridge
+
+**File:** `src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs`
+
+The orchestration layer that:
+
+1. **Translates context:** Converts `AgentFrameworkRequest` (SharpClaw's agent input model) into:
+ - Tool registry entries → `ProviderToolDefinition` list
+ - `ToolExecutionContext` (permissions, workspace bounds, mutation recorder)
+ - Framework session and run options
+
+2. **Instantiates the framework agent:** Creates a `SharpClawFrameworkAgent` with a delegate that calls `ProviderBackedAgentKernel`
+
+3. **Orchestrates execution:**
+ ```csharp
+ var frameworkAgent = new SharpClawFrameworkAgent(
+ request.AgentId,
+ request.Name,
+ request.Description,
+ async (messages, session, runOptions, ct) =>
+ {
+ providerResult = await providerBackedAgentKernel.ExecuteAsync(
+ request,
+ toolExecutionContext,
+ providerTools,
+ ct).ConfigureAwait(false);
+ return new AgentResponse(new ChatMessage(ChatRole.Assistant, providerResult.Output));
+ });
+
+ response = await frameworkAgent.RunAsync(request.Context.Prompt, session, cancellationToken: cancellationToken);
+ ```
+
+4. **Returns an `AgentRunResult`** with:
+ - Output text
+ - Token usage metrics
+ - Provider request/response details
+ - Tool results and runtime events
+ - `AgentSpawnedEvent` and `AgentCompletedEvent` for session telemetry
+
+### Layer 3: ProviderBackedAgentKernel
+
+**File:** `src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs`
+
+The core execution engine for streaming provider responses and driving the tool-calling loop.
+
+Key responsibilities:
+
+1. **Auth preflight:** Checks `IAuthFlowService` to verify the provider is authenticated before making calls
+2. **Provider resolution:** Uses `IModelProviderResolver` to get the configured `IModelProvider`
+3. **Message assembly:** Builds the conversation thread from:
+ - System prompt
+ - Prior turn history (multi-turn context)
+ - Current user prompt
+4. **Tool-calling loop:**
+ - Calls `provider.StartStreamAsync()` to get a streaming provider event sequence
+ - Extracts `ProviderEvent` items (text chunks, tool-use invocations, usage stats)
+ - On tool-use events, constructs `ContentBlock` entries with tool name, ID, and input JSON
+ - Dispatches each tool via `ToolCallDispatcher` (which runs through the permission engine)
+ - Feeds tool results back to the provider in the next iteration
+ - Repeats until max iterations or no tool calls remain
+
+5. **Error handling:**
+ - Missing provider → `ProviderExecutionException` with `ProviderFailureKind.MissingProvider`
+ - Auth check failure → `ProviderFailureKind.AuthenticationUnavailable`
+ - Stream error → `ProviderFailureKind.StreamFailed`
+ - Placeholder response when stream is empty
+
+**Loop Configuration:** Controlled by `AgentLoopOptions`:
+- `MaxToolIterations` — maximum rounds (default ~10)
+- `MaxTokensPerRequest` — per-iteration token budget
+
+### Layer 4: IModelProvider
+
+**File:** `src/SharpClaw.Code.Providers/Abstractions/IModelProvider.cs`
+
+SharpClaw's abstraction over model providers:
+
+```csharp
+public interface IModelProvider
+{
+ string ProviderName { get; }
+ Task GetAuthStatusAsync(CancellationToken cancellationToken);
+ Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken);
+}
+```
+
+**Registered implementations:**
+- `AnthropicProvider` — Anthropic Claude models via HTTP
+- `OpenAiCompatibleProvider` — OpenAI-compatible endpoints (LM Studio, Ollama, etc.)
+
+Both stream `ProviderEvent` sequences containing:
+- Text chunks
+- Tool-use invocations (`ToolUseId`, `ToolName`, `ToolInputJson`)
+- Terminal usage metrics
+
+## Integration entry points
+
+### 1. Extending SharpClaw Agent Types
+
+SharpClaw provides a base class for custom agents:
+
+**File:** `src/SharpClaw.Code.Agents/Agents/SharpClawAgentBase.cs`
+
+```csharp
+public abstract class SharpClawAgentBase(IAgentFrameworkBridge agentFrameworkBridge) : ISharpClawAgent
+{
+ public abstract string AgentId { get; }
+ public abstract string AgentKind { get; }
+ protected abstract string Name { get; }
+ protected abstract string Description { get; }
+ protected abstract string Instructions { get; }
+
+ public virtual Task RunAsync(AgentRunContext context, CancellationToken cancellationToken)
+ => agentFrameworkBridge.RunAsync(
+ new AgentFrameworkRequest(
+ AgentId,
+ AgentKind,
+ Name,
+ Description,
+ Instructions,
+ context),
+ cancellationToken);
+}
+```
+
+**To add a custom agent:**
+
+1. Inherit from `SharpClawAgentBase`
+2. Provide concrete implementations of `AgentId`, `AgentKind`, `Name`, `Description`, `Instructions`
+3. Optionally override `RunAsync` to customize behavior before/after framework execution
+4. Register in DI:
+ ```csharp
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+ ```
+
+**Example:** `PrimaryCodingAgent` (default agent for prompts):
+```csharp
+public sealed class PrimaryCodingAgent(IAgentFrameworkBridge agentFrameworkBridge)
+ : SharpClawAgentBase(agentFrameworkBridge)
+{
+ public override string AgentId => "primary-coding-agent";
+ public override string AgentKind => "primaryCoding";
+ protected override string Name => "Primary Coding Agent";
+ protected override string Description => "Handles the default coding workflow for prompt execution.";
+ protected override string Instructions => "You are SharpClaw Code's primary coding agent. ...";
+}
+```
+
+### 2. Adding a Custom Model Provider
+
+Implement `IModelProvider` to integrate a new model source:
+
+```csharp
+public sealed class YourModelProvider : IModelProvider
+{
+ public string ProviderName => "your-provider";
+
+ public async Task GetAuthStatusAsync(CancellationToken cancellationToken)
+ {
+ // Check if credentials are available (API key, token, etc.)
+ return new AuthStatus(IsAuthenticated: _hasCredentials);
+ }
+
+ public async Task StartStreamAsync(
+ ProviderRequest request,
+ CancellationToken cancellationToken)
+ {
+ // Stream model responses as ProviderEvent sequences
+ return new ProviderStreamHandle(
+ Events: StreamEventsAsync(request, cancellationToken));
+ }
+
+ private async IAsyncEnumerable StreamEventsAsync(
+ ProviderRequest request,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ // Emit text chunks as ProviderEvent with IsTerminal=false
+ // Emit tool-use events with ToolUseId, ToolName, ToolInputJson
+ // Emit usage metrics in the final ProviderEvent with IsTerminal=true
+ }
+}
+```
+
+**Registration:**
+
+```csharp
+public static void AddYourProvider(this IServiceCollection services)
+{
+ services.AddSingleton();
+ // Configure options if needed
+ services.Configure(configuration.GetSection("Your:Provider"));
+}
+```
+
+**Provider catalog:** Update `ProviderCatalogOptions` to register aliases:
+```json
+{
+ "SharpClaw:Providers:Catalog": {
+ "DefaultProvider": "your-provider",
+ "ModelAliases": {
+ "default": "your-provider/latest-model"
+ }
+ }
+}
+```
+
+### 3. Adding Custom Tools
+
+Custom tools integrate via the registry and are automatically available to agents:
+
+**File:** `src/SharpClaw.Code.Tools/Abstractions/ISharpClawTool.cs`
+
+```csharp
+public interface ISharpClawTool
+{
+ string Name { get; }
+ string Description { get; }
+ string InputSchemaJson { get; }
+ Task ExecuteAsync(string input, ToolExecutionContext context, CancellationToken cancellationToken);
+}
+```
+
+**Registration:** Tools register via `IToolRegistry`:
+
+```csharp
+public sealed class YourCustomTool : ISharpClawTool
+{
+ public string Name => "your-tool";
+ public string Description => "Does something useful";
+ public string InputSchemaJson => /* JSON Schema */;
+
+ public async Task ExecuteAsync(
+ string input,
+ ToolExecutionContext context,
+ CancellationToken cancellationToken)
+ {
+ // Perform work respecting context boundaries
+ // context.WorkspaceRoot, context.PermissionMode, context.SessionId, etc.
+ }
+}
+```
+
+**DI Registration:**
+
+```csharp
+services.AddSingleton();
+```
+
+**Tool calling:** The agent kernel automatically:
+1. Includes tool schemas in the initial provider request
+2. Extracts tool-use events from the provider stream
+3. Dispatches via `ToolCallDispatcher` (which consults the permission engine)
+4. Collects results and feeds them back to the provider for continued reasoning
+
+See [tools.md](tools.md) for full details on tool execution, permissions, and plugin integration.
+
+## Tool-calling flow within the framework
+
+The `ProviderBackedAgentKernel` drives a multi-iteration loop that respects the framework's abstractions:
+
+1. **Iteration N:** Call `provider.StartStreamAsync()` with conversation history + tool schemas
+2. **Stream processing:** Collect text and tool-use events
+3. **Build assistant message:** Add text block and tool-use content blocks to conversation
+4. **Tool dispatch:** Call `ToolCallDispatcher` for each tool-use event
+5. **Build user message:** Add tool result content blocks
+6. **Continue:** Append both messages and loop back to step 1
+7. **Exit:** When iteration returns no tool-use events, break and return accumulated text
+
+This pattern keeps the framework session state synchronized with the multi-turn conversation and tool results.
+
+## Configuration and instantiation
+
+### Runtime integration
+
+The `ConversationRuntime` owns agent execution via the `DefaultTurnRunner`:
+
+```
+Prompt input
+ ↓
+ConversationRuntime.RunPromptAsync
+ ↓
+DefaultTurnRunner.RunAsync (assembles context)
+ ↓
+PrimaryCodingAgent.RunAsync (framework bridge)
+ ↓
+AgentFrameworkBridge.RunAsync
+ ↓
+ProviderBackedAgentKernel.ExecuteAsync (tool-calling loop)
+ ↓
+IModelProvider.StartStreamAsync (streaming)
+```
+
+### Service registration
+
+The agents module registers via `AgentsServiceCollectionExtensions`:
+
+```csharp
+public static IServiceCollection AddSharpClawAgents(
+ this IServiceCollection services,
+ IConfiguration configuration)
+{
+ // Register bridge, kernel, concrete agents, etc.
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ // ... other agents
+
+ services.Configure(configuration.GetSection("SharpClaw:AgentLoop"));
+ return services;
+}
+```
+
+The CLI host calls:
+```csharp
+services.AddSharpClawRuntime(configuration); // includes agents
+```
+
+## Key architectural decisions
+
+### Why a bridge?
+
+The `AgentFrameworkBridge` isolates **SharpClaw's agent orchestration** (context assembly, tool dispatch, permission checks) from **Microsoft Agent Framework's abstractions** (session, message, response). This allows:
+
+- **Version independence:** Framework updates don't force refactoring across SharpClaw
+- **Testing:** Bridge can be tested with mock providers and kernels
+- **Clarity:** Clear contract between layers; framework details are hidden from callers
+
+### Why ProviderBackedAgentKernel?
+
+Separates **provider streaming** and **tool-calling logic** from **framework integration**:
+
+- **Streaming:** Handles partial chunks, tool-use extraction, usage metrics
+- **Tool calling:** Drives the multi-iteration loop, permission checks, result collection
+- **Auth checks:** Runs preflight before expensive provider calls
+- **Error handling:** Classifies failures and maps to `ProviderFailureKind`
+
+This kernel can be tested independently or used in non-framework contexts (e.g., batch processing).
+
+### Why IModelProvider over framework providers?
+
+SharpClaw's `IModelProvider` is:
+
+- **Simpler:** One async method to stream events
+- **Resilient:** Built-in auth preflight and preflight normalization
+- **Pluggable:** Easy to add new endpoints (Anthropic, OpenAI-compatible, custom)
+- **Streaming-first:** Designed for partial updates and tool-calling loops
+
+The framework provides `IChatCompletionService` and `IEmbeddingService` abstractions; SharpClaw adds `IModelProvider` for agent-specific streaming requirements.
+
+## Testing
+
+### Unit testing the bridge
+
+Test with a mock provider:
+
+```csharp
+var mockProvider = new MockModelProvider();
+services.AddSingleton(mockProvider);
+
+var bridge = new AgentFrameworkBridge(/* deps */);
+var result = await bridge.RunAsync(request, cancellationToken);
+
+Assert.NotNull(result.Output);
+Assert.Equal(request.AgentId, result.AgentId);
+```
+
+### Integration testing
+
+Use the `SharpClaw.Code.MockProvider` test fixture:
+
+```csharp
+var host = TestHostBuilder.BuildWithMockProvider();
+var runtime = host.Services.GetRequiredService();
+
+var result = await runtime.RunPromptAsync(
+ sessionId: "test-session",
+ prompt: "What is 2 + 2?",
+ cancellationToken);
+
+Assert.Contains("4", result.Output);
+```
+
+See [testing.md](testing.md) for full test patterns.
+
+## Further reading
+
+- [Architecture](architecture.md) — Solution structure and overall data flow
+- [Providers](providers.md) — Provider interface, registration, and catalog
+- [Tools](tools.md) — Tool registry, execution, permissions, and plugins
+- [Sessions](sessions.md) — Session snapshots, event logs, and checkpoints
+- [MCP](mcp.md) — Model Context Protocol server registration and supervision
+- [Testing](testing.md) — Test patterns and fixtures
+
+## Microsoft Agent Framework links
+
+- [Microsoft Agent Framework GitHub](https://github.com/microsoft/agents)
+- [AIAgent interface documentation](https://github.com/microsoft/agents/blob/main/dotnet/src/Microsoft.Agents.Core/AIAgent.cs)
+- [Agent Framework samples](https://github.com/microsoft/agents/tree/main/dotnet/samples)
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..b9dc006
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,315 @@
+# Getting Started with SharpClaw Code
+
+Run a .NET-native coding agent in 15 minutes.
+
+## Prerequisites
+
+- [.NET SDK 10](https://dotnet.microsoft.com/download/dotnet/10.0) or later
+- A terminal or command prompt
+- A text editor (optional, for configuration)
+
+## Clone and Build
+
+Clone the repository and build the solution:
+
+```bash
+git clone https://github.com/clawdotnet/SharpClawCode.git
+cd SharpClawCode
+dotnet build SharpClawCode.sln
+```
+
+Run the test suite to verify your build:
+
+```bash
+dotnet test SharpClawCode.sln
+```
+
+All tests should pass. If they don't, check that you have .NET 10 SDK installed:
+
+```bash
+dotnet --version
+```
+
+## Run the CLI
+
+The CLI is in `src/SharpClaw.Code.Cli`. Start the interactive REPL:
+
+```bash
+dotnet run --project src/SharpClaw.Code.Cli
+```
+
+You'll see a prompt and command-line interface. This is the REPL.
+
+## Interactive REPL
+
+The REPL is your primary interface for chatting with the agent.
+
+### Slash Commands
+
+Type `/` to see available commands:
+
+- `/help` – Show all available commands
+- `/status` – Display current session and workspace state
+- `/doctor` – Check runtime health and provider configuration
+- `/session` – View or manage the current session
+- `/mode` – Switch workflow mode (build, plan, spec)
+- `/editor` – Open current conversation in $EDITOR
+- `/export` – Export session history as JSON
+- `/undo` – Undo the last turn
+- `/redo` – Redo the last undone turn
+- `/version` – Show SharpClaw version
+- `/commands` – List custom workspace commands
+- `/exit` – Exit the REPL
+
+### Workflow Modes
+
+The runtime supports three primary modes:
+
+| Mode | Purpose |
+|------|---------|
+| `build` | Normal coding-agent execution; all tools enabled |
+| `plan` | Analysis-first mode; planning tools only, no file/shell mutations |
+| `spec` | Generate structured spec artifacts in `docs/superpowers/specs/` |
+
+Switch modes in the REPL with `/mode build`, `/mode plan`, or `/mode spec`.
+
+## Your First Prompt
+
+Run a one-shot prompt without entering the REPL:
+
+```bash
+dotnet run --project src/SharpClaw.Code.Cli -- prompt "List all .cs files in this workspace"
+```
+
+The agent will execute and print the result to stdout.
+
+### Output Formats
+
+Emit JSON instead of human-readable output:
+
+```bash
+dotnet run --project src/SharpClaw.Code.Cli -- --output-format json prompt "Summarize the README"
+```
+
+Supported formats: `text` (default), `json`, `markdown`.
+
+## Configuration
+
+### API Keys (Environment Variables)
+
+Set provider API keys before running the CLI:
+
+```bash
+export SHARPCLAW_ANTHROPIC_API_KEY=sk-ant-...
+dotnet run --project src/SharpClaw.Code.Cli
+```
+
+Supported environment variables:
+
+- `SHARPCLAW_ANTHROPIC_API_KEY` – Anthropic API key
+- `SHARPCLAW_OPENAI_API_KEY` – OpenAI API key
+
+### Configuration File
+
+Alternatively, configure providers in `appsettings.json` (or `.local`):
+
+```json
+{
+ "SharpClaw": {
+ "Providers": {
+ "Anthropic": {
+ "ApiKey": "sk-ant-...",
+ "Model": "claude-3-5-sonnet-20241022"
+ },
+ "OpenAI": {
+ "ApiKey": "sk-...",
+ "Model": "gpt-4-turbo"
+ }
+ }
+ }
+}
+```
+
+The runtime loads from:
+1. Environment variables (highest priority)
+2. `appsettings.{Environment}.json` (if applicable)
+3. `appsettings.json` (default)
+4. `appsettings.local.json` (if present)
+
+## Embed in Your Own App
+
+Use SharpClaw as a library in your .NET application.
+
+### 1. Install the NuGet Package
+
+```bash
+dotnet add package SharpClaw.Code.Runtime
+```
+
+### 2. Register the Runtime
+
+In your application startup, add SharpClaw to the dependency injection container:
+
+```csharp
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SharpClaw.Code.Runtime;
+using SharpClaw.Code.Protocol.Commands;
+using SharpClaw.Code.Protocol.Enums;
+
+var builder = Host.CreateApplicationBuilder(args);
+
+// Add SharpClaw runtime
+builder.Services.AddSharpClawRuntime(builder.Configuration);
+
+var host = builder.Build();
+```
+
+### 3. Execute a Prompt
+
+```csharp
+using var host = builder.Build();
+await host.StartAsync();
+
+var runtime = host.Services.GetRequiredService();
+
+var request = new RunPromptRequest(
+ Prompt: "Analyze the current workspace",
+ SessionId: null, // new session
+ WorkingDirectory: Environment.CurrentDirectory,
+ PermissionMode: PermissionMode.Auto,
+ OutputFormat: OutputFormat.Markdown,
+ Metadata: new Dictionary
+ {
+ { "user-id", "developer-1" }
+ }
+);
+
+var result = await runtime.RunPromptAsync(request, CancellationToken.None);
+
+Console.WriteLine(result.FinalOutput);
+Console.WriteLine($"Session: {result.Session.Id}");
+```
+
+### 4. Reuse Sessions
+
+Sessions are durable. Resume an existing session by passing `SessionId`:
+
+```csharp
+var latestSession = await runtime.GetLatestSessionAsync(
+ workspacePath: Environment.CurrentDirectory,
+ cancellationToken: CancellationToken.None
+);
+
+var request = new RunPromptRequest(
+ Prompt: "Continue from before",
+ SessionId: latestSession?.Id, // Resume this session
+ WorkingDirectory: Environment.CurrentDirectory,
+ PermissionMode: PermissionMode.Auto,
+ OutputFormat: OutputFormat.Markdown,
+ Metadata: null
+);
+
+var result = await runtime.RunPromptAsync(request, CancellationToken.None);
+```
+
+### Minimal Example
+
+Complete console app:
+
+```csharp
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SharpClaw.Code.Runtime;
+using SharpClaw.Code.Protocol.Commands;
+using SharpClaw.Code.Protocol.Enums;
+
+var builder = Host.CreateApplicationBuilder(args);
+builder.Services.AddSharpClawRuntime(builder.Configuration);
+
+var host = builder.Build();
+await host.StartAsync();
+
+try
+{
+ var runtime = host.Services.GetRequiredService();
+ var result = await runtime.RunPromptAsync(
+ new RunPromptRequest(
+ "What is in this directory?",
+ SessionId: null,
+ WorkingDirectory: Environment.CurrentDirectory,
+ PermissionMode: PermissionMode.Auto,
+ OutputFormat: OutputFormat.Markdown,
+ Metadata: null
+ ),
+ CancellationToken.None
+ );
+
+ Console.WriteLine(result.FinalOutput);
+}
+finally
+{
+ await host.StopAsync();
+}
+```
+
+## Next Steps
+
+Learn more about SharpClaw:
+
+- **[Architecture](architecture.md)** – Design, layers, and runtime model
+- **[Sessions](sessions.md)** – Durable state, history, checkpoints, and recovery
+- **[Tools](tools.md)** – Available tools and integration patterns
+- **[Providers](providers.md)** – Provider abstraction, Anthropic, OpenAI, and custom backends
+- **[MCP Support](mcp.md)** – Model Context Protocol servers and lifecycle
+- **[Agents](agents.md)** – Agent Framework integration and configuration
+- **[Runtime Concepts](runtime.md)** – Execution model, turns, events, and telemetry
+- **[Permissions](permissions.md)** – Permission modes and approval gates
+- **[Testing](testing.md)** – Unit and integration testing strategies
+- **[Plugins](plugins.md)** – Extending SharpClaw with custom plugins
+
+## Troubleshooting
+
+### Agent doesn't respond or times out
+
+Check that your API key is set:
+
+```bash
+dotnet run --project src/SharpClaw.Code.Cli -- doctor
+```
+
+Look for your provider (Anthropic or OpenAI) in the output. If it shows "not configured", set `SHARPCLAW_ANTHROPIC_API_KEY` or configure `appsettings.json`.
+
+### Build fails with .NET version error
+
+Ensure you have .NET 10:
+
+```bash
+dotnet --version
+```
+
+If you have an older version, [install .NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0).
+
+### REPL commands not available
+
+Update to the latest main branch:
+
+```bash
+git pull origin main
+dotnet build SharpClawCode.sln
+```
+
+### Tests fail
+
+Run with verbose output:
+
+```bash
+dotnet test SharpClawCode.sln --verbosity detailed
+```
+
+Check that all prerequisites are installed and your internet connection is stable (tests may fetch test fixtures or run integration tests).
+
+## Questions?
+
+- Open an issue: [github.com/clawdotnet/SharpClawCode/issues](https://github.com/clawdotnet/SharpClawCode/issues)
+- Read the [README](../README.md) for a full feature overview
diff --git a/examples/McpToolAgent/EchoTool.cs b/examples/McpToolAgent/EchoTool.cs
new file mode 100644
index 0000000..2477e8e
--- /dev/null
+++ b/examples/McpToolAgent/EchoTool.cs
@@ -0,0 +1,52 @@
+using SharpClaw.Code.Protocol.Enums;
+using SharpClaw.Code.Protocol.Models;
+using SharpClaw.Code.Tools.BuiltIn;
+using SharpClaw.Code.Tools.Models;
+
+namespace McpToolAgent;
+
+///
+/// A simple echo tool that returns its input unchanged.
+/// Demonstrates the minimal pattern for implementing a custom SharpClaw tool.
+///
+public sealed class EchoTool : SharpClawToolBase
+{
+ ///
+ /// The stable tool name used by the agent to invoke this tool.
+ ///
+ public const string ToolName = "echo";
+
+ ///
+ public override ToolDefinition Definition { get; } = new(
+ Name: ToolName,
+ Description: "Returns the supplied message unchanged. Useful for testing tool dispatch.",
+ ApprovalScope: ApprovalScope.None,
+ IsDestructive: false,
+ RequiresApproval: false,
+ InputTypeName: nameof(EchoToolArguments),
+ InputDescription: "JSON object with a single 'message' string field.",
+ Tags: ["echo", "test", "example"]);
+
+ ///
+ public override Task ExecuteAsync(
+ ToolExecutionContext context,
+ ToolExecutionRequest request,
+ CancellationToken cancellationToken)
+ {
+ var arguments = DeserializeArguments(request);
+ var payload = new EchoToolResult(arguments.Message);
+ return Task.FromResult(CreateSuccessResult(context, request, arguments.Message, payload));
+ }
+}
+
+///
+/// Arguments accepted by .
+///
+/// The message to echo back.
+public sealed record EchoToolArguments(string Message);
+
+///
+/// Structured result produced by .
+///
+/// The echoed message.
+public sealed record EchoToolResult(string Message);
diff --git a/examples/McpToolAgent/McpToolAgent.csproj b/examples/McpToolAgent/McpToolAgent.csproj
new file mode 100644
index 0000000..bbc30f8
--- /dev/null
+++ b/examples/McpToolAgent/McpToolAgent.csproj
@@ -0,0 +1,17 @@
+
+
+
+ Exe
+ Custom tool agent example for SharpClaw Code.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/McpToolAgent/Program.cs b/examples/McpToolAgent/Program.cs
new file mode 100644
index 0000000..38d9e7a
--- /dev/null
+++ b/examples/McpToolAgent/Program.cs
@@ -0,0 +1,42 @@
+using McpToolAgent;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SharpClaw.Code.Protocol.Commands;
+using SharpClaw.Code.Protocol.Enums;
+using SharpClaw.Code.Runtime.Abstractions;
+using SharpClaw.Code.Runtime.Composition;
+using SharpClaw.Code.Tools.Abstractions;
+
+var builder = Host.CreateApplicationBuilder(args);
+builder.Services.AddSharpClawRuntime(builder.Configuration);
+
+// Register the custom echo tool so the agent can invoke it during turns.
+builder.Services.AddSingleton();
+builder.Services.AddSingleton(sp => sp.GetRequiredService());
+
+using var host = builder.Build();
+await host.StartAsync();
+
+var runtime = host.Services.GetRequiredService();
+
+var workspacePath = Directory.GetCurrentDirectory();
+var session = await runtime.CreateSessionAsync(
+ workspacePath,
+ PermissionMode.ReadOnly,
+ OutputFormat.Text,
+ CancellationToken.None);
+
+// Ask the agent to use the echo tool.
+var request = new RunPromptRequest(
+ Prompt: "Use the echo tool to echo the message: Hello from SharpClaw!",
+ SessionId: session.Id,
+ WorkingDirectory: workspacePath,
+ PermissionMode: PermissionMode.ReadOnly,
+ OutputFormat: OutputFormat.Text,
+ Metadata: null);
+
+var result = await runtime.RunPromptAsync(request, CancellationToken.None);
+
+Console.WriteLine(result.FinalOutput ?? "(no output)");
+
+await host.StopAsync();
diff --git a/examples/McpToolAgent/appsettings.json b/examples/McpToolAgent/appsettings.json
new file mode 100644
index 0000000..1804403
--- /dev/null
+++ b/examples/McpToolAgent/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "SharpClaw": {
+ "Provider": "Anthropic",
+ "Anthropic": {
+ "ApiKey": "",
+ "Model": "claude-sonnet-4-5"
+ }
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "SharpClaw": "Information"
+ }
+ }
+}
diff --git a/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj b/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj
new file mode 100644
index 0000000..09f2de4
--- /dev/null
+++ b/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ Minimal console agent example for SharpClaw Code.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/MinimalConsoleAgent/Program.cs b/examples/MinimalConsoleAgent/Program.cs
new file mode 100644
index 0000000..8d53747
--- /dev/null
+++ b/examples/MinimalConsoleAgent/Program.cs
@@ -0,0 +1,44 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SharpClaw.Code.Protocol.Commands;
+using SharpClaw.Code.Protocol.Enums;
+using SharpClaw.Code.Runtime.Abstractions;
+using SharpClaw.Code.Runtime.Composition;
+
+if (args.Length == 0)
+{
+ Console.Error.WriteLine("Usage: MinimalConsoleAgent ");
+ return 1;
+}
+
+var prompt = string.Join(' ', args);
+
+var builder = Host.CreateApplicationBuilder(args);
+builder.Services.AddSharpClawRuntime(builder.Configuration);
+
+using var host = builder.Build();
+await host.StartAsync();
+
+var runtime = host.Services.GetRequiredService();
+
+var workspacePath = Directory.GetCurrentDirectory();
+var session = await runtime.CreateSessionAsync(
+ workspacePath,
+ PermissionMode.ReadOnly,
+ OutputFormat.Text,
+ CancellationToken.None);
+
+var request = new RunPromptRequest(
+ Prompt: prompt,
+ SessionId: session.Id,
+ WorkingDirectory: workspacePath,
+ PermissionMode: PermissionMode.ReadOnly,
+ OutputFormat: OutputFormat.Text,
+ Metadata: null);
+
+var result = await runtime.RunPromptAsync(request, CancellationToken.None);
+
+Console.WriteLine(result.FinalOutput ?? "(no output)");
+
+await host.StopAsync();
+return 0;
diff --git a/examples/MinimalConsoleAgent/appsettings.json b/examples/MinimalConsoleAgent/appsettings.json
new file mode 100644
index 0000000..1804403
--- /dev/null
+++ b/examples/MinimalConsoleAgent/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "SharpClaw": {
+ "Provider": "Anthropic",
+ "Anthropic": {
+ "ApiKey": "",
+ "Model": "claude-sonnet-4-5"
+ }
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "SharpClaw": "Information"
+ }
+ }
+}
diff --git a/examples/WebApiAgent/Program.cs b/examples/WebApiAgent/Program.cs
new file mode 100644
index 0000000..f83023a
--- /dev/null
+++ b/examples/WebApiAgent/Program.cs
@@ -0,0 +1,46 @@
+using SharpClaw.Code.Protocol.Commands;
+using SharpClaw.Code.Protocol.Enums;
+using SharpClaw.Code.Runtime.Abstractions;
+using SharpClaw.Code.Runtime.Composition;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddSharpClawRuntime(builder.Configuration);
+
+var app = builder.Build();
+
+app.MapPost("/chat", async (ChatRequest body, IConversationRuntime runtime, CancellationToken ct) =>
+{
+ var workspacePath = Directory.GetCurrentDirectory();
+
+ string sessionId;
+ if (!string.IsNullOrWhiteSpace(body.SessionId))
+ {
+ sessionId = body.SessionId;
+ }
+ else
+ {
+ var session = await runtime.CreateSessionAsync(
+ workspacePath,
+ PermissionMode.ReadOnly,
+ OutputFormat.Text,
+ ct);
+ sessionId = session.Id;
+ }
+
+ var request = new RunPromptRequest(
+ Prompt: body.Prompt,
+ SessionId: sessionId,
+ WorkingDirectory: workspacePath,
+ PermissionMode: PermissionMode.ReadOnly,
+ OutputFormat: OutputFormat.Text,
+ Metadata: null);
+
+ var result = await runtime.RunPromptAsync(request, ct);
+
+ return Results.Ok(new ChatResponse(result.FinalOutput ?? string.Empty, sessionId));
+});
+
+app.Run();
+
+record ChatRequest(string Prompt, string? SessionId);
+record ChatResponse(string Output, string SessionId);
diff --git a/examples/WebApiAgent/WebApiAgent.csproj b/examples/WebApiAgent/WebApiAgent.csproj
new file mode 100644
index 0000000..243d3a3
--- /dev/null
+++ b/examples/WebApiAgent/WebApiAgent.csproj
@@ -0,0 +1,11 @@
+
+
+
+ ASP.NET Core minimal API agent example for SharpClaw Code.
+
+
+
+
+
+
+
diff --git a/examples/WebApiAgent/appsettings.json b/examples/WebApiAgent/appsettings.json
new file mode 100644
index 0000000..25fcfdc
--- /dev/null
+++ b/examples/WebApiAgent/appsettings.json
@@ -0,0 +1,17 @@
+{
+ "SharpClaw": {
+ "Provider": "Anthropic",
+ "Anthropic": {
+ "ApiKey": "",
+ "Model": "claude-sonnet-4-5"
+ }
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "SharpClaw": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj b/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj
index 344d324..3b86ce5 100644
--- a/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj
+++ b/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj
@@ -4,6 +4,7 @@
net10.0
enable
enable
+ ACP stdio host for editor and protocol bridge scenarios.
diff --git a/src/SharpClaw.Code.Agents/AgentsServiceCollectionExtensions.cs b/src/SharpClaw.Code.Agents/AgentsServiceCollectionExtensions.cs
index f62a742..629b9b0 100644
--- a/src/SharpClaw.Code.Agents/AgentsServiceCollectionExtensions.cs
+++ b/src/SharpClaw.Code.Agents/AgentsServiceCollectionExtensions.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using SharpClaw.Code.Agents.Abstractions;
using SharpClaw.Code.Agents.Agents;
+using SharpClaw.Code.Agents.Configuration;
using SharpClaw.Code.Agents.Internal;
using SharpClaw.Code.Agents.Services;
@@ -18,6 +19,8 @@ public static class AgentsServiceCollectionExtensions
/// The updated service collection.
public static IServiceCollection AddSharpClawAgents(this IServiceCollection services)
{
+ services.AddOptions();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/SharpClaw.Code.Agents/Configuration/AgentLoopOptions.cs b/src/SharpClaw.Code.Agents/Configuration/AgentLoopOptions.cs
new file mode 100644
index 0000000..6fedd5d
--- /dev/null
+++ b/src/SharpClaw.Code.Agents/Configuration/AgentLoopOptions.cs
@@ -0,0 +1,17 @@
+namespace SharpClaw.Code.Agents.Configuration;
+
+///
+/// Configures the tool-calling loop executed by .
+///
+public sealed class AgentLoopOptions
+{
+ ///
+ /// The maximum number of tool-calling iterations before the loop is forcefully terminated.
+ ///
+ public int MaxToolIterations { get; set; } = 25;
+
+ ///
+ /// The maximum number of tokens to request per provider call.
+ ///
+ public int MaxTokensPerRequest { get; set; } = 16_384;
+}
diff --git a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs
index eedc14c..365a060 100644
--- a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs
+++ b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs
@@ -1,29 +1,49 @@
+using System.Diagnostics;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using SharpClaw.Code.Agents.Configuration;
using SharpClaw.Code.Agents.Models;
+using SharpClaw.Code.Protocol.Events;
+using SharpClaw.Code.Protocol.Models;
using SharpClaw.Code.Providers.Abstractions;
using SharpClaw.Code.Providers.Models;
using SharpClaw.Code.Protocol.Enums;
-using SharpClaw.Code.Protocol.Models;
+using SharpClaw.Code.Telemetry.Diagnostics;
+using SharpClaw.Code.Telemetry.Metrics;
+using SharpClaw.Code.Tools.Models;
namespace SharpClaw.Code.Agents.Internal;
///
-/// Executes the provider-backed core of a SharpClaw agent run.
+/// Executes the provider-backed core of a SharpClaw agent run,
+/// including a multi-iteration tool-calling loop.
///
public sealed class ProviderBackedAgentKernel(
IProviderRequestPreflight providerRequestPreflight,
IModelProviderResolver providerResolver,
IAuthFlowService authFlowService,
+ ToolCallDispatcher toolCallDispatcher,
+ IOptions loopOptions,
ILogger logger)
{
- internal async Task ExecuteAsync(AgentFrameworkRequest request, CancellationToken cancellationToken)
+ internal async Task ExecuteAsync(
+ AgentFrameworkRequest request,
+ ToolExecutionContext? toolExecutionContext,
+ IReadOnlyList? availableTools,
+ CancellationToken cancellationToken)
{
+ var options = loopOptions.Value;
var requestedModel = request.Context.Model;
var requestedProvider = request.Context.Metadata is not null && request.Context.Metadata.TryGetValue("provider", out var metadataProvider)
? metadataProvider
: string.Empty;
- var providerRequest = providerRequestPreflight.Prepare(new ProviderRequest(
+ var baseMetadata = request.Context.Metadata is null
+ ? null
+ : new Dictionary(request.Context.Metadata, StringComparer.Ordinal);
+
+ // Run a single preflight to resolve the effective provider name for auth/resolution
+ var resolvedRequest = providerRequestPreflight.Prepare(new ProviderRequest(
Id: $"provider-request-{Guid.NewGuid():N}",
SessionId: request.Context.SessionId,
TurnId: request.Context.TurnId,
@@ -33,28 +53,29 @@ internal async Task ExecuteAsync(AgentFrameworkRequest
SystemPrompt: request.Instructions,
OutputFormat: request.Context.OutputFormat,
Temperature: 0.1m,
- Metadata: request.Context.Metadata is null
- ? null
- : new Dictionary(request.Context.Metadata, StringComparer.Ordinal)));
+ Metadata: baseMetadata));
+
+ var resolvedProviderName = resolvedRequest.ProviderName;
try
{
+ // --- Auth check ---
AuthStatus authStatus;
try
{
- authStatus = await authFlowService.GetStatusAsync(providerRequest.ProviderName, cancellationToken).ConfigureAwait(false);
+ authStatus = await authFlowService.GetStatusAsync(resolvedProviderName, cancellationToken).ConfigureAwait(false);
}
catch (InvalidOperationException)
{
- throw CreateMissingProviderException(providerRequest.ProviderName, requestedModel, "auth status lookup");
+ throw CreateMissingProviderException(resolvedProviderName, requestedModel, "auth status lookup");
}
catch (Exception exception)
{
throw new ProviderExecutionException(
- providerRequest.ProviderName,
+ resolvedProviderName,
requestedModel,
ProviderFailureKind.AuthenticationUnavailable,
- $"Provider '{providerRequest.ProviderName}' authentication probe failed.",
+ $"Provider '{resolvedProviderName}' authentication probe failed.",
exception);
}
@@ -62,42 +83,169 @@ internal async Task ExecuteAsync(AgentFrameworkRequest
{
logger.LogWarning(
"Provider {ProviderName} is not authenticated for session {SessionId}.",
- providerRequest.ProviderName,
+ resolvedProviderName,
request.Context.SessionId);
throw new ProviderExecutionException(
- providerRequest.ProviderName,
- providerRequest.Model,
+ resolvedProviderName,
+ requestedModel,
ProviderFailureKind.AuthenticationUnavailable,
- $"Provider '{providerRequest.ProviderName}' is not authenticated.");
+ $"Provider '{resolvedProviderName}' is not authenticated.");
}
+ // --- Resolve provider ---
IModelProvider provider;
try
{
- provider = providerResolver.Resolve(providerRequest.ProviderName);
+ provider = providerResolver.Resolve(resolvedProviderName);
}
catch (InvalidOperationException)
{
- throw CreateMissingProviderException(providerRequest.ProviderName, requestedModel, "provider resolution");
+ throw CreateMissingProviderException(resolvedProviderName, requestedModel, "provider resolution");
}
- var stream = await provider.StartStreamAsync(providerRequest, cancellationToken).ConfigureAwait(false);
- var providerEvents = new List();
+ // --- Build initial conversation messages ---
+ var messages = new List();
+ if (!string.IsNullOrWhiteSpace(request.Instructions))
+ {
+ messages.Add(new ChatMessage("system", [new ContentBlock(ContentBlockKind.Text, request.Instructions, null, null, null, null)]));
+ }
+
+ // Prepend prior-turn conversation history for multi-turn context.
+ if (request.Context.ConversationHistory is { Count: > 0 } history)
+ {
+ messages.AddRange(history);
+ }
+
+ messages.Add(new ChatMessage("user", [new ContentBlock(ContentBlockKind.Text, request.Context.Prompt, null, null, null, null)]));
+
+ // --- Tool-calling loop ---
+ var allProviderEvents = new List();
+ var allToolResults = new List();
+ var allToolEvents = new List();
var outputSegments = new List();
UsageSnapshot? terminalUsage = null;
+ ProviderRequest? lastProviderRequest = null;
- await foreach (var providerEvent in stream.Events.WithCancellation(cancellationToken))
+ for (var iteration = 0; iteration < options.MaxToolIterations; iteration++)
{
- providerEvents.Add(providerEvent);
+ var providerRequest = providerRequestPreflight.Prepare(new ProviderRequest(
+ Id: $"provider-request-{Guid.NewGuid():N}",
+ SessionId: request.Context.SessionId,
+ TurnId: request.Context.TurnId,
+ ProviderName: resolvedProviderName,
+ Model: requestedModel,
+ Prompt: request.Context.Prompt,
+ SystemPrompt: request.Instructions,
+ OutputFormat: request.Context.OutputFormat,
+ Temperature: 0.1m,
+ Metadata: baseMetadata,
+ Messages: messages,
+ Tools: availableTools,
+ MaxTokens: options.MaxTokensPerRequest));
+
+ lastProviderRequest = providerRequest;
+
+ var iterationTextSegments = new List();
+ var toolUseEvents = new List();
+
+ using var providerScope = new ProviderActivityScope(resolvedProviderName, requestedModel, providerRequest.Id);
+ var providerSw = Stopwatch.StartNew();
+ try
+ {
+ var stream = await provider.StartStreamAsync(providerRequest, cancellationToken).ConfigureAwait(false);
+
+ await foreach (var providerEvent in stream.Events.WithCancellation(cancellationToken))
+ {
+ allProviderEvents.Add(providerEvent);
+
+ if (!providerEvent.IsTerminal && !string.IsNullOrWhiteSpace(providerEvent.Content))
+ {
+ iterationTextSegments.Add(providerEvent.Content);
+ }
+
+ if (!string.IsNullOrEmpty(providerEvent.ToolUseId) && !string.IsNullOrEmpty(providerEvent.ToolName))
+ {
+ toolUseEvents.Add(providerEvent);
+ }
- if (!providerEvent.IsTerminal && !string.IsNullOrWhiteSpace(providerEvent.Content))
+ if (providerEvent.IsTerminal && providerEvent.Usage is not null)
+ {
+ terminalUsage = providerEvent.Usage;
+ }
+ }
+
+ providerSw.Stop();
+ providerScope.SetCompleted(terminalUsage?.InputTokens, terminalUsage?.OutputTokens);
+ SharpClawMeterSource.ProviderDuration.Record(providerSw.Elapsed.TotalMilliseconds);
+ }
+ catch (Exception ex)
+ {
+ providerSw.Stop();
+ providerScope.SetError(ex.Message);
+ throw;
+ }
+
+ // If no tool-use events, accumulate text and break
+ if (toolUseEvents.Count == 0)
{
- outputSegments.Add(providerEvent.Content);
+ outputSegments.AddRange(iterationTextSegments);
+ break;
}
- if (providerEvent.IsTerminal && providerEvent.Usage is not null)
+ // Build assistant message with text + tool-use content blocks
+ var assistantBlocks = new List();
+ var iterationText = string.Concat(iterationTextSegments);
+ if (!string.IsNullOrEmpty(iterationText))
{
- terminalUsage = providerEvent.Usage;
+ assistantBlocks.Add(new ContentBlock(ContentBlockKind.Text, iterationText, null, null, null, null));
+ }
+
+ foreach (var toolUseEvent in toolUseEvents)
+ {
+ assistantBlocks.Add(new ContentBlock(
+ ContentBlockKind.ToolUse,
+ null,
+ toolUseEvent.ToolUseId,
+ toolUseEvent.ToolName,
+ toolUseEvent.ToolInputJson,
+ null));
+ }
+
+ messages.Add(new ChatMessage("assistant", assistantBlocks));
+
+ // Dispatch each tool call and collect results
+ var toolResultBlocks = new List();
+ foreach (var toolUseEvent in toolUseEvents)
+ {
+ if (toolExecutionContext is null)
+ {
+ // No tool execution context means we cannot dispatch tools
+ toolResultBlocks.Add(new ContentBlock(
+ ContentBlockKind.ToolResult,
+ "Tool execution is not available in this context.",
+ toolUseEvent.ToolUseId,
+ null,
+ null,
+ true));
+ continue;
+ }
+
+ var (resultBlock, toolResult, events) = await toolCallDispatcher.DispatchAsync(
+ toolUseEvent,
+ toolExecutionContext,
+ cancellationToken).ConfigureAwait(false);
+
+ toolResultBlocks.Add(resultBlock);
+ allToolResults.Add(toolResult);
+ allToolEvents.AddRange(events);
+ }
+
+ messages.Add(new ChatMessage("user", toolResultBlocks));
+
+ // Accumulate partial text from tool-calling iterations
+ if (!string.IsNullOrEmpty(iterationText))
+ {
+ outputSegments.Add(iterationText);
}
}
@@ -106,9 +254,9 @@ internal async Task ExecuteAsync(AgentFrameworkRequest
{
logger.LogWarning(
"Provider {ProviderName} returned no stream content for session {SessionId}; returning placeholder response.",
- providerRequest.ProviderName,
+ resolvedProviderName,
request.Context.SessionId);
- return CreatePlaceholderResult(request, providerRequest.Model, $"Provider '{providerRequest.ProviderName}' returned no content; using placeholder response.");
+ return CreatePlaceholderResult(request, requestedModel, $"Provider '{resolvedProviderName}' returned no content; using placeholder response.");
}
var usage = terminalUsage ?? new UsageSnapshot(
@@ -121,9 +269,11 @@ internal async Task ExecuteAsync(AgentFrameworkRequest
return new ProviderInvocationResult(
Output: output,
Usage: usage,
- Summary: $"Streamed provider response from {providerRequest.ProviderName}/{providerRequest.Model}.",
- ProviderRequest: providerRequest,
- ProviderEvents: providerEvents);
+ Summary: $"Streamed provider response from {resolvedProviderName}/{requestedModel}.",
+ ProviderRequest: lastProviderRequest,
+ ProviderEvents: allProviderEvents,
+ ToolResults: allToolResults.Count > 0 ? allToolResults : null,
+ ToolEvents: allToolEvents.Count > 0 ? allToolEvents : null);
}
catch (OperationCanceledException)
{
@@ -140,14 +290,20 @@ internal async Task ExecuteAsync(AgentFrameworkRequest
catch (Exception exception)
{
throw new ProviderExecutionException(
- providerRequest.ProviderName,
- providerRequest.Model,
+ resolvedProviderName,
+ requestedModel,
ProviderFailureKind.StreamFailed,
- $"Provider '{providerRequest.ProviderName}' failed during execution.",
+ $"Provider '{resolvedProviderName}' failed during execution.",
exception);
}
}
+ ///
+ /// Backward-compatible overload for callers that do not need tool calling.
+ ///
+ internal Task ExecuteAsync(AgentFrameworkRequest request, CancellationToken cancellationToken)
+ => ExecuteAsync(request, toolExecutionContext: null, availableTools: null, cancellationToken);
+
private static ProviderInvocationResult CreatePlaceholderResult(AgentFrameworkRequest request, string model, string summary)
{
var output = $"Session {request.Context.SessionId} turn {request.Context.TurnId}: placeholder response for '{request.Context.Prompt}' using model '{model}'.";
diff --git a/src/SharpClaw.Code.Agents/Internal/ProviderInvocationResult.cs b/src/SharpClaw.Code.Agents/Internal/ProviderInvocationResult.cs
index 65ff1f2..3e0e157 100644
--- a/src/SharpClaw.Code.Agents/Internal/ProviderInvocationResult.cs
+++ b/src/SharpClaw.Code.Agents/Internal/ProviderInvocationResult.cs
@@ -1,3 +1,4 @@
+using SharpClaw.Code.Protocol.Events;
using SharpClaw.Code.Protocol.Models;
namespace SharpClaw.Code.Agents.Internal;
@@ -7,4 +8,6 @@ internal sealed record ProviderInvocationResult(
UsageSnapshot Usage,
string Summary,
ProviderRequest? ProviderRequest,
- IReadOnlyList? ProviderEvents);
+ IReadOnlyList? ProviderEvents,
+ IReadOnlyList? ToolResults = null,
+ IReadOnlyList? ToolEvents = null);
diff --git a/src/SharpClaw.Code.Agents/Internal/ToolCallDispatcher.cs b/src/SharpClaw.Code.Agents/Internal/ToolCallDispatcher.cs
new file mode 100644
index 0000000..8a12ff1
--- /dev/null
+++ b/src/SharpClaw.Code.Agents/Internal/ToolCallDispatcher.cs
@@ -0,0 +1,95 @@
+using SharpClaw.Code.Protocol.Events;
+using SharpClaw.Code.Protocol.Models;
+using SharpClaw.Code.Telemetry.Abstractions;
+using SharpClaw.Code.Tools.Abstractions;
+using SharpClaw.Code.Tools.Models;
+
+namespace SharpClaw.Code.Agents.Internal;
+
+///
+/// Dispatches tool-use requests from provider events through the permission-aware tool executor
+/// and returns content blocks for the provider conversation.
+///
+public sealed class ToolCallDispatcher(
+ IToolExecutor toolExecutor,
+ IRuntimeEventPublisher eventPublisher)
+{
+ ///
+ /// Executes a tool call and returns a tool-result content block.
+ ///
+ public async Task<(ContentBlock ResultBlock, ToolResult ToolResult, List Events)> DispatchAsync(
+ ProviderEvent toolUseEvent,
+ ToolExecutionContext context,
+ CancellationToken cancellationToken)
+ {
+ var toolName = toolUseEvent.ToolName ?? string.Empty;
+ var toolInputJson = toolUseEvent.ToolInputJson ?? "{}";
+ var toolUseId = toolUseEvent.ToolUseId ?? string.Empty;
+
+ var collectedEvents = new List();
+
+ // Build a minimal ToolExecutionRequest for the ToolStartedEvent
+ var requestId = $"event-{Guid.NewGuid():N}";
+ var startRequest = new Protocol.Models.ToolExecutionRequest(
+ Id: requestId,
+ SessionId: context.SessionId,
+ TurnId: context.TurnId,
+ ToolName: toolName,
+ ArgumentsJson: toolInputJson,
+ ApprovalScope: Protocol.Enums.ApprovalScope.ToolExecution,
+ WorkingDirectory: context.WorkingDirectory,
+ RequiresApproval: false,
+ IsDestructive: false);
+
+ // 1. Publish ToolStartedEvent
+ var startedEvent = new ToolStartedEvent(
+ EventId: $"event-{Guid.NewGuid():N}",
+ SessionId: context.SessionId,
+ TurnId: context.TurnId,
+ OccurredAtUtc: DateTimeOffset.UtcNow,
+ Request: startRequest);
+
+ await eventPublisher.PublishAsync(startedEvent, cancellationToken: cancellationToken);
+ collectedEvents.Add(startedEvent);
+
+ // 2. Execute the tool
+ var envelope = await toolExecutor.ExecuteAsync(toolName, toolInputJson, context, cancellationToken);
+
+ // 3. Publish ToolCompletedEvent
+ var completedEvent = new ToolCompletedEvent(
+ EventId: $"event-{Guid.NewGuid():N}",
+ SessionId: context.SessionId,
+ TurnId: context.TurnId,
+ OccurredAtUtc: DateTimeOffset.UtcNow,
+ Result: envelope.Result);
+
+ await eventPublisher.PublishAsync(completedEvent, cancellationToken: cancellationToken);
+ collectedEvents.Add(completedEvent);
+
+ // 4. Convert ToolResult to ContentBlock
+ ContentBlock resultBlock;
+ if (envelope.Result.Succeeded)
+ {
+ resultBlock = new ContentBlock(
+ ContentBlockKind.ToolResult,
+ envelope.Result.Output,
+ toolUseId,
+ null,
+ null,
+ null);
+ }
+ else
+ {
+ resultBlock = new ContentBlock(
+ ContentBlockKind.ToolResult,
+ envelope.Result.ErrorMessage ?? "Tool execution failed",
+ toolUseId,
+ null,
+ null,
+ true);
+ }
+
+ // 5. Return
+ return (resultBlock, envelope.Result, collectedEvents);
+ }
+}
diff --git a/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs b/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs
index 09b01a1..5c8f044 100644
--- a/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs
+++ b/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs
@@ -20,6 +20,10 @@ namespace SharpClaw.Code.Agents.Models;
/// The bounded delegated task contract, if any.
/// Active build vs plan workflow mode for tool permission behavior.
/// Optional mutation recorder forwarded to tool executions.
+///
+/// Prior-turn messages assembled from session events. When non-empty these are prepended
+/// to the provider request so the model has multi-turn context.
+///
public sealed record AgentRunContext(
string SessionId,
string TurnId,
@@ -33,4 +37,5 @@ public sealed record AgentRunContext(
string? ParentAgentId = null,
DelegatedTaskContract? DelegatedTask = null,
PrimaryMode PrimaryMode = PrimaryMode.Build,
- IToolMutationRecorder? ToolMutationRecorder = null);
+ IToolMutationRecorder? ToolMutationRecorder = null,
+ IReadOnlyList? ConversationHistory = null);
diff --git a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs
index 90a0709..2dc3260 100644
--- a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs
+++ b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs
@@ -5,8 +5,14 @@
using SharpClaw.Code.Infrastructure.Abstractions;
using SharpClaw.Code.Agents.Internal;
using SharpClaw.Code.Agents.Models;
+using SharpClaw.Code.Permissions.Models;
+using SharpClaw.Code.Protocol.Enums;
using SharpClaw.Code.Providers.Models;
using SharpClaw.Code.Protocol.Events;
+using SharpClaw.Code.Protocol.Models;
+using SharpClaw.Code.Tools.Abstractions;
+using SharpClaw.Code.Tools.Models;
+using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
namespace SharpClaw.Code.Agents.Services;
@@ -15,6 +21,7 @@ namespace SharpClaw.Code.Agents.Services;
///
public sealed class AgentFrameworkBridge(
ProviderBackedAgentKernel providerBackedAgentKernel,
+ IToolRegistry toolRegistry,
ISystemClock systemClock,
ILogger logger) : IAgentFrameworkBridge
{
@@ -23,6 +30,34 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel
{
ArgumentNullException.ThrowIfNull(request);
+ // Build tool execution context from agent run context
+ var toolExecutionContext = new ToolExecutionContext(
+ SessionId: request.Context.SessionId,
+ TurnId: request.Context.TurnId,
+ WorkspaceRoot: request.Context.WorkingDirectory,
+ WorkingDirectory: request.Context.WorkingDirectory,
+ PermissionMode: request.Context.PermissionMode,
+ OutputFormat: request.Context.OutputFormat,
+ EnvironmentVariables: null,
+ AllowedTools: null,
+ AllowDangerousBypass: false,
+ IsInteractive: false,
+ SourceKind: PermissionRequestSourceKind.Runtime,
+ SourceName: null,
+ TrustedPluginNames: null,
+ TrustedMcpServerNames: null,
+ PrimaryMode: request.Context.PrimaryMode,
+ MutationRecorder: request.Context.ToolMutationRecorder);
+
+ // Map tool definitions from the registry to provider tool definitions
+ var registryTools = await toolRegistry.ListAsync(
+ request.Context.WorkingDirectory,
+ cancellationToken).ConfigureAwait(false);
+
+ var providerTools = registryTools
+ .Select(t => new ProviderToolDefinition(t.Name, t.Description, t.InputSchemaJson))
+ .ToList();
+
ProviderInvocationResult? providerResult = null;
var frameworkAgent = new SharpClawFrameworkAgent(
request.AgentId,
@@ -30,7 +65,11 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel
request.Description,
async (messages, session, runOptions, ct) =>
{
- providerResult = await providerBackedAgentKernel.ExecuteAsync(request, ct).ConfigureAwait(false);
+ providerResult = await providerBackedAgentKernel.ExecuteAsync(
+ request,
+ toolExecutionContext,
+ providerTools,
+ ct).ConfigureAwait(false);
return new AgentResponse(new ChatMessage(ChatRole.Assistant, providerResult.Output));
});
@@ -58,7 +97,8 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel
ProviderEvents: null);
logger.LogInformation("Completed framework-backed agent run for {AgentId}.", request.AgentId);
- var events = new RuntimeEvent[]
+
+ var events = new List
{
new AgentSpawnedEvent(
EventId: $"event-{Guid.NewGuid():N}",
@@ -79,6 +119,12 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel
Usage: resolvedProviderResult.Usage)
};
+ // Include tool-related events from the kernel
+ if (resolvedProviderResult.ToolEvents is { Count: > 0 } toolEvents)
+ {
+ events.AddRange(toolEvents);
+ }
+
return new AgentRunResult(
AgentId: request.AgentId,
AgentKind: request.AgentKind,
@@ -87,7 +133,7 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel
Summary: resolvedProviderResult.Summary,
ProviderRequest: resolvedProviderResult.ProviderRequest,
ProviderEvents: resolvedProviderResult.ProviderEvents,
- ToolResults: [],
+ ToolResults: resolvedProviderResult.ToolResults ?? [],
Events: events);
}
}
diff --git a/src/SharpClaw.Code.Agents/SharpClaw.Code.Agents.csproj b/src/SharpClaw.Code.Agents/SharpClaw.Code.Agents.csproj
index a89f4db..c687b4d 100644
--- a/src/SharpClaw.Code.Agents/SharpClaw.Code.Agents.csproj
+++ b/src/SharpClaw.Code.Agents/SharpClaw.Code.Agents.csproj
@@ -3,6 +3,7 @@
+
@@ -16,10 +17,15 @@
+
+
+
+
net10.0
enable
enable
+ Microsoft Agent Framework integration and agent orchestration for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj b/src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj
index e2b73ce..1521160 100644
--- a/src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj
+++ b/src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj
@@ -2,6 +2,7 @@
Exe
+ Command-line interface and REPL for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Commands/SharpClaw.Code.Commands.csproj b/src/SharpClaw.Code.Commands/SharpClaw.Code.Commands.csproj
index 5a49d52..4ac17c4 100644
--- a/src/SharpClaw.Code.Commands/SharpClaw.Code.Commands.csproj
+++ b/src/SharpClaw.Code.Commands/SharpClaw.Code.Commands.csproj
@@ -1,6 +1,7 @@
+ Command handlers and output rendering for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Git/SharpClaw.Code.Git.csproj b/src/SharpClaw.Code.Git/SharpClaw.Code.Git.csproj
index 070dcb7..047b103 100644
--- a/src/SharpClaw.Code.Git/SharpClaw.Code.Git.csproj
+++ b/src/SharpClaw.Code.Git/SharpClaw.Code.Git.csproj
@@ -11,6 +11,7 @@
net10.0
enable
enable
+ Git inspection and workspace operations for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Infrastructure/SharpClaw.Code.Infrastructure.csproj b/src/SharpClaw.Code.Infrastructure/SharpClaw.Code.Infrastructure.csproj
index 2b8ceb2..2621528 100644
--- a/src/SharpClaw.Code.Infrastructure/SharpClaw.Code.Infrastructure.csproj
+++ b/src/SharpClaw.Code.Infrastructure/SharpClaw.Code.Infrastructure.csproj
@@ -10,6 +10,7 @@
net10.0
enable
enable
+ Shared infrastructure services for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Mcp/SharpClaw.Code.Mcp.csproj b/src/SharpClaw.Code.Mcp/SharpClaw.Code.Mcp.csproj
index e75eb22..63960d1 100644
--- a/src/SharpClaw.Code.Mcp/SharpClaw.Code.Mcp.csproj
+++ b/src/SharpClaw.Code.Mcp/SharpClaw.Code.Mcp.csproj
@@ -16,6 +16,7 @@
net10.0
enable
enable
+ Model Context Protocol client integration for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj b/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj
index 070dcb7..839d28c 100644
--- a/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj
+++ b/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj
@@ -11,6 +11,7 @@
net10.0
enable
enable
+ Memory extraction, indexing, and recall for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Permissions/SharpClaw.Code.Permissions.csproj b/src/SharpClaw.Code.Permissions/SharpClaw.Code.Permissions.csproj
index 37b0333..96dda7f 100644
--- a/src/SharpClaw.Code.Permissions/SharpClaw.Code.Permissions.csproj
+++ b/src/SharpClaw.Code.Permissions/SharpClaw.Code.Permissions.csproj
@@ -10,6 +10,7 @@
net10.0
enable
enable
+ Permission policy engine and approval workflows for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Plugins/SharpClaw.Code.Plugins.csproj b/src/SharpClaw.Code.Plugins/SharpClaw.Code.Plugins.csproj
index 7524122..7ad4c5f 100644
--- a/src/SharpClaw.Code.Plugins/SharpClaw.Code.Plugins.csproj
+++ b/src/SharpClaw.Code.Plugins/SharpClaw.Code.Plugins.csproj
@@ -15,6 +15,7 @@
net10.0
enable
enable
+ Plugin discovery, manifest validation, and lifecycle management for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Protocol/Models/ChatMessage.cs b/src/SharpClaw.Code.Protocol/Models/ChatMessage.cs
new file mode 100644
index 0000000..f52972f
--- /dev/null
+++ b/src/SharpClaw.Code.Protocol/Models/ChatMessage.cs
@@ -0,0 +1,10 @@
+namespace SharpClaw.Code.Protocol.Models;
+
+///
+/// A single message in a conversation history, carrying one or more content blocks.
+///
+/// The message author role: "user", "assistant", or "system".
+/// The ordered list of content blocks that make up this message.
+public sealed record ChatMessage(
+ string Role,
+ IReadOnlyList Content);
diff --git a/src/SharpClaw.Code.Protocol/Models/ContentBlock.cs b/src/SharpClaw.Code.Protocol/Models/ContentBlock.cs
new file mode 100644
index 0000000..43c9d3b
--- /dev/null
+++ b/src/SharpClaw.Code.Protocol/Models/ContentBlock.cs
@@ -0,0 +1,45 @@
+using System.Text.Json.Serialization;
+
+namespace SharpClaw.Code.Protocol.Models;
+
+///
+/// Discriminates the kind of content carried by a .
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ContentBlockKind
+{
+ ///
+ /// Plain text content.
+ ///
+ [JsonStringEnumMemberName("text")]
+ Text,
+
+ ///
+ /// The model is requesting a tool call.
+ ///
+ [JsonStringEnumMemberName("tool_use")]
+ ToolUse,
+
+ ///
+ /// The result of a prior tool invocation.
+ ///
+ [JsonStringEnumMemberName("tool_result")]
+ ToolResult,
+}
+
+///
+/// A single block of content within a .
+///
+/// The block discriminator.
+/// Text content, used for and kinds.
+/// Tool call identifier, used for and kinds.
+/// Tool name, used for kind.
+/// Tool input serialized as a JSON string, used for kind.
+/// Whether the tool result represents an error, used for kind.
+public sealed record ContentBlock(
+ ContentBlockKind Kind,
+ string? Text,
+ string? ToolUseId,
+ string? ToolName,
+ string? ToolInputJson,
+ bool? IsError);
diff --git a/src/SharpClaw.Code.Protocol/Models/ProviderEvent.cs b/src/SharpClaw.Code.Protocol/Models/ProviderEvent.cs
index 6854b02..957cde7 100644
--- a/src/SharpClaw.Code.Protocol/Models/ProviderEvent.cs
+++ b/src/SharpClaw.Code.Protocol/Models/ProviderEvent.cs
@@ -10,6 +10,10 @@ namespace SharpClaw.Code.Protocol.Models;
/// The textual or structured event payload, if any.
/// Indicates whether the event terminates the provider interaction.
/// The usage snapshot associated with the event, if any.
+/// The content block type for structured provider responses, if any.
+/// The tool-use block identifier when the provider requests a tool call, if any.
+/// The name of the tool the provider is requesting to call, if any.
+/// The JSON-encoded input arguments for the requested tool call, if any.
public sealed record ProviderEvent(
string Id,
string RequestId,
@@ -17,4 +21,8 @@ public sealed record ProviderEvent(
DateTimeOffset CreatedAtUtc,
string? Content,
bool IsTerminal,
- UsageSnapshot? Usage);
+ UsageSnapshot? Usage,
+ string? BlockType = null,
+ string? ToolUseId = null,
+ string? ToolName = null,
+ string? ToolInputJson = null);
diff --git a/src/SharpClaw.Code.Protocol/Models/ProviderRequest.cs b/src/SharpClaw.Code.Protocol/Models/ProviderRequest.cs
index 27be431..e9ab5f2 100644
--- a/src/SharpClaw.Code.Protocol/Models/ProviderRequest.cs
+++ b/src/SharpClaw.Code.Protocol/Models/ProviderRequest.cs
@@ -15,6 +15,9 @@ namespace SharpClaw.Code.Protocol.Models;
/// The preferred output format.
/// The requested sampling temperature, if any.
/// Additional machine-readable provider metadata.
+/// The conversation history to send to the provider, if any.
+/// The tool definitions available to the provider, if any.
+/// The maximum number of tokens to generate, if any.
public sealed record ProviderRequest(
string Id,
string SessionId,
@@ -25,4 +28,7 @@ public sealed record ProviderRequest(
string? SystemPrompt,
OutputFormat OutputFormat,
decimal? Temperature,
- Dictionary? Metadata);
+ Dictionary? Metadata,
+ IReadOnlyList? Messages = null,
+ IReadOnlyList? Tools = null,
+ int? MaxTokens = null);
diff --git a/src/SharpClaw.Code.Protocol/Models/ProviderToolDefinition.cs b/src/SharpClaw.Code.Protocol/Models/ProviderToolDefinition.cs
new file mode 100644
index 0000000..6a609fd
--- /dev/null
+++ b/src/SharpClaw.Code.Protocol/Models/ProviderToolDefinition.cs
@@ -0,0 +1,12 @@
+namespace SharpClaw.Code.Protocol.Models;
+
+///
+/// A lightweight tool definition for provider requests, decoupled from the full tool registry.
+///
+/// The tool name.
+/// A description of what the tool does.
+/// The JSON Schema describing the tool's input parameters.
+public sealed record ProviderToolDefinition(
+ string Name,
+ string Description,
+ string? InputSchemaJson);
diff --git a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs
index 29cc714..29ec94e 100644
--- a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs
+++ b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs
@@ -121,4 +121,11 @@ namespace SharpClaw.Code.Protocol.Serialization;
[JsonSerializable(typeof(SpecTasksDocument))]
[JsonSerializable(typeof(SpecGenerationPayload))]
[JsonSerializable(typeof(SpecArtifactSet))]
+[JsonSerializable(typeof(ChatMessage))]
+[JsonSerializable(typeof(ChatMessage[]))]
+[JsonSerializable(typeof(ContentBlock))]
+[JsonSerializable(typeof(ContentBlock[]))]
+[JsonSerializable(typeof(ContentBlockKind))]
+[JsonSerializable(typeof(ProviderToolDefinition))]
+[JsonSerializable(typeof(ProviderToolDefinition[]))]
public sealed partial class ProtocolJsonContext : JsonSerializerContext;
diff --git a/src/SharpClaw.Code.Protocol/SharpClaw.Code.Protocol.csproj b/src/SharpClaw.Code.Protocol/SharpClaw.Code.Protocol.csproj
index b760144..3b70165 100644
--- a/src/SharpClaw.Code.Protocol/SharpClaw.Code.Protocol.csproj
+++ b/src/SharpClaw.Code.Protocol/SharpClaw.Code.Protocol.csproj
@@ -4,6 +4,7 @@
net10.0
enable
enable
+ Core contracts, models, and events for the SharpClaw Code agent runtime.
diff --git a/src/SharpClaw.Code.Providers/AnthropicProvider.cs b/src/SharpClaw.Code.Providers/AnthropicProvider.cs
index beed1b6..283dc8f 100644
--- a/src/SharpClaw.Code.Providers/AnthropicProvider.cs
+++ b/src/SharpClaw.Code.Providers/AnthropicProvider.cs
@@ -38,20 +38,42 @@ public async Task StartStreamAsync(ProviderRequest request
var systemPrompt = string.IsNullOrWhiteSpace(request.SystemPrompt) ? null : request.SystemPrompt;
float? temperature = request.Temperature.HasValue ? (float)request.Temperature.Value : null;
- var parameters = new MessageCreateParams
+ MessageCreateParams parameters;
+
+ if (request.Messages is not null)
{
- MaxTokens = 1024,
- Model = modelId,
- Messages =
- [
- new MessageParam
- {
- Role = Role.User,
- Content = request.Prompt,
- },
- ],
- Temperature = temperature,
- };
+ var messages = Internal.AnthropicMessageBuilder.BuildMessages(request.Messages);
+ parameters = new MessageCreateParams
+ {
+ MaxTokens = request.MaxTokens ?? 1024,
+ Model = modelId,
+ Messages = messages,
+ Temperature = temperature,
+ };
+
+ if (request.Tools is { Count: > 0 } tools)
+ {
+ var anthropicTools = Internal.AnthropicMessageBuilder.BuildTools(tools);
+ parameters = parameters with { Tools = anthropicTools };
+ }
+ }
+ else
+ {
+ parameters = new MessageCreateParams
+ {
+ MaxTokens = request.MaxTokens ?? 1024,
+ Model = modelId,
+ Messages =
+ [
+ new MessageParam
+ {
+ Role = Role.User,
+ Content = request.Prompt,
+ },
+ ],
+ Temperature = temperature,
+ };
+ }
if (systemPrompt is not null)
{
diff --git a/src/SharpClaw.Code.Providers/Configuration/ProviderResilienceOptions.cs b/src/SharpClaw.Code.Providers/Configuration/ProviderResilienceOptions.cs
new file mode 100644
index 0000000..0e2d3a2
--- /dev/null
+++ b/src/SharpClaw.Code.Providers/Configuration/ProviderResilienceOptions.cs
@@ -0,0 +1,28 @@
+namespace SharpClaw.Code.Providers.Configuration;
+
+///
+/// Configures resilience behavior for provider requests.
+///
+public sealed class ProviderResilienceOptions
+{
+ /// Maximum number of retry attempts for transient failures.
+ public int MaxRetries { get; set; } = 3;
+
+ /// Initial delay before the first retry.
+ public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(500);
+
+ /// Maximum delay between retries.
+ public TimeSpan MaxRetryDelay { get; set; } = TimeSpan.FromSeconds(30);
+
+ /// Timeout for a single provider request.
+ public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromMinutes(5);
+
+ /// Number of consecutive failures before the circuit opens.
+ public int CircuitBreakerFailureThreshold { get; set; } = 5;
+
+ /// Duration the circuit stays open before allowing a probe request.
+ public TimeSpan CircuitBreakerBreakDuration { get; set; } = TimeSpan.FromSeconds(30);
+
+ /// Whether resilience is enabled. When false, no retry/circuit-breaker wrapping is applied.
+ public bool Enabled { get; set; } = true;
+}
diff --git a/src/SharpClaw.Code.Providers/Internal/AnthropicMessageBuilder.cs b/src/SharpClaw.Code.Providers/Internal/AnthropicMessageBuilder.cs
new file mode 100644
index 0000000..e367bc3
--- /dev/null
+++ b/src/SharpClaw.Code.Providers/Internal/AnthropicMessageBuilder.cs
@@ -0,0 +1,148 @@
+using System.Text.Json;
+using Anthropic.Models.Messages;
+using SharpClaw.Code.Protocol.Models;
+
+namespace SharpClaw.Code.Providers.Internal;
+
+///
+/// Maps SharpClaw conversation types to Anthropic SDK request parameters.
+///
+internal static class AnthropicMessageBuilder
+{
+ ///
+ /// Converts an array of records into Anthropic instances.
+ ///
+ public static MessageParam[] BuildMessages(IReadOnlyList messages)
+ {
+ var result = new MessageParam[messages.Count];
+ for (var i = 0; i < messages.Count; i++)
+ {
+ result[i] = BuildMessageParam(messages[i]);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Converts an array of records into Anthropic instances.
+ ///
+ public static ToolUnion[] BuildTools(IReadOnlyList tools)
+ {
+ var result = new ToolUnion[tools.Count];
+ for (var i = 0; i < tools.Count; i++)
+ {
+ result[i] = BuildTool(tools[i]);
+ }
+
+ return result;
+ }
+
+ private static MessageParam BuildMessageParam(ChatMessage message)
+ {
+ var role = message.Role.Equals("assistant", StringComparison.OrdinalIgnoreCase)
+ ? Role.Assistant
+ : Role.User;
+
+ var contentBlocks = new List(message.Content.Count);
+ foreach (var block in message.Content)
+ {
+ var param = BuildContentBlockParam(block);
+ if (param is not null)
+ {
+ contentBlocks.Add(param);
+ }
+ }
+
+ return new MessageParam
+ {
+ Role = role,
+ Content = contentBlocks,
+ };
+ }
+
+ private static ContentBlockParam? BuildContentBlockParam(Protocol.Models.ContentBlock block)
+ {
+ switch (block.Kind)
+ {
+ case ContentBlockKind.Text:
+ var textParam = new TextBlockParam { Text = block.Text ?? string.Empty };
+ return new ContentBlockParam(textParam, null);
+
+ case ContentBlockKind.ToolUse:
+ var input = ParseInputJson(block.ToolInputJson);
+ var toolUseParam = new ToolUseBlockParam
+ {
+ ID = block.ToolUseId ?? string.Empty,
+ Name = block.ToolName ?? string.Empty,
+ Input = input,
+ };
+ return new ContentBlockParam(toolUseParam, null);
+
+ case ContentBlockKind.ToolResult:
+ var toolResult = new ToolResultBlockParam(block.ToolUseId ?? string.Empty)
+ {
+ Content = block.Text ?? string.Empty,
+ IsError = block.IsError,
+ };
+ return new ContentBlockParam(toolResult, null);
+
+ default:
+ return null;
+ }
+ }
+
+ private static Tool BuildTool(ProviderToolDefinition definition)
+ {
+ var schemaJson = definition.InputSchemaJson ?? """{"type":"object","properties":{}}""";
+ var rawData = ParseSchemaToRawData(schemaJson);
+ var schema = InputSchema.FromRawUnchecked(rawData);
+
+ return new Tool
+ {
+ Name = definition.Name,
+ Description = definition.Description,
+ InputSchema = schema,
+ };
+ }
+
+ private static IReadOnlyDictionary ParseInputJson(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return new Dictionary();
+ }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.EnumerateObject()
+ .ToDictionary(
+ p => p.Name,
+ p => p.Value.Clone());
+ }
+ catch (JsonException)
+ {
+ return new Dictionary();
+ }
+ }
+
+ private static IReadOnlyDictionary ParseSchemaToRawData(string schemaJson)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(schemaJson);
+ return doc.RootElement.EnumerateObject()
+ .ToDictionary(
+ p => p.Name,
+ p => p.Value.Clone());
+ }
+ catch (JsonException)
+ {
+ using var fallback = JsonDocument.Parse("""{"type":"object","properties":{}}""");
+ return fallback.RootElement.EnumerateObject()
+ .ToDictionary(
+ p => p.Name,
+ p => p.Value.Clone());
+ }
+ }
+}
diff --git a/src/SharpClaw.Code.Providers/Internal/AnthropicSdkStreamAdapter.cs b/src/SharpClaw.Code.Providers/Internal/AnthropicSdkStreamAdapter.cs
index 0ddb617..49f0c9f 100644
--- a/src/SharpClaw.Code.Providers/Internal/AnthropicSdkStreamAdapter.cs
+++ b/src/SharpClaw.Code.Providers/Internal/AnthropicSdkStreamAdapter.cs
@@ -19,6 +19,11 @@ public static async IAsyncEnumerable AdaptAsync(
ISystemClock clock,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
+ // Track the current tool_use block being accumulated across events.
+ string? pendingToolUseId = null;
+ string? pendingToolName = null;
+ System.Text.StringBuilder? pendingToolInputBuilder = null;
+
IAsyncEnumerator? enumerator = null;
try
{
@@ -53,13 +58,38 @@ public static async IAsyncEnumerable AdaptAsync(
}
var ev = enumerator.Current;
- if (ev.TryPickContentBlockDelta(out var blockDelta))
+
+ if (ev.TryPickContentBlockStart(out var blockStart))
+ {
+ if (blockStart.ContentBlock.TryPickToolUse(out var toolUse))
+ {
+ pendingToolUseId = toolUse.ID;
+ pendingToolName = toolUse.Name;
+ pendingToolInputBuilder = new System.Text.StringBuilder();
+ }
+ }
+ else if (ev.TryPickContentBlockDelta(out var blockDelta))
{
- var deltaText = ExtractTextDelta(blockDelta.Delta);
+ var (deltaText, partialJson) = ExtractDeltas(blockDelta.Delta);
if (!string.IsNullOrEmpty(deltaText))
{
yield return ProviderStreamEventFactory.Delta(requestId, clock, deltaText);
}
+ else if (partialJson is not null && pendingToolInputBuilder is not null)
+ {
+ pendingToolInputBuilder.Append(partialJson);
+ }
+ }
+ else if (ev.TryPickContentBlockStop(out _))
+ {
+ if (pendingToolUseId is not null && pendingToolName is not null && pendingToolInputBuilder is not null)
+ {
+ var toolInputJson = pendingToolInputBuilder.ToString();
+ yield return ProviderStreamEventFactory.ToolUse(requestId, clock, pendingToolUseId, pendingToolName, toolInputJson);
+ pendingToolUseId = null;
+ pendingToolName = null;
+ pendingToolInputBuilder = null;
+ }
}
else if (ev.TryPickStop(out _))
{
@@ -79,11 +109,18 @@ public static async IAsyncEnumerable AdaptAsync(
yield return ProviderStreamEventFactory.Completed(requestId, clock, null);
}
- private static string? ExtractTextDelta(RawContentBlockDelta delta)
- => delta.Match(
- text => text.Text,
- _ => null,
- _ => null,
- _ => null,
- _ => null);
+ private static (string? Text, string? PartialJson) ExtractDeltas(RawContentBlockDelta delta)
+ {
+ string? text = null;
+ string? partialJson = null;
+
+ delta.Match(
+ textDelta => { text = textDelta.Text; return 0; },
+ inputJsonDelta => { partialJson = inputJsonDelta.PartialJson; return 0; },
+ _ => 0,
+ _ => 0,
+ _ => 0);
+
+ return (text, partialJson);
+ }
}
diff --git a/src/SharpClaw.Code.Providers/Internal/OpenAiMeaiStreamAdapter.cs b/src/SharpClaw.Code.Providers/Internal/OpenAiMeaiStreamAdapter.cs
index 4926f9b..565acc6 100644
--- a/src/SharpClaw.Code.Providers/Internal/OpenAiMeaiStreamAdapter.cs
+++ b/src/SharpClaw.Code.Providers/Internal/OpenAiMeaiStreamAdapter.cs
@@ -60,6 +60,24 @@ public static async IAsyncEnumerable AdaptAsync(
yield return ProviderStreamEventFactory.Delta(requestId, clock, text);
}
+ if (update.Contents is not null)
+ {
+ foreach (var content in update.Contents)
+ {
+ if (content is FunctionCallContent functionCall)
+ {
+ var argsJson = functionCall.Arguments is not null
+ ? System.Text.Json.JsonSerializer.Serialize(functionCall.Arguments)
+ : "{}";
+ yield return ProviderStreamEventFactory.ToolUse(
+ requestId, clock,
+ functionCall.CallId ?? $"call-{Guid.NewGuid():N}",
+ functionCall.Name ?? "unknown",
+ argsJson);
+ }
+ }
+ }
+
if (update.FinishReason is { } finish && !string.IsNullOrEmpty(finish.Value))
{
var usage = ProviderStreamEventFactory.TryUsageFromUpdate(update);
diff --git a/src/SharpClaw.Code.Providers/Internal/OpenAiMessageBuilder.cs b/src/SharpClaw.Code.Providers/Internal/OpenAiMessageBuilder.cs
new file mode 100644
index 0000000..325230c
--- /dev/null
+++ b/src/SharpClaw.Code.Providers/Internal/OpenAiMessageBuilder.cs
@@ -0,0 +1,138 @@
+using System.Text.Json;
+using Microsoft.Extensions.AI;
+using SharpClaw.Code.Protocol.Models;
+using MeaiChatMessage = Microsoft.Extensions.AI.ChatMessage;
+using ProtocolChatMessage = SharpClaw.Code.Protocol.Models.ChatMessage;
+
+namespace SharpClaw.Code.Providers.Internal;
+
+///
+/// Maps SharpClaw conversation types to Microsoft.Extensions.AI request parameters.
+///
+internal static class OpenAiMessageBuilder
+{
+ ///
+ /// Converts an array of SharpClaw records into MEAI instances.
+ ///
+ ///
+ /// Role mapping:
+ ///
+ /// - system→ with
+ /// - assistant→ with or
+ /// - user→ with or
+ ///
+ ///
+ public static List BuildMessages(IReadOnlyList messages)
+ {
+ var result = new List(messages.Count);
+ foreach (var message in messages)
+ {
+ var meaiMessage = BuildMeaiMessage(message);
+ if (meaiMessage is not null)
+ {
+ result.Add(meaiMessage);
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Converts an array of records into MEAI instances.
+ /// Uses to build tool declarations from the JSON schema.
+ ///
+ public static List BuildTools(IReadOnlyList tools)
+ {
+ var result = new List(tools.Count);
+ foreach (var tool in tools)
+ {
+ result.Add(BuildAiTool(tool));
+ }
+
+ return result;
+ }
+
+ private static MeaiChatMessage? BuildMeaiMessage(ProtocolChatMessage message)
+ {
+ var role = message.Role.ToLowerInvariant() switch
+ {
+ "system" => ChatRole.System,
+ "assistant" => ChatRole.Assistant,
+ _ => ChatRole.User,
+ };
+
+ var contentItems = new List(message.Content.Count);
+ foreach (var block in message.Content)
+ {
+ var item = BuildAiContent(block);
+ if (item is not null)
+ {
+ contentItems.Add(item);
+ }
+ }
+
+ if (contentItems.Count == 0)
+ {
+ return null;
+ }
+
+ return new MeaiChatMessage(role, contentItems);
+ }
+
+ private static AIContent? BuildAiContent(ContentBlock block)
+ {
+ return block.Kind switch
+ {
+ ContentBlockKind.Text => new TextContent(block.Text ?? string.Empty),
+
+ ContentBlockKind.ToolUse => new FunctionCallContent(
+ callId: block.ToolUseId ?? string.Empty,
+ name: block.ToolName ?? string.Empty,
+ arguments: ParseArguments(block.ToolInputJson)),
+
+ ContentBlockKind.ToolResult => new FunctionResultContent(
+ callId: block.ToolUseId ?? string.Empty,
+ result: (object?)(block.Text ?? string.Empty)),
+
+ _ => null,
+ };
+ }
+
+ private static AITool BuildAiTool(ProviderToolDefinition definition)
+ {
+ var schemaJson = definition.InputSchemaJson ?? """{"type":"object","properties":{}}""";
+ JsonElement schemaElement;
+ try
+ {
+ using var doc = JsonDocument.Parse(schemaJson);
+ schemaElement = doc.RootElement.Clone();
+ }
+ catch (JsonException)
+ {
+ using var fallback = JsonDocument.Parse("""{"type":"object","properties":{}}""");
+ schemaElement = fallback.RootElement.Clone();
+ }
+
+ return AIFunctionFactory.CreateDeclaration(
+ definition.Name,
+ definition.Description,
+ schemaElement);
+ }
+
+ private static Dictionary? ParseArguments(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return null;
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize>(json);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/SharpClaw.Code.Providers/Internal/ProviderStreamEventFactory.cs b/src/SharpClaw.Code.Providers/Internal/ProviderStreamEventFactory.cs
index 060029b..4f6fc45 100644
--- a/src/SharpClaw.Code.Providers/Internal/ProviderStreamEventFactory.cs
+++ b/src/SharpClaw.Code.Providers/Internal/ProviderStreamEventFactory.cs
@@ -76,6 +76,23 @@ public static ProviderEvent Completed(string requestId, ISystemClock clock, Usag
EstimatedCostUsd: null);
}
+ ///
+ /// Creates a non-terminal event representing a tool-use request from the model.
+ ///
+ public static ProviderEvent ToolUse(string requestId, ISystemClock clock, string toolUseId, string toolName, string toolInputJson)
+ => new(
+ Id: $"provider-event-{Guid.NewGuid():N}",
+ RequestId: requestId,
+ Kind: "tool_use",
+ CreatedAtUtc: clock.UtcNow,
+ Content: null,
+ IsTerminal: false,
+ Usage: null,
+ BlockType: "tool_use",
+ ToolUseId: toolUseId,
+ ToolName: toolName,
+ ToolInputJson: toolInputJson);
+
///
/// Extracts usage from a streamed update's message contents, if present.
///
diff --git a/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs b/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs
index addb0ab..031c616 100644
--- a/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs
+++ b/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs
@@ -47,13 +47,26 @@ private async IAsyncEnumerable StreamEventsAsync(
var nativeClient = openAiClient.GetChatClient(modelId);
using var chatClient = nativeClient.AsIChatClient();
- var messages = BuildChatMessages(request);
+ var messages = request.Messages is not null
+ ? OpenAiMessageBuilder.BuildMessages(request.Messages)
+ : BuildChatMessages(request);
+
var chatOptions = new ChatOptions();
if (request.Temperature is { } temp)
{
chatOptions.Temperature = (float)temp;
}
+ if (request.MaxTokens is { } maxTokens)
+ {
+ chatOptions.MaxOutputTokens = maxTokens;
+ }
+
+ if (request.Tools is { Count: > 0 } toolDefs)
+ {
+ chatOptions.Tools = OpenAiMessageBuilder.BuildTools(toolDefs);
+ }
+
var updates = chatClient.GetStreamingResponseAsync(messages, chatOptions, cancellationToken);
await foreach (var ev in OpenAiMeaiStreamAdapter.AdaptAsync(updates, request.Id, systemClock, cancellationToken)
.WithCancellation(cancellationToken)
diff --git a/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs b/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs
index e299707..168f05b 100644
--- a/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs
+++ b/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs
@@ -1,9 +1,11 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SharpClaw.Code.Infrastructure.Abstractions;
using SharpClaw.Code.Providers.Abstractions;
using SharpClaw.Code.Providers.Configuration;
+using SharpClaw.Code.Providers.Resilience;
namespace SharpClaw.Code.Providers;
@@ -16,6 +18,7 @@ public static class ProvidersServiceCollectionExtensions
private const string CatalogSectionName = $"{ProviderRootSectionName}:Catalog";
private const string AnthropicSectionName = $"{ProviderRootSectionName}:Anthropic";
private const string OpenAiCompatibleSectionName = $"{ProviderRootSectionName}:OpenAiCompatible";
+ private const string ResilienceSectionName = $"{ProviderRootSectionName}:Resilience";
///
/// Adds the default provider layer services and binds provider options from configuration.
@@ -31,7 +34,8 @@ public static IServiceCollection AddSharpClawProviders(
IConfiguration configuration,
Action? configureCatalog = null,
Action? configureAnthropic = null,
- Action? configureOpenAiCompatible = null)
+ Action? configureOpenAiCompatible = null,
+ Action? configureResilience = null)
{
ArgumentNullException.ThrowIfNull(configuration);
@@ -41,8 +45,10 @@ public static IServiceCollection AddSharpClawProviders(
.Bind(configuration.GetSection(AnthropicSectionName));
services.AddOptions()
.Bind(configuration.GetSection(OpenAiCompatibleSectionName));
+ services.AddOptions()
+ .Bind(configuration.GetSection(ResilienceSectionName));
- return AddSharpClawProvidersCore(services, configureCatalog, configureAnthropic, configureOpenAiCompatible);
+ return AddSharpClawProvidersCore(services, configureCatalog, configureAnthropic, configureOpenAiCompatible, configureResilience);
}
///
@@ -57,18 +63,21 @@ public static IServiceCollection AddSharpClawProviders(
this IServiceCollection services,
Action? configureCatalog = null,
Action? configureAnthropic = null,
- Action? configureOpenAiCompatible = null)
- => AddSharpClawProvidersCore(services, configureCatalog, configureAnthropic, configureOpenAiCompatible);
+ Action? configureOpenAiCompatible = null,
+ Action? configureResilience = null)
+ => AddSharpClawProvidersCore(services, configureCatalog, configureAnthropic, configureOpenAiCompatible, configureResilience);
private static IServiceCollection AddSharpClawProvidersCore(
IServiceCollection services,
Action? configureCatalog,
Action? configureAnthropic,
- Action? configureOpenAiCompatible)
+ Action? configureOpenAiCompatible,
+ Action? configureResilience)
{
services.AddOptions();
services.AddOptions();
services.AddOptions();
+ services.AddOptions();
if (configureCatalog is not null)
{
@@ -85,6 +94,11 @@ private static IServiceCollection AddSharpClawProvidersCore(
services.Configure(configureOpenAiCompatible);
}
+ if (configureResilience is not null)
+ {
+ services.Configure(configureResilience);
+ }
+
services.AddSingleton, ProviderCatalogOptionsValidator>();
services.AddSingleton, AnthropicProviderOptionsValidator>();
services.AddSingleton, OpenAiCompatibleProviderOptionsValidator>();
@@ -98,9 +112,25 @@ private static IServiceCollection AddSharpClawProvidersCore(
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
- services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService());
- services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService());
+
+ services.AddSingleton(serviceProvider =>
+ WrapWithResilience(serviceProvider, serviceProvider.GetRequiredService()));
+ services.AddSingleton(serviceProvider =>
+ WrapWithResilience(serviceProvider, serviceProvider.GetRequiredService()));
return services;
}
+
+ private static IModelProvider WrapWithResilience(IServiceProvider serviceProvider, IModelProvider inner)
+ {
+ var options = serviceProvider.GetRequiredService>().Value;
+ if (!options.Enabled)
+ {
+ return inner;
+ }
+
+ var loggerFactory = serviceProvider.GetRequiredService();
+ var logger = loggerFactory.CreateLogger();
+ return new ResilientProviderDecorator(inner, options, logger);
+ }
}
diff --git a/src/SharpClaw.Code.Providers/Resilience/ResilientProviderDecorator.cs b/src/SharpClaw.Code.Providers/Resilience/ResilientProviderDecorator.cs
new file mode 100644
index 0000000..449b197
--- /dev/null
+++ b/src/SharpClaw.Code.Providers/Resilience/ResilientProviderDecorator.cs
@@ -0,0 +1,220 @@
+using System.Net;
+using Microsoft.Extensions.Logging;
+using SharpClaw.Code.Providers.Abstractions;
+using SharpClaw.Code.Providers.Configuration;
+using SharpClaw.Code.Providers.Models;
+using SharpClaw.Code.Protocol.Models;
+
+namespace SharpClaw.Code.Providers.Resilience;
+
+///
+/// Decorates an with retry, rate-limit handling, and circuit-breaker resilience.
+///
+internal sealed class ResilientProviderDecorator : IModelProvider
+{
+ private readonly IModelProvider _inner;
+ private readonly ProviderResilienceOptions _options;
+ private readonly ILogger _logger;
+
+ // Circuit breaker state
+ private int _consecutiveFailures;
+ private DateTimeOffset _circuitOpenedAt;
+ private bool _circuitOpen;
+ private readonly object _lock = new();
+
+ public ResilientProviderDecorator(
+ IModelProvider inner,
+ ProviderResilienceOptions options,
+ ILogger logger)
+ {
+ _inner = inner;
+ _options = options;
+ _logger = logger;
+ }
+
+ ///
+ public string ProviderName => _inner.ProviderName;
+
+ ///
+ public Task GetAuthStatusAsync(CancellationToken cancellationToken)
+ => _inner.GetAuthStatusAsync(cancellationToken);
+
+ ///
+ public async Task StartStreamAsync(ProviderRequest request, CancellationToken ct)
+ {
+ // 1. Check circuit breaker
+ lock (_lock)
+ {
+ if (_circuitOpen)
+ {
+ var elapsed = DateTimeOffset.UtcNow - _circuitOpenedAt;
+ if (elapsed < _options.CircuitBreakerBreakDuration)
+ {
+ var remaining = _options.CircuitBreakerBreakDuration - elapsed;
+ _logger.LogWarning(
+ "Circuit breaker is open for provider {Provider}. Rejecting request. Circuit resets in {Remaining}.",
+ ProviderName,
+ remaining);
+ throw new ProviderExecutionException(
+ ProviderName,
+ request.Model,
+ ProviderFailureKind.StreamFailed,
+ $"Circuit breaker is open for provider '{ProviderName}'. Try again in {remaining.TotalSeconds:F1}s.");
+ }
+
+ // Break duration elapsed — allow a probe attempt (half-open)
+ _circuitOpen = false;
+ _logger.LogInformation(
+ "Circuit breaker entering half-open state for provider {Provider}. Allowing probe request.",
+ ProviderName);
+ }
+ }
+
+ // 2. Retry loop
+ Exception? lastException = null;
+
+ for (var attempt = 0; attempt <= _options.MaxRetries; attempt++)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ using var timeoutCts = new CancellationTokenSource(_options.RequestTimeout);
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
+
+ try
+ {
+ var result = await _inner.StartStreamAsync(request, linkedCts.Token).ConfigureAwait(false);
+
+ // Success — reset circuit breaker
+ ResetCircuit();
+ return result;
+ }
+ catch (Exception ex) when (!IsTransient(ex))
+ {
+ // Non-transient: fail immediately without retrying
+ _logger.LogError(
+ ex,
+ "Non-transient failure from provider {Provider} on attempt {Attempt}. Not retrying.",
+ ProviderName,
+ attempt + 1);
+ RecordFailure();
+ throw;
+ }
+ catch (Exception ex) when (IsTransient(ex))
+ {
+ lastException = ex;
+ RecordFailure();
+
+ if (attempt >= _options.MaxRetries)
+ {
+ // All retries exhausted
+ break;
+ }
+
+ // Determine delay — respect Retry-After for 429 responses
+ var delay = ComputeDelay(attempt, ex);
+
+ _logger.LogWarning(
+ ex,
+ "Transient failure from provider {Provider} on attempt {Attempt}/{MaxAttempts}. Retrying in {Delay}ms.",
+ ProviderName,
+ attempt + 1,
+ _options.MaxRetries + 1,
+ delay.TotalMilliseconds);
+
+ await Task.Delay(delay, ct).ConfigureAwait(false);
+ }
+ }
+
+ // All retries exhausted — open circuit if threshold reached
+ OpenCircuitIfThresholdReached();
+
+ throw new ProviderExecutionException(
+ ProviderName,
+ request.Model,
+ ProviderFailureKind.StreamFailed,
+ $"Provider '{ProviderName}' failed after {_options.MaxRetries + 1} attempt(s).",
+ lastException);
+ }
+
+ private static bool IsTransient(Exception ex)
+ {
+ // Non-transient exceptions we should NOT retry
+ if (ex is ProviderExecutionException pee &&
+ (pee.Kind is ProviderFailureKind.AuthenticationUnavailable or ProviderFailureKind.MissingProvider))
+ {
+ return false;
+ }
+
+ if (ex is ArgumentException)
+ {
+ return false;
+ }
+
+ // Transient: HTTP errors (5xx), timeouts, IO failures
+ return ex is HttpRequestException or TaskCanceledException or IOException;
+ }
+
+ private TimeSpan ComputeDelay(int attempt, Exception ex)
+ {
+ // Check for 429 with Retry-After
+ if (ex is HttpRequestException httpEx && httpEx.StatusCode == HttpStatusCode.TooManyRequests)
+ {
+ // Attempt to extract Retry-After from the inner exception message or data
+ // HttpRequestException does not carry headers directly; providers may embed seconds in Data
+ if (httpEx.Data.Contains("Retry-After") &&
+ httpEx.Data["Retry-After"] is int retryAfterSeconds and > 0)
+ {
+ var retryAfterDelay = TimeSpan.FromSeconds(retryAfterSeconds);
+ if (retryAfterDelay <= _options.MaxRetryDelay)
+ {
+ return retryAfterDelay;
+ }
+ }
+ }
+
+ // Exponential backoff: InitialDelay * 2^attempt + jitter
+ var exponential = _options.InitialRetryDelay.TotalMilliseconds * Math.Pow(2, attempt);
+ var jitter = Random.Shared.Next(0, 100);
+ var total = exponential + jitter;
+ var capped = Math.Min(total, _options.MaxRetryDelay.TotalMilliseconds);
+ return TimeSpan.FromMilliseconds(capped);
+ }
+
+ private void ResetCircuit()
+ {
+ lock (_lock)
+ {
+ if (_consecutiveFailures > 0 || _circuitOpen)
+ {
+ _logger.LogInformation("Circuit breaker reset for provider {Provider}.", ProviderName);
+ }
+
+ _consecutiveFailures = 0;
+ _circuitOpen = false;
+ }
+ }
+
+ private void RecordFailure()
+ {
+ lock (_lock)
+ {
+ _consecutiveFailures++;
+ }
+ }
+
+ private void OpenCircuitIfThresholdReached()
+ {
+ lock (_lock)
+ {
+ if (_consecutiveFailures >= _options.CircuitBreakerFailureThreshold)
+ {
+ _circuitOpen = true;
+ _circuitOpenedAt = DateTimeOffset.UtcNow;
+ _logger.LogError(
+ "Circuit breaker opened for provider {Provider} after {Failures} consecutive failures.",
+ ProviderName,
+ _consecutiveFailures);
+ }
+ }
+ }
+}
diff --git a/src/SharpClaw.Code.Providers/SharpClaw.Code.Providers.csproj b/src/SharpClaw.Code.Providers/SharpClaw.Code.Providers.csproj
index f743e99..36a54e9 100644
--- a/src/SharpClaw.Code.Providers/SharpClaw.Code.Providers.csproj
+++ b/src/SharpClaw.Code.Providers/SharpClaw.Code.Providers.csproj
@@ -22,6 +22,7 @@
net10.0
enable
enable
+ Anthropic and OpenAI-compatible provider integration for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Runtime/Context/ContextWindowManager.cs b/src/SharpClaw.Code.Runtime/Context/ContextWindowManager.cs
new file mode 100644
index 0000000..0b9e7ab
--- /dev/null
+++ b/src/SharpClaw.Code.Runtime/Context/ContextWindowManager.cs
@@ -0,0 +1,96 @@
+using SharpClaw.Code.Protocol.Models;
+
+namespace SharpClaw.Code.Runtime.Context;
+
+///
+/// Trims conversation history to fit within a token budget using a simple
+/// character-based token estimate (characters ÷ 4).
+///
+public static class ContextWindowManager
+{
+ private const int CharsPerTokenEstimate = 4;
+
+ ///
+ /// Returns a subset of that fits within
+ /// estimated tokens.
+ ///
+ /// Rules applied in priority order:
+ ///
+ /// - The system message (role == "system") is always kept.
+ /// - The most-recent user message is always kept.
+ /// - Oldest non-system messages are dropped until the budget is satisfied.
+ ///
+ ///
+ /// The full conversation history to truncate.
+ /// The maximum number of estimated tokens to allow.
+ /// A (possibly shorter) ordered array of objects.
+ public static ChatMessage[] Truncate(IReadOnlyList messages, int maxTokenBudget)
+ {
+ ArgumentNullException.ThrowIfNull(messages);
+ if (maxTokenBudget <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maxTokenBudget), "Token budget must be positive.");
+ }
+
+ if (messages.Count == 0)
+ {
+ return [];
+ }
+
+ // Fast path: everything fits already.
+ if (EstimateTokens(messages) <= maxTokenBudget)
+ {
+ return [.. messages];
+ }
+
+ // Separate the system message from the rest.
+ var systemMessage = messages.FirstOrDefault(m =>
+ string.Equals(m.Role, "system", StringComparison.OrdinalIgnoreCase));
+
+ // Build a mutable working list of non-system messages.
+ var working = messages
+ .Where(m => !string.Equals(m.Role, "system", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ // Drop oldest messages until budget is satisfied.
+ // Always preserve at least the last message (most recent user turn).
+ while (working.Count > 1)
+ {
+ var totalEstimate = EstimateTokens(working);
+ if (systemMessage is not null)
+ {
+ totalEstimate += EstimateTokens(systemMessage);
+ }
+
+ if (totalEstimate <= maxTokenBudget)
+ {
+ break;
+ }
+
+ working.RemoveAt(0);
+ }
+
+ // Reassemble with system message first (if present).
+ var result = new List(working.Count + 1);
+ if (systemMessage is not null)
+ {
+ result.Add(systemMessage);
+ }
+
+ result.AddRange(working);
+ return [.. result];
+ }
+
+ private static int EstimateTokens(IEnumerable messages)
+ => messages.Sum(EstimateTokens);
+
+ private static int EstimateTokens(ChatMessage message)
+ {
+ var charCount = message.Content.Sum(block =>
+ (block.Text?.Length ?? 0)
+ + (block.ToolName?.Length ?? 0)
+ + (block.ToolInputJson?.Length ?? 0));
+
+ return (charCount + CharsPerTokenEstimate - 1) / CharsPerTokenEstimate;
+ }
+}
diff --git a/src/SharpClaw.Code.Runtime/Context/ConversationHistoryAssembler.cs b/src/SharpClaw.Code.Runtime/Context/ConversationHistoryAssembler.cs
new file mode 100644
index 0000000..44b3086
--- /dev/null
+++ b/src/SharpClaw.Code.Runtime/Context/ConversationHistoryAssembler.cs
@@ -0,0 +1,96 @@
+using SharpClaw.Code.Protocol.Events;
+using SharpClaw.Code.Protocol.Models;
+
+namespace SharpClaw.Code.Runtime.Context;
+
+///
+/// Assembles conversation history from a session's persisted runtime events.
+///
+public static class ConversationHistoryAssembler
+{
+ ///
+ /// Converts a flat list of runtime events into an ordered conversation history.
+ /// Only completed turns (those with both a and a
+ /// ) contribute to the returned messages.
+ ///
+ /// The full ordered event log for a session.
+ ///
+ /// An array of objects in chronological turn order,
+ /// alternating user / assistant pairs.
+ ///
+ public static ChatMessage[] Assemble(IReadOnlyList events)
+ {
+ ArgumentNullException.ThrowIfNull(events);
+
+ if (events.Count == 0)
+ {
+ return [];
+ }
+
+ // Group events by TurnId, preserving insertion order of first occurrence.
+ var turnOrder = new List();
+ var turnEvents = new Dictionary>(StringComparer.Ordinal);
+
+ foreach (var evt in events)
+ {
+ if (evt.TurnId is null)
+ {
+ continue;
+ }
+
+ if (!turnEvents.TryGetValue(evt.TurnId, out var bucket))
+ {
+ bucket = [];
+ turnEvents[evt.TurnId] = bucket;
+ turnOrder.Add(evt.TurnId);
+ }
+
+ bucket.Add(evt);
+ }
+
+ var messages = new List(turnOrder.Count * 2);
+
+ foreach (var turnId in turnOrder)
+ {
+ var bucket = turnEvents[turnId];
+
+ var started = bucket.OfType().FirstOrDefault();
+ var completed = bucket.OfType().FirstOrDefault();
+
+ // Skip incomplete turns.
+ if (started is null || completed is null)
+ {
+ continue;
+ }
+
+ // User message: the raw input for the turn.
+ var userInput = started.Turn.Input ?? string.Empty;
+ messages.Add(new ChatMessage(
+ "user",
+ [new ContentBlock(ContentBlockKind.Text, userInput, null, null, null, null)]));
+
+ // Assistant message: prefer the turn summary; fall back to accumulated deltas.
+ string assistantText;
+ if (!string.IsNullOrWhiteSpace(completed.Summary))
+ {
+ assistantText = completed.Summary;
+ }
+ else
+ {
+ var deltas = bucket.OfType();
+ assistantText = string.Concat(deltas.Select(d => d.Content));
+ }
+
+ if (string.IsNullOrEmpty(assistantText))
+ {
+ assistantText = string.Empty;
+ }
+
+ messages.Add(new ChatMessage(
+ "assistant",
+ [new ContentBlock(ContentBlockKind.Text, assistantText, null, null, null, null)]));
+ }
+
+ return [.. messages];
+ }
+}
diff --git a/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs b/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs
index d4d0b55..8198cc0 100644
--- a/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs
+++ b/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs
@@ -6,6 +6,7 @@
using SharpClaw.Code.Protocol.Serialization;
using SharpClaw.Code.Runtime.Abstractions;
using SharpClaw.Code.Runtime.Workflow;
+using SharpClaw.Code.Sessions.Abstractions;
using SharpClaw.Code.Skills.Abstractions;
namespace SharpClaw.Code.Runtime.Context;
@@ -19,7 +20,8 @@ public sealed class PromptContextAssembler(
ISkillRegistry skillRegistry,
IGitWorkspaceService gitWorkspaceService,
IPromptReferenceResolver promptReferenceResolver,
- ISpecWorkflowService specWorkflowService) : IPromptContextAssembler
+ ISpecWorkflowService specWorkflowService,
+ IEventStore eventStore) : IPromptContextAssembler
{
///
public async Task AssembleAsync(
@@ -118,8 +120,17 @@ public async Task AssembleAsync(
sections.Add($"User request:\n{refResolution.ExpandedPrompt}");
+ // Assemble prior-turn conversation history for multi-turn context.
+ const int MaxHistoryTokenBudget = 100_000;
+ var sessionEvents = await eventStore
+ .ReadAllAsync(workspaceRoot, session.Id, cancellationToken)
+ .ConfigureAwait(false);
+ var rawHistory = ConversationHistoryAssembler.Assemble(sessionEvents);
+ var conversationHistory = ContextWindowManager.Truncate(rawHistory, MaxHistoryTokenBudget);
+
return new PromptExecutionContext(
Prompt: string.Join(Environment.NewLine + Environment.NewLine, sections),
- Metadata: metadata);
+ Metadata: metadata,
+ ConversationHistory: conversationHistory);
}
}
diff --git a/src/SharpClaw.Code.Runtime/Context/PromptExecutionContext.cs b/src/SharpClaw.Code.Runtime/Context/PromptExecutionContext.cs
index d3c7c01..ddd7982 100644
--- a/src/SharpClaw.Code.Runtime/Context/PromptExecutionContext.cs
+++ b/src/SharpClaw.Code.Runtime/Context/PromptExecutionContext.cs
@@ -1,3 +1,5 @@
+using SharpClaw.Code.Protocol.Models;
+
namespace SharpClaw.Code.Runtime.Context;
///
@@ -5,6 +7,11 @@ namespace SharpClaw.Code.Runtime.Context;
///
/// The final prompt text.
/// The merged execution metadata.
+///
+/// Prior turn messages assembled from session events, ready to be prepended to the
+/// provider request. May be empty for a brand-new session.
+///
public sealed record PromptExecutionContext(
string Prompt,
- IReadOnlyDictionary Metadata);
+ IReadOnlyDictionary Metadata,
+ IReadOnlyList? ConversationHistory = null);
diff --git a/src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj b/src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj
index 02bfc43..95471d7 100644
--- a/src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj
+++ b/src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj
@@ -1,6 +1,7 @@
+ Production runtime orchestration for SharpClaw Code agents, built on Microsoft Agent Framework.
diff --git a/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs b/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs
index c1d0be1..c1308a8 100644
--- a/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs
+++ b/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using SharpClaw.Code.Agents.Abstractions;
using SharpClaw.Code.Agents.Agents;
using SharpClaw.Code.Agents.Models;
@@ -5,6 +6,8 @@
using SharpClaw.Code.Protocol.Models;
using SharpClaw.Code.Runtime.Abstractions;
using SharpClaw.Code.Runtime.Workflow;
+using SharpClaw.Code.Telemetry.Diagnostics;
+using SharpClaw.Code.Telemetry.Metrics;
using SharpClaw.Code.Tools.Abstractions;
namespace SharpClaw.Code.Runtime.Turns;
@@ -54,9 +57,31 @@ public async Task RunAsync(
Metadata: promptContext.Metadata,
PrimaryMode: primaryMode,
ToolMutationRecorder: mutationAccumulator,
- DelegatedTask: request.DelegatedTask);
+ DelegatedTask: request.DelegatedTask,
+ ConversationHistory: promptContext.ConversationHistory);
+
+ using var turnScope = new TurnActivityScope(session.Id, turn.Id, promptContext.Prompt);
+ var sw = Stopwatch.StartNew();
+ AgentRunResult agentResult;
+ try
+ {
+ agentResult = await agent.RunAsync(agentContext, cancellationToken).ConfigureAwait(false);
+ sw.Stop();
+ turnScope.SetOutput(agentResult.Output, agentResult.Usage?.InputTokens, agentResult.Usage?.OutputTokens);
+ SharpClawMeterSource.TurnDuration.Record(sw.Elapsed.TotalMilliseconds);
+ if (agentResult.Usage is not null)
+ {
+ SharpClawMeterSource.InputTokens.Add(agentResult.Usage.InputTokens);
+ SharpClawMeterSource.OutputTokens.Add(agentResult.Usage.OutputTokens);
+ }
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ turnScope.SetError(ex);
+ throw;
+ }
- var agentResult = await agent.RunAsync(agentContext, cancellationToken).ConfigureAwait(false);
var mutations = mutationAccumulator.ToSnapshot();
return new TurnRunResult(
Output: agentResult.Output,
diff --git a/src/SharpClaw.Code.Sessions/SharpClaw.Code.Sessions.csproj b/src/SharpClaw.Code.Sessions/SharpClaw.Code.Sessions.csproj
index 6be6509..0e7237c 100644
--- a/src/SharpClaw.Code.Sessions/SharpClaw.Code.Sessions.csproj
+++ b/src/SharpClaw.Code.Sessions/SharpClaw.Code.Sessions.csproj
@@ -10,6 +10,7 @@
net10.0
enable
enable
+ Durable session persistence with append-only event logs for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Skills/SharpClaw.Code.Skills.csproj b/src/SharpClaw.Code.Skills/SharpClaw.Code.Skills.csproj
index 070dcb7..c4c7ddc 100644
--- a/src/SharpClaw.Code.Skills/SharpClaw.Code.Skills.csproj
+++ b/src/SharpClaw.Code.Skills/SharpClaw.Code.Skills.csproj
@@ -11,6 +11,7 @@
net10.0
enable
enable
+ Skill discovery and execution metadata for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Telemetry/Diagnostics/ProviderActivityScope.cs b/src/SharpClaw.Code.Telemetry/Diagnostics/ProviderActivityScope.cs
new file mode 100644
index 0000000..5a7d2a9
--- /dev/null
+++ b/src/SharpClaw.Code.Telemetry/Diagnostics/ProviderActivityScope.cs
@@ -0,0 +1,33 @@
+using System.Diagnostics;
+
+namespace SharpClaw.Code.Telemetry.Diagnostics;
+
+///
+/// Wraps a provider call in an OpenTelemetry Activity span.
+///
+public sealed class ProviderActivityScope : IDisposable
+{
+ private readonly Activity? _activity;
+
+ public ProviderActivityScope(string providerName, string model, string requestId)
+ {
+ _activity = SharpClawActivitySource.Instance.StartActivity("sharpclaw.provider");
+ _activity?.SetTag("sharpclaw.provider.name", providerName);
+ _activity?.SetTag("sharpclaw.provider.model", model);
+ _activity?.SetTag("sharpclaw.provider.request_id", requestId);
+ }
+
+ public void SetCompleted(long? inputTokens, long? outputTokens)
+ {
+ if (inputTokens.HasValue) _activity?.SetTag("sharpclaw.tokens.input", inputTokens.Value);
+ if (outputTokens.HasValue) _activity?.SetTag("sharpclaw.tokens.output", outputTokens.Value);
+ _activity?.SetStatus(ActivityStatusCode.Ok);
+ }
+
+ public void SetError(string message)
+ {
+ _activity?.SetStatus(ActivityStatusCode.Error, message);
+ }
+
+ public void Dispose() => _activity?.Dispose();
+}
diff --git a/src/SharpClaw.Code.Telemetry/Diagnostics/SharpClawActivitySource.cs b/src/SharpClaw.Code.Telemetry/Diagnostics/SharpClawActivitySource.cs
new file mode 100644
index 0000000..b80974d
--- /dev/null
+++ b/src/SharpClaw.Code.Telemetry/Diagnostics/SharpClawActivitySource.cs
@@ -0,0 +1,16 @@
+using System.Diagnostics;
+
+namespace SharpClaw.Code.Telemetry.Diagnostics;
+
+///
+/// Central for OpenTelemetry distributed tracing in SharpClaw Code.
+/// Consumers wire this into their OpenTelemetry pipeline via AddSource(SharpClawActivitySource.SourceName).
+///
+public static class SharpClawActivitySource
+{
+ /// The ActivitySource name for OpenTelemetry configuration.
+ public const string SourceName = "SharpClaw.Code";
+
+ /// Shared ActivitySource instance.
+ public static readonly ActivitySource Instance = new(SourceName, "1.0.0");
+}
diff --git a/src/SharpClaw.Code.Telemetry/Diagnostics/TurnActivityScope.cs b/src/SharpClaw.Code.Telemetry/Diagnostics/TurnActivityScope.cs
new file mode 100644
index 0000000..7a3c625
--- /dev/null
+++ b/src/SharpClaw.Code.Telemetry/Diagnostics/TurnActivityScope.cs
@@ -0,0 +1,46 @@
+using System.Diagnostics;
+
+namespace SharpClaw.Code.Telemetry.Diagnostics;
+
+///
+/// Wraps a turn execution in an OpenTelemetry Activity span.
+///
+public sealed class TurnActivityScope : IDisposable
+{
+ private readonly Activity? _activity;
+
+ public TurnActivityScope(string sessionId, string turnId, string? prompt = null)
+ {
+ _activity = SharpClawActivitySource.Instance.StartActivity("sharpclaw.turn");
+ _activity?.SetTag("sharpclaw.session.id", sessionId);
+ _activity?.SetTag("sharpclaw.turn.id", turnId);
+ if (prompt is not null)
+ {
+ // Truncate prompt to avoid huge spans
+ _activity?.SetTag("sharpclaw.turn.prompt_preview", prompt.Length > 200 ? prompt[..200] + "..." : prompt);
+ }
+ }
+
+ public void SetOutput(string? output, long? inputTokens, long? outputTokens)
+ {
+ if (output is not null)
+ {
+ _activity?.SetTag("sharpclaw.turn.output_length", output.Length);
+ }
+ if (inputTokens.HasValue) _activity?.SetTag("sharpclaw.tokens.input", inputTokens.Value);
+ if (outputTokens.HasValue) _activity?.SetTag("sharpclaw.tokens.output", outputTokens.Value);
+ _activity?.SetStatus(ActivityStatusCode.Ok);
+ }
+
+ public void SetError(Exception exception)
+ {
+ _activity?.SetStatus(ActivityStatusCode.Error, exception.Message);
+ _activity?.AddEvent(new ActivityEvent("exception", tags: new ActivityTagsCollection
+ {
+ { "exception.type", exception.GetType().FullName },
+ { "exception.message", exception.Message },
+ }));
+ }
+
+ public void Dispose() => _activity?.Dispose();
+}
diff --git a/src/SharpClaw.Code.Telemetry/Export/NdjsonTraceFileSink.cs b/src/SharpClaw.Code.Telemetry/Export/NdjsonTraceFileSink.cs
new file mode 100644
index 0000000..acd5b2b
--- /dev/null
+++ b/src/SharpClaw.Code.Telemetry/Export/NdjsonTraceFileSink.cs
@@ -0,0 +1,56 @@
+using System.Diagnostics;
+using System.Text.Json;
+using SharpClaw.Code.Telemetry.Diagnostics;
+
+namespace SharpClaw.Code.Telemetry.Export;
+
+///
+/// Writes completed Activity spans as NDJSON lines to a file for offline analysis.
+/// Register as an ActivityListener to capture spans.
+///
+public sealed class NdjsonTraceFileSink : IDisposable
+{
+ private readonly StreamWriter _writer;
+ private readonly ActivityListener _listener;
+
+ public NdjsonTraceFileSink(string filePath)
+ {
+ var directory = Path.GetDirectoryName(filePath);
+ if (!string.IsNullOrWhiteSpace(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ _writer = new StreamWriter(filePath, append: true) { AutoFlush = true, NewLine = "\n" };
+ _listener = new ActivityListener
+ {
+ ShouldListenTo = source => source.Name == SharpClawActivitySource.SourceName,
+ Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded,
+ ActivityStopped = OnActivityStopped,
+ };
+ ActivitySource.AddActivityListener(_listener);
+ }
+
+ private void OnActivityStopped(Activity activity)
+ {
+ var entry = new
+ {
+ traceId = activity.TraceId.ToString(),
+ spanId = activity.SpanId.ToString(),
+ parentSpanId = activity.ParentSpanId.ToString(),
+ operationName = activity.OperationName,
+ startTimeUtc = activity.StartTimeUtc,
+ durationMs = activity.Duration.TotalMilliseconds,
+ status = activity.Status.ToString(),
+ tags = activity.Tags.ToDictionary(t => t.Key, t => t.Value),
+ };
+ // Serialize with System.Text.Json (not source-generated — this is diagnostic-only)
+ _writer.WriteLine(JsonSerializer.Serialize(entry));
+ }
+
+ public void Dispose()
+ {
+ _listener.Dispose();
+ _writer.Dispose();
+ }
+}
diff --git a/src/SharpClaw.Code.Telemetry/Metrics/SharpClawMeterSource.cs b/src/SharpClaw.Code.Telemetry/Metrics/SharpClawMeterSource.cs
new file mode 100644
index 0000000..67172a2
--- /dev/null
+++ b/src/SharpClaw.Code.Telemetry/Metrics/SharpClawMeterSource.cs
@@ -0,0 +1,32 @@
+using System.Diagnostics.Metrics;
+
+namespace SharpClaw.Code.Telemetry.Metrics;
+
+///
+/// Central Meter for SharpClaw Code runtime metrics.
+/// Consumers wire this via AddMeter(SharpClawMeterSource.MeterName).
+///
+public static class SharpClawMeterSource
+{
+ public const string MeterName = "SharpClaw.Code";
+
+ private static readonly Meter Meter = new(MeterName, "1.0.0");
+
+ /// Total input tokens consumed.
+ public static readonly Counter InputTokens = Meter.CreateCounter("sharpclaw.tokens.input", "tokens");
+
+ /// Total output tokens consumed.
+ public static readonly Counter OutputTokens = Meter.CreateCounter("sharpclaw.tokens.output", "tokens");
+
+ /// Turn execution duration.
+ public static readonly Histogram TurnDuration = Meter.CreateHistogram("sharpclaw.turn.duration", "ms");
+
+ /// Provider request duration.
+ public static readonly Histogram ProviderDuration = Meter.CreateHistogram("sharpclaw.provider.duration", "ms");
+
+ /// Tool execution duration.
+ public static readonly Histogram ToolDuration = Meter.CreateHistogram("sharpclaw.tool.duration", "ms");
+
+ /// Total tool invocations.
+ public static readonly Counter ToolInvocations = Meter.CreateCounter("sharpclaw.tool.invocations", "invocations");
+}
diff --git a/src/SharpClaw.Code.Telemetry/SharpClaw.Code.Telemetry.csproj b/src/SharpClaw.Code.Telemetry/SharpClaw.Code.Telemetry.csproj
index 8503eb0..828692c 100644
--- a/src/SharpClaw.Code.Telemetry/SharpClaw.Code.Telemetry.csproj
+++ b/src/SharpClaw.Code.Telemetry/SharpClaw.Code.Telemetry.csproj
@@ -16,6 +16,7 @@
net10.0
enable
enable
+ Structured telemetry, event publishing, and usage tracking for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Tools/Models/ToolDefinition.cs b/src/SharpClaw.Code.Tools/Models/ToolDefinition.cs
index 40e2c71..275afe6 100644
--- a/src/SharpClaw.Code.Tools/Models/ToolDefinition.cs
+++ b/src/SharpClaw.Code.Tools/Models/ToolDefinition.cs
@@ -13,6 +13,7 @@ namespace SharpClaw.Code.Tools.Models;
/// The CLR argument contract type name used by the tool.
/// A concise description of the JSON input shape.
/// Searchable tags for discoverability.
+/// The JSON Schema describing the tool's input parameters, if any.
public sealed record ToolDefinition(
string Name,
string Description,
@@ -21,4 +22,5 @@ public sealed record ToolDefinition(
bool RequiresApproval,
string InputTypeName,
string InputDescription,
- string[] Tags);
+ string[] Tags,
+ string? InputSchemaJson = null);
diff --git a/src/SharpClaw.Code.Tools/SharpClaw.Code.Tools.csproj b/src/SharpClaw.Code.Tools/SharpClaw.Code.Tools.csproj
index ebfc0af..3aa88eb 100644
--- a/src/SharpClaw.Code.Tools/SharpClaw.Code.Tools.csproj
+++ b/src/SharpClaw.Code.Tools/SharpClaw.Code.Tools.csproj
@@ -15,6 +15,7 @@
net10.0
enable
enable
+ Built-in tools and permission-aware tool execution for SharpClaw Code.
diff --git a/src/SharpClaw.Code.Web/SharpClaw.Code.Web.csproj b/src/SharpClaw.Code.Web/SharpClaw.Code.Web.csproj
index d591387..366cb9b 100644
--- a/src/SharpClaw.Code.Web/SharpClaw.Code.Web.csproj
+++ b/src/SharpClaw.Code.Web/SharpClaw.Code.Web.csproj
@@ -15,6 +15,7 @@
net10.0
enable
enable
+ Web search and fetch services for SharpClaw Code.
diff --git a/tests/SharpClaw.Code.MockProvider/DeterministicMockModelProvider.cs b/tests/SharpClaw.Code.MockProvider/DeterministicMockModelProvider.cs
index 99f9383..9b2ce73 100644
--- a/tests/SharpClaw.Code.MockProvider/DeterministicMockModelProvider.cs
+++ b/tests/SharpClaw.Code.MockProvider/DeterministicMockModelProvider.cs
@@ -72,6 +72,20 @@ private async IAsyncEnumerable StreamEventsAsync(
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
yield return CreateTerminal(request, sequence: 2);
yield break;
+ case ParityProviderScenario.ToolCallRoundtrip:
+ // Check if the request already contains tool-result content (second iteration)
+ if (HasToolResultInMessages(request))
+ {
+ yield return CreateDelta(request, sequence: 1, "Tool result received");
+ yield return CreateTerminal(request, sequence: 2);
+ }
+ else
+ {
+ // First iteration: emit a tool-use event
+ yield return CreateToolUse(request, sequence: 1, "toolu_mock_001", "read_file", """{"path":"test.txt"}""");
+ yield return CreateTerminal(request, sequence: 2);
+ }
+ yield break;
case ParityProviderScenario.StreamingText:
default:
yield return CreateDelta(request, sequence: 1, "Hello ");
@@ -81,6 +95,27 @@ private async IAsyncEnumerable StreamEventsAsync(
}
}
+ private static bool HasToolResultInMessages(ProviderRequest request)
+ {
+ if (request.Messages is null)
+ {
+ return false;
+ }
+
+ foreach (var message in request.Messages)
+ {
+ foreach (var block in message.Content)
+ {
+ if (block.Kind == Protocol.Models.ContentBlockKind.ToolResult)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
private static ProviderEvent CreateDelta(ProviderRequest request, int sequence, string content)
=> new(
Id: CreateEventId(request, sequence),
@@ -91,6 +126,20 @@ private static ProviderEvent CreateDelta(ProviderRequest request, int sequence,
IsTerminal: false,
Usage: null);
+ private static ProviderEvent CreateToolUse(ProviderRequest request, int sequence, string toolUseId, string toolName, string toolInputJson)
+ => new(
+ Id: CreateEventId(request, sequence),
+ RequestId: request.Id,
+ Kind: "tool_use",
+ CreatedAtUtc: CreateTimestamp(sequence),
+ Content: null,
+ IsTerminal: false,
+ Usage: null,
+ BlockType: "tool_use",
+ ToolUseId: toolUseId,
+ ToolName: toolName,
+ ToolInputJson: toolInputJson);
+
private static ProviderEvent CreateTerminal(ProviderRequest request, int sequence)
=> new(
Id: CreateEventId(request, sequence),
diff --git a/tests/SharpClaw.Code.MockProvider/ParityProviderScenario.cs b/tests/SharpClaw.Code.MockProvider/ParityProviderScenario.cs
index 26b0ca7..8447e09 100644
--- a/tests/SharpClaw.Code.MockProvider/ParityProviderScenario.cs
+++ b/tests/SharpClaw.Code.MockProvider/ParityProviderScenario.cs
@@ -19,4 +19,10 @@ public static class ParityProviderScenario
/// Delays long enough to let timeout and recovery scenarios cancel the stream.
///
public const string StreamSlow = "stream_slow";
+
+ ///
+ /// Simulates a tool-calling roundtrip: first call emits a tool-use event,
+ /// second call (with tool results in messages) emits a text response.
+ ///
+ public const string ToolCallRoundtrip = "tool_call_roundtrip";
}
diff --git a/tests/SharpClaw.Code.ParityHarness/ParityScenarioIds.cs b/tests/SharpClaw.Code.ParityHarness/ParityScenarioIds.cs
index 4b1dacb..94a8c09 100644
--- a/tests/SharpClaw.Code.ParityHarness/ParityScenarioIds.cs
+++ b/tests/SharpClaw.Code.ParityHarness/ParityScenarioIds.cs
@@ -17,6 +17,7 @@ internal static class ParityScenarioIds
public const string PluginToolRoundtrip = "plugin_tool_roundtrip";
public const string McpPartialStartup = "mcp_partial_startup";
public const string RecoveryAfterTimeout = "recovery_after_timeout";
+ public const string ToolCallRoundtrip = "tool_call_roundtrip";
///
/// All first-class parity scenarios expected in this harness.
@@ -34,5 +35,6 @@ internal static class ParityScenarioIds
PluginToolRoundtrip,
McpPartialStartup,
RecoveryAfterTimeout,
+ ToolCallRoundtrip,
];
}
diff --git a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs
index a1ae7fe..a6b4789 100644
--- a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs
+++ b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs
@@ -318,13 +318,37 @@ await runtime.RunPromptAsync(
session!.State.Should().Be(SessionLifecycleState.Failed);
}
+ [Fact]
+ public async Task Tool_call_roundtrip_executes_loop_and_returns_final_text()
+ {
+ // Create the fixture file the mock tool-use will request
+ await File.WriteAllTextAsync(Path.Combine(_workspace, "test.txt"), "fixture-content");
+
+ using var provider = ParityTestHost.Create(replaceApprovals: null);
+ var runtime = ParityTestHost.GetConversation(provider);
+ var turn = await runtime.RunPromptAsync(
+ new RunPromptRequest(
+ Prompt: "tool roundtrip",
+ SessionId: null,
+ WorkingDirectory: _workspace,
+ PermissionMode.WorkspaceWrite,
+ OutputFormat.Text,
+ Metadata: new Dictionary
+ {
+ [ParityMetadataKeys.Scenario] = ParityProviderScenario.ToolCallRoundtrip,
+ }),
+ CancellationToken.None);
+
+ turn.FinalOutput.Should().Contain("Tool result received");
+ }
+
///
/// Documents parity catalog entries for discoverability in test runners.
///
[Fact]
public void Scenario_catalog_contains_expected_keys()
{
- ParityScenarioIds.All.Should().HaveCount(11);
+ ParityScenarioIds.All.Should().HaveCount(12);
ParityScenarioIds.All.Should().OnlyHaveUniqueItems();
}
}
diff --git a/tests/SharpClaw.Code.UnitTests/Agents/ToolCallDispatcherTests.cs b/tests/SharpClaw.Code.UnitTests/Agents/ToolCallDispatcherTests.cs
new file mode 100644
index 0000000..a3b3a01
--- /dev/null
+++ b/tests/SharpClaw.Code.UnitTests/Agents/ToolCallDispatcherTests.cs
@@ -0,0 +1,176 @@
+using FluentAssertions;
+using SharpClaw.Code.Agents.Internal;
+using SharpClaw.Code.Permissions.Models;
+using SharpClaw.Code.Protocol.Enums;
+using SharpClaw.Code.Protocol.Events;
+using SharpClaw.Code.Protocol.Models;
+using SharpClaw.Code.Telemetry;
+using SharpClaw.Code.Telemetry.Abstractions;
+using SharpClaw.Code.Tools.Abstractions;
+using SharpClaw.Code.Tools.Models;
+
+namespace SharpClaw.Code.UnitTests.Agents;
+
+///
+/// Verifies that bridges provider tool-use events to the tool executor
+/// and produces correct content blocks and runtime events.
+///
+public sealed class ToolCallDispatcherTests
+{
+ ///
+ /// Ensures a successful tool execution returns a non-error tool-result content block
+ /// with the tool output and the correct ToolUseId.
+ ///
+ [Fact]
+ public async Task DispatchAsync_executes_tool_and_returns_result_block()
+ {
+ var result = new ToolResult("req-1", "read_file", true, OutputFormat.Text, "file contents", null, 0, 100, null);
+ var envelope = BuildEnvelope("read_file", result);
+ var executor = new StubToolExecutor { ReturnValue = envelope };
+ var publisher = new StubEventPublisher();
+ var dispatcher = new ToolCallDispatcher(executor, publisher);
+
+ var providerEvent = BuildToolUseEvent("tool-use-id-1", "read_file", "{}");
+ var context = BuildContext();
+
+ var (resultBlock, toolResult, events) = await dispatcher.DispatchAsync(providerEvent, context, CancellationToken.None);
+
+ resultBlock.Kind.Should().Be(ContentBlockKind.ToolResult);
+ resultBlock.ToolUseId.Should().Be("tool-use-id-1");
+ resultBlock.Text.Should().Be("file contents");
+ resultBlock.IsError.Should().BeNull();
+ toolResult.Succeeded.Should().BeTrue();
+ }
+
+ ///
+ /// Ensures a failed tool execution returns an error-flagged tool-result content block
+ /// with the error message.
+ ///
+ [Fact]
+ public async Task DispatchAsync_returns_error_block_on_tool_failure()
+ {
+ var result = new ToolResult("req-2", "write_file", false, OutputFormat.Text, null, "Permission denied", null, null, null);
+ var envelope = BuildEnvelope("write_file", result);
+ var executor = new StubToolExecutor { ReturnValue = envelope };
+ var publisher = new StubEventPublisher();
+ var dispatcher = new ToolCallDispatcher(executor, publisher);
+
+ var providerEvent = BuildToolUseEvent("tool-use-id-2", "write_file", "{\"path\":\"x\"}");
+ var context = BuildContext();
+
+ var (resultBlock, toolResult, events) = await dispatcher.DispatchAsync(providerEvent, context, CancellationToken.None);
+
+ resultBlock.Kind.Should().Be(ContentBlockKind.ToolResult);
+ resultBlock.ToolUseId.Should().Be("tool-use-id-2");
+ resultBlock.Text.Should().Be("Permission denied");
+ resultBlock.IsError.Should().Be(true);
+ toolResult.Succeeded.Should().BeFalse();
+ }
+
+ ///
+ /// Ensures a and a are published
+ /// in the correct order with matching session and turn identifiers.
+ ///
+ [Fact]
+ public async Task DispatchAsync_publishes_started_and_completed_events()
+ {
+ var result = new ToolResult("req-3", "bash", true, OutputFormat.Text, "done", null, 0, 50, null);
+ var envelope = BuildEnvelope("bash", result);
+ var executor = new StubToolExecutor { ReturnValue = envelope };
+ var publisher = new StubEventPublisher();
+ var dispatcher = new ToolCallDispatcher(executor, publisher);
+
+ var providerEvent = BuildToolUseEvent("tool-use-id-3", "bash", "{\"command\":\"ls\"}");
+ var context = BuildContext();
+
+ var (_, _, events) = await dispatcher.DispatchAsync(providerEvent, context, CancellationToken.None);
+
+ publisher.Published.Should().HaveCount(2);
+ events.Should().HaveCount(2);
+
+ var startedEvent = publisher.Published[0].Should().BeOfType().Subject;
+ startedEvent.SessionId.Should().Be("s1");
+ startedEvent.TurnId.Should().Be("t1");
+ startedEvent.Request.ToolName.Should().Be("bash");
+
+ var completedEvent = publisher.Published[1].Should().BeOfType().Subject;
+ completedEvent.SessionId.Should().Be("s1");
+ completedEvent.TurnId.Should().Be("t1");
+ completedEvent.Result.Succeeded.Should().BeTrue();
+ }
+
+ private static ProviderEvent BuildToolUseEvent(string toolUseId, string toolName, string toolInputJson)
+ => new(
+ Id: "pev-1",
+ RequestId: "req-1",
+ Kind: "tool_use",
+ CreatedAtUtc: DateTimeOffset.UtcNow,
+ Content: null,
+ IsTerminal: false,
+ Usage: null,
+ BlockType: "tool_use",
+ ToolUseId: toolUseId,
+ ToolName: toolName,
+ ToolInputJson: toolInputJson);
+
+ private static ToolExecutionContext BuildContext()
+ => new(
+ SessionId: "s1",
+ TurnId: "t1",
+ WorkspaceRoot: "/tmp/test",
+ WorkingDirectory: "/tmp/test",
+ PermissionMode: PermissionMode.WorkspaceWrite,
+ OutputFormat: OutputFormat.Text,
+ EnvironmentVariables: null);
+
+ private static ToolExecutionEnvelope BuildEnvelope(string toolName, ToolResult result)
+ {
+ var request = new ToolExecutionRequest(
+ Id: "req-1",
+ SessionId: "s1",
+ TurnId: "t1",
+ ToolName: toolName,
+ ArgumentsJson: "{}",
+ ApprovalScope: ApprovalScope.ToolExecution,
+ WorkingDirectory: "/tmp/test",
+ RequiresApproval: false,
+ IsDestructive: false);
+
+ var decision = new PermissionDecision(
+ Scope: ApprovalScope.ToolExecution,
+ Mode: PermissionMode.WorkspaceWrite,
+ IsAllowed: true,
+ Reason: null,
+ EvaluatedAtUtc: DateTimeOffset.UtcNow);
+
+ return new ToolExecutionEnvelope(request, decision, result);
+ }
+
+ private sealed class StubToolExecutor : IToolExecutor
+ {
+ public ToolExecutionEnvelope? ReturnValue { get; set; }
+
+ public Task ExecuteAsync(
+ string toolName,
+ string argumentsJson,
+ ToolExecutionContext context,
+ CancellationToken cancellationToken)
+ => Task.FromResult(ReturnValue!);
+ }
+
+ private sealed class StubEventPublisher : IRuntimeEventPublisher
+ {
+ public List Published { get; } = [];
+
+ public ValueTask PublishAsync(
+ RuntimeEvent runtimeEvent,
+ RuntimeEventPublishOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ Published.Add(runtimeEvent);
+ return ValueTask.CompletedTask;
+ }
+
+ public IReadOnlyList GetRecentEventsSnapshot() => Published;
+ }
+}
diff --git a/tests/SharpClaw.Code.UnitTests/Protocol/ChatMessageSerializationTests.cs b/tests/SharpClaw.Code.UnitTests/Protocol/ChatMessageSerializationTests.cs
new file mode 100644
index 0000000..be7d44f
--- /dev/null
+++ b/tests/SharpClaw.Code.UnitTests/Protocol/ChatMessageSerializationTests.cs
@@ -0,0 +1,106 @@
+using System.Text.Json;
+using FluentAssertions;
+using SharpClaw.Code.Protocol.Models;
+using SharpClaw.Code.Protocol.Serialization;
+
+namespace SharpClaw.Code.UnitTests.Protocol;
+
+///
+/// Verifies roundtrip JSON serialization of and .
+///
+public sealed class ChatMessageSerializationTests
+{
+ ///
+ /// A user message carrying a plain text block survives a full serialize/deserialize cycle.
+ ///
+ [Fact]
+ public void ChatMessage_with_text_block_roundtrips()
+ {
+ var message = new ChatMessage(
+ Role: "user",
+ Content:
+ [
+ new ContentBlock(
+ Kind: ContentBlockKind.Text,
+ Text: "Hello, world!",
+ ToolUseId: null,
+ ToolName: null,
+ ToolInputJson: null,
+ IsError: null)
+ ]);
+
+ var json = JsonSerializer.Serialize(message, ProtocolJsonContext.Default.ChatMessage);
+ var deserialized = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.ChatMessage);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.Role.Should().Be("user");
+ deserialized.Content.Should().HaveCount(1);
+ deserialized.Content[0].Kind.Should().Be(ContentBlockKind.Text);
+ deserialized.Content[0].Text.Should().Be("Hello, world!");
+ }
+
+ ///
+ /// An assistant message carrying a tool-use block survives a full serialize/deserialize cycle.
+ ///
+ [Fact]
+ public void ChatMessage_with_tool_use_block_roundtrips()
+ {
+ var message = new ChatMessage(
+ Role: "assistant",
+ Content:
+ [
+ new ContentBlock(
+ Kind: ContentBlockKind.ToolUse,
+ Text: null,
+ ToolUseId: "call-1",
+ ToolName: "read_file",
+ ToolInputJson: "{\"path\":\"a.cs\"}",
+ IsError: null)
+ ]);
+
+ var json = JsonSerializer.Serialize(message, ProtocolJsonContext.Default.ChatMessage);
+ var deserialized = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.ChatMessage);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.Role.Should().Be("assistant");
+ deserialized.Content.Should().HaveCount(1);
+
+ var block = deserialized.Content[0];
+ block.Kind.Should().Be(ContentBlockKind.ToolUse);
+ block.ToolUseId.Should().Be("call-1");
+ block.ToolName.Should().Be("read_file");
+ block.ToolInputJson.Should().Be("{\"path\":\"a.cs\"}");
+ }
+
+ ///
+ /// A user message carrying a tool-result block survives a full serialize/deserialize cycle.
+ ///
+ [Fact]
+ public void ChatMessage_with_tool_result_block_roundtrips()
+ {
+ var message = new ChatMessage(
+ Role: "user",
+ Content:
+ [
+ new ContentBlock(
+ Kind: ContentBlockKind.ToolResult,
+ Text: "file contents",
+ ToolUseId: "call-1",
+ ToolName: null,
+ ToolInputJson: null,
+ IsError: null)
+ ]);
+
+ var json = JsonSerializer.Serialize(message, ProtocolJsonContext.Default.ChatMessage);
+ var deserialized = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.ChatMessage);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.Role.Should().Be("user");
+ deserialized.Content.Should().HaveCount(1);
+
+ var block = deserialized.Content[0];
+ block.Kind.Should().Be(ContentBlockKind.ToolResult);
+ block.ToolUseId.Should().Be("call-1");
+ block.Text.Should().Be("file contents");
+ }
+}
diff --git a/tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs b/tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs
new file mode 100644
index 0000000..2d1a9ca
--- /dev/null
+++ b/tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs
@@ -0,0 +1,182 @@
+using FluentAssertions;
+using Microsoft.Extensions.Logging.Abstractions;
+using SharpClaw.Code.Providers.Abstractions;
+using SharpClaw.Code.Providers.Configuration;
+using SharpClaw.Code.Providers.Models;
+using SharpClaw.Code.Providers.Resilience;
+using SharpClaw.Code.Protocol.Enums;
+using SharpClaw.Code.Protocol.Models;
+
+namespace SharpClaw.Code.UnitTests.Providers;
+
+///
+/// Verifies retry, rate-limit, and circuit-breaker behavior of .
+///
+public sealed class ResilienceTests
+{
+ private static readonly ProviderRequest FakeRequest = new(
+ Id: "req-001",
+ SessionId: "session-1",
+ TurnId: "turn-1",
+ ProviderName: "test",
+ Model: "test-model",
+ Prompt: "hello",
+ SystemPrompt: null,
+ OutputFormat: OutputFormat.Text,
+ Temperature: null,
+ Metadata: null);
+
+ private static ResilientProviderDecorator BuildDecorator(
+ IModelProvider inner,
+ ProviderResilienceOptions? options = null)
+ {
+ var opts = options ?? new ProviderResilienceOptions
+ {
+ MaxRetries = 2,
+ InitialRetryDelay = TimeSpan.Zero,
+ MaxRetryDelay = TimeSpan.Zero,
+ RequestTimeout = TimeSpan.FromSeconds(30),
+ CircuitBreakerFailureThreshold = 5,
+ CircuitBreakerBreakDuration = TimeSpan.FromSeconds(30),
+ };
+
+ return new ResilientProviderDecorator(inner, opts, NullLogger.Instance);
+ }
+
+ [Fact]
+ public async Task Retries_on_transient_failure_then_succeeds()
+ {
+ // Arrange: fail twice with HttpRequestException, succeed on third attempt
+ var fakeHandle = new ProviderStreamHandle(FakeRequest, AsyncEnumerable.Empty());
+ var mock = new CountingMockProvider();
+ mock.Behaviors.Enqueue(() => throw new HttpRequestException("transient 1"));
+ mock.Behaviors.Enqueue(() => throw new HttpRequestException("transient 2"));
+ mock.Behaviors.Enqueue(() => Task.FromResult(fakeHandle));
+
+ var decorator = BuildDecorator(mock);
+
+ // Act
+ var result = await decorator.StartStreamAsync(FakeRequest, CancellationToken.None);
+
+ // Assert
+ result.Should().BeSameAs(fakeHandle);
+ mock.CallCount.Should().Be(3);
+ }
+
+ [Fact]
+ public async Task Does_not_retry_non_transient_failures()
+ {
+ // Arrange: throw ArgumentException immediately
+ var mock = new CountingMockProvider();
+ mock.Behaviors.Enqueue(() => throw new ArgumentException("bad argument"));
+
+ var decorator = BuildDecorator(mock);
+
+ // Act
+ Func act = () => decorator.StartStreamAsync(FakeRequest, CancellationToken.None);
+
+ // Assert: propagates immediately after a single call
+ await act.Should().ThrowAsync();
+ mock.CallCount.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task Circuit_breaker_opens_after_threshold()
+ {
+ // Arrange: always fail; threshold = 3, maxRetries = 2 (so each outer call = 1 attempt since no retries remain after threshold)
+ var opts = new ProviderResilienceOptions
+ {
+ MaxRetries = 0, // No retries — each call is a single attempt
+ InitialRetryDelay = TimeSpan.Zero,
+ MaxRetryDelay = TimeSpan.Zero,
+ RequestTimeout = TimeSpan.FromSeconds(30),
+ CircuitBreakerFailureThreshold = 3,
+ CircuitBreakerBreakDuration = TimeSpan.FromHours(1), // Will not auto-reset
+ };
+
+ var mock = new CountingMockProvider();
+ // Queue more than threshold failures
+ for (var i = 0; i < 10; i++)
+ {
+ mock.Behaviors.Enqueue(() => throw new HttpRequestException("always fails"));
+ }
+
+ var decorator = BuildDecorator(mock, opts);
+
+ // Exhaust threshold with 3 calls (each fails)
+ for (var i = 0; i < 3; i++)
+ {
+ await FluentActions
+ .Awaiting(() => decorator.StartStreamAsync(FakeRequest, CancellationToken.None))
+ .Should().ThrowAsync();
+ }
+
+ var callsBeforeCircuitOpen = mock.CallCount;
+
+ // Next call should be rejected by the open circuit without reaching inner provider
+ await FluentActions
+ .Awaiting(() => decorator.StartStreamAsync(FakeRequest, CancellationToken.None))
+ .Should().ThrowAsync()
+ .WithMessage("*Circuit breaker*");
+
+ mock.CallCount.Should().Be(callsBeforeCircuitOpen, "circuit breaker must not forward the call to the inner provider");
+ }
+
+ [Fact]
+ public async Task Circuit_breaker_allows_probe_after_break_duration()
+ {
+ // Arrange: circuit opens immediately after threshold, then break duration is 0 so probe is allowed right away
+ var opts = new ProviderResilienceOptions
+ {
+ MaxRetries = 0,
+ InitialRetryDelay = TimeSpan.Zero,
+ MaxRetryDelay = TimeSpan.Zero,
+ RequestTimeout = TimeSpan.FromSeconds(30),
+ CircuitBreakerFailureThreshold = 1, // Opens after 1 failure
+ CircuitBreakerBreakDuration = TimeSpan.Zero, // Immediately allow probe
+ };
+
+ var fakeHandle = new ProviderStreamHandle(FakeRequest, AsyncEnumerable.Empty());
+ var mock = new CountingMockProvider();
+
+ // First call fails — opens circuit
+ mock.Behaviors.Enqueue(() => throw new HttpRequestException("initial failure"));
+ // Probe call succeeds
+ mock.Behaviors.Enqueue(() => Task.FromResult(fakeHandle));
+
+ var decorator = BuildDecorator(mock, opts);
+
+ // First call: should fail and open the circuit
+ await FluentActions
+ .Awaiting(() => decorator.StartStreamAsync(FakeRequest, CancellationToken.None))
+ .Should().ThrowAsync();
+
+ mock.CallCount.Should().Be(1);
+
+ // Second call: break duration has elapsed (it's zero), so probe should reach inner provider
+ var result = await decorator.StartStreamAsync(FakeRequest, CancellationToken.None);
+ result.Should().BeSameAs(fakeHandle);
+ mock.CallCount.Should().Be(2, "probe attempt must reach the inner provider");
+ }
+
+ // -----------------------------------------------------------------------
+ // Test double
+ // -----------------------------------------------------------------------
+
+ private sealed class CountingMockProvider : IModelProvider
+ {
+ public int CallCount { get; private set; }
+ public Queue>> Behaviors { get; } = new();
+
+ public string ProviderName => "test";
+
+ public Task GetAuthStatusAsync(CancellationToken ct)
+ => Task.FromResult(new AuthStatus(null, false, "test", null, null, null));
+
+ public async Task StartStreamAsync(ProviderRequest request, CancellationToken ct)
+ {
+ CallCount++;
+ return await Behaviors.Dequeue()();
+ }
+ }
+}
diff --git a/tests/SharpClaw.Code.UnitTests/Providers/ToolUseStreamAdapterTests.cs b/tests/SharpClaw.Code.UnitTests/Providers/ToolUseStreamAdapterTests.cs
new file mode 100644
index 0000000..7191266
--- /dev/null
+++ b/tests/SharpClaw.Code.UnitTests/Providers/ToolUseStreamAdapterTests.cs
@@ -0,0 +1,337 @@
+using System.Globalization;
+using System.Text.Json;
+using Anthropic.Models.Messages;
+using FluentAssertions;
+using SharpClaw.Code.Infrastructure.Abstractions;
+using SharpClaw.Code.Providers.Internal;
+using ProtocolModels = SharpClaw.Code.Protocol.Models;
+
+namespace SharpClaw.Code.UnitTests.Providers;
+
+///
+/// Tests for tool-use stream events and message/tool builder mappings.
+///
+public sealed class ToolUseStreamAdapterTests
+{
+ [Fact]
+ public void ToolUse_factory_creates_event_with_tool_metadata()
+ {
+ var clock = new FixedClock();
+ var ev = ProviderStreamEventFactory.ToolUse("req-1", clock, "call-1", "read_file", "{\"path\":\"a.cs\"}");
+
+ ev.Kind.Should().Be("tool_use");
+ ev.BlockType.Should().Be("tool_use");
+ ev.ToolUseId.Should().Be("call-1");
+ ev.ToolName.Should().Be("read_file");
+ ev.ToolInputJson.Should().Be("{\"path\":\"a.cs\"}");
+ ev.IsTerminal.Should().BeFalse();
+ ev.Content.Should().BeNull();
+ ev.Usage.Should().BeNull();
+ ev.RequestId.Should().Be("req-1");
+ }
+
+ [Fact]
+ public async Task Anthropic_sdk_adapter_emits_tool_use_event_on_complete_tool_block()
+ {
+ var clock = new FixedClock();
+
+ async IAsyncEnumerable Stream()
+ {
+ var toolUseBlock = MakeToolUseBlock("call-42", "list_files");
+ yield return new RawMessageStreamEvent(
+ new RawContentBlockStartEvent
+ {
+ Index = 0,
+ ContentBlock = new RawContentBlockStartEventContentBlock(toolUseBlock, null),
+ },
+ default);
+
+ yield return new RawMessageStreamEvent(
+ new RawContentBlockDeltaEvent
+ {
+ Index = 0,
+ Delta = new RawContentBlockDelta(new InputJsonDelta { PartialJson = "{\"pa" }, null),
+ },
+ default);
+
+ yield return new RawMessageStreamEvent(
+ new RawContentBlockDeltaEvent
+ {
+ Index = 0,
+ Delta = new RawContentBlockDelta(new InputJsonDelta { PartialJson = "th\":\"src\"}" }, null),
+ },
+ default);
+
+ yield return new RawMessageStreamEvent(new RawContentBlockStopEvent { Index = 0 }, default);
+ yield return new RawMessageStreamEvent(new RawMessageStopEvent(), default);
+ }
+
+ var events = new List();
+ await foreach (var e in AnthropicSdkStreamAdapter.AdaptAsync(Stream(), "req-tool", clock, CancellationToken.None))
+ {
+ events.Add(e);
+ }
+
+ events.Should().HaveCount(2);
+
+ var toolEvent = events[0];
+ toolEvent.Kind.Should().Be("tool_use");
+ toolEvent.BlockType.Should().Be("tool_use");
+ toolEvent.ToolUseId.Should().Be("call-42");
+ toolEvent.ToolName.Should().Be("list_files");
+ toolEvent.ToolInputJson.Should().Be("{\"path\":\"src\"}");
+ toolEvent.IsTerminal.Should().BeFalse();
+
+ var completedEvent = events[1];
+ completedEvent.Kind.Should().Be("completed");
+ completedEvent.IsTerminal.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Anthropic_sdk_adapter_mixes_text_and_tool_use_blocks()
+ {
+ var clock = new FixedClock();
+
+ async IAsyncEnumerable Stream()
+ {
+ yield return new RawMessageStreamEvent(
+ new RawContentBlockDeltaEvent
+ {
+ Index = 0,
+ Delta = new RawContentBlockDelta(new TextDelta("Let me check."), null),
+ },
+ default);
+
+ var toolUseBlock = MakeToolUseBlock("call-7", "read_file");
+ yield return new RawMessageStreamEvent(
+ new RawContentBlockStartEvent
+ {
+ Index = 1,
+ ContentBlock = new RawContentBlockStartEventContentBlock(toolUseBlock, null),
+ },
+ default);
+
+ yield return new RawMessageStreamEvent(
+ new RawContentBlockDeltaEvent
+ {
+ Index = 1,
+ Delta = new RawContentBlockDelta(new InputJsonDelta { PartialJson = "{\"path\":\"b.cs\"}" }, null),
+ },
+ default);
+
+ yield return new RawMessageStreamEvent(new RawContentBlockStopEvent { Index = 1 }, default);
+ yield return new RawMessageStreamEvent(new RawMessageStopEvent(), default);
+ }
+
+ var events = new List();
+ await foreach (var e in AnthropicSdkStreamAdapter.AdaptAsync(Stream(), "req-mixed", clock, CancellationToken.None))
+ {
+ events.Add(e);
+ }
+
+ events.Should().HaveCount(3);
+ events[0].Kind.Should().Be("delta");
+ events[0].Content.Should().Be("Let me check.");
+ events[1].Kind.Should().Be("tool_use");
+ events[1].ToolName.Should().Be("read_file");
+ events[2].Kind.Should().Be("completed");
+ }
+
+ [Fact]
+ public void AnthropicMessageBuilder_builds_messages_from_chat_history()
+ {
+ var messages = new[]
+ {
+ new ProtocolModels.ChatMessage("user", new[]
+ {
+ new ProtocolModels.ContentBlock(ProtocolModels.ContentBlockKind.Text, "Hello", null, null, null, null),
+ }),
+ new ProtocolModels.ChatMessage("assistant", new[]
+ {
+ new ProtocolModels.ContentBlock(ProtocolModels.ContentBlockKind.Text, "Hi there", null, null, null, null),
+ new ProtocolModels.ContentBlock(ProtocolModels.ContentBlockKind.ToolUse, null, "call-1", "get_time", "{}", null),
+ }),
+ new ProtocolModels.ChatMessage("user", new[]
+ {
+ new ProtocolModels.ContentBlock(ProtocolModels.ContentBlockKind.ToolResult, "12:00 PM", "call-1", null, null, false),
+ }),
+ };
+
+ var result = AnthropicMessageBuilder.BuildMessages(messages);
+
+ result.Should().HaveCount(3);
+ result[0].Role.Raw().Should().Be("user");
+ result[1].Role.Raw().Should().Be("assistant");
+ result[2].Role.Raw().Should().Be("user");
+ }
+
+ [Fact]
+ public void AnthropicMessageBuilder_builds_tools_from_definitions()
+ {
+ var definitions = new[]
+ {
+ new ProtocolModels.ProviderToolDefinition(
+ "read_file",
+ "Read the contents of a file.",
+ """{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}"""),
+ };
+
+ var result = AnthropicMessageBuilder.BuildTools(definitions);
+
+ result.Should().HaveCount(1);
+ result[0].TryPickTool(out var tool).Should().BeTrue();
+ tool!.Name.Should().Be("read_file");
+ tool.Description.Should().Be("Read the contents of a file.");
+ tool.InputSchema.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void AnthropicMessageBuilder_handles_null_input_schema_gracefully()
+ {
+ var definitions = new[]
+ {
+ new ProtocolModels.ProviderToolDefinition("noop", "Does nothing.", null),
+ };
+
+ var result = AnthropicMessageBuilder.BuildTools(definitions);
+
+ result.Should().HaveCount(1);
+ result[0].TryPickTool(out var tool).Should().BeTrue();
+ tool!.Name.Should().Be("noop");
+ tool.InputSchema.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void OpenAi_message_builder_maps_roles_correctly()
+ {
+ var messages = new[]
+ {
+ new ProtocolModels.ChatMessage("system", new[]
+ {
+ new ProtocolModels.ContentBlock(ProtocolModels.ContentBlockKind.Text, "You are a helpful assistant.", null, null, null, null),
+ }),
+ new ProtocolModels.ChatMessage("user", new[]
+ {
+ new ProtocolModels.ContentBlock(ProtocolModels.ContentBlockKind.Text, "Hello", null, null, null, null),
+ }),
+ new ProtocolModels.ChatMessage("assistant", new[]
+ {
+ new ProtocolModels.ContentBlock(ProtocolModels.ContentBlockKind.Text, "Hi there", null, null, null, null),
+ }),
+ };
+
+ var result = OpenAiMessageBuilder.BuildMessages(messages);
+
+ result.Should().HaveCount(3);
+ result[0].Role.Should().Be(Microsoft.Extensions.AI.ChatRole.System);
+ result[1].Role.Should().Be(Microsoft.Extensions.AI.ChatRole.User);
+ result[2].Role.Should().Be(Microsoft.Extensions.AI.ChatRole.Assistant);
+ }
+
+ [Fact]
+ public void OpenAi_message_builder_maps_tool_use_to_function_call_content()
+ {
+ var messages = new[]
+ {
+ new ProtocolModels.ChatMessage("assistant", new[]
+ {
+ new ProtocolModels.ContentBlock(ProtocolModels.ContentBlockKind.Text, "Let me check.", null, null, null, null),
+ new ProtocolModels.ContentBlock(ProtocolModels.ContentBlockKind.ToolUse, null, "call-99", "read_file", "{\"path\":\"src/main.cs\"}", null),
+ }),
+ };
+
+ var result = OpenAiMessageBuilder.BuildMessages(messages);
+
+ result.Should().HaveCount(1);
+ result[0].Role.Should().Be(Microsoft.Extensions.AI.ChatRole.Assistant);
+ result[0].Contents.Should().HaveCount(2);
+
+ var textItem = result[0].Contents[0];
+ textItem.Should().BeOfType();
+ ((Microsoft.Extensions.AI.TextContent)textItem).Text.Should().Be("Let me check.");
+
+ var callItem = result[0].Contents[1];
+ callItem.Should().BeOfType();
+ var functionCall = (Microsoft.Extensions.AI.FunctionCallContent)callItem;
+ functionCall.CallId.Should().Be("call-99");
+ functionCall.Name.Should().Be("read_file");
+ }
+
+ [Fact]
+ public void OpenAi_message_builder_maps_tool_result_to_function_result_content()
+ {
+ var messages = new[]
+ {
+ new ProtocolModels.ChatMessage("user", new[]
+ {
+ new ProtocolModels.ContentBlock(ProtocolModels.ContentBlockKind.ToolResult, "namespace Foo;", "call-99", null, null, false),
+ }),
+ };
+
+ var result = OpenAiMessageBuilder.BuildMessages(messages);
+
+ result.Should().HaveCount(1);
+ result[0].Role.Should().Be(Microsoft.Extensions.AI.ChatRole.User);
+ result[0].Contents.Should().HaveCount(1);
+
+ var resultItem = result[0].Contents[0];
+ resultItem.Should().BeOfType();
+ var functionResult = (Microsoft.Extensions.AI.FunctionResultContent)resultItem;
+ functionResult.CallId.Should().Be("call-99");
+ functionResult.Result.Should().Be("namespace Foo;");
+ }
+
+ [Fact]
+ public void OpenAi_message_builder_builds_tools_from_definitions()
+ {
+ var definitions = new[]
+ {
+ new ProtocolModels.ProviderToolDefinition(
+ "read_file",
+ "Read the contents of a file.",
+ """{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}"""),
+ };
+
+ var result = OpenAiMessageBuilder.BuildTools(definitions);
+
+ result.Should().HaveCount(1);
+ result[0].Should().NotBeNull();
+
+ var funcDecl = result[0] as Microsoft.Extensions.AI.AIFunctionDeclaration;
+ funcDecl.Should().NotBeNull();
+ funcDecl!.Name.Should().Be("read_file");
+ funcDecl.Description.Should().Be("Read the contents of a file.");
+ }
+
+ [Fact]
+ public void OpenAi_message_builder_handles_null_input_schema_for_tools()
+ {
+ var definitions = new[]
+ {
+ new ProtocolModels.ProviderToolDefinition("noop", "Does nothing.", null),
+ };
+
+ var result = OpenAiMessageBuilder.BuildTools(definitions);
+
+ result.Should().HaveCount(1);
+ var funcDecl = result[0] as Microsoft.Extensions.AI.AIFunctionDeclaration;
+ funcDecl.Should().NotBeNull();
+ funcDecl!.Name.Should().Be("noop");
+ }
+
+ ///
+ /// Creates a with all required members set, using the raw JSON deserialization path.
+ ///
+ private static ToolUseBlock MakeToolUseBlock(string id, string name)
+ {
+ var json = $"{{\"id\":\"{id}\",\"name\":\"{name}\",\"input\":{{}},\"type\":\"tool_use\",\"caller\":{{\"type\":\"direct\"}}}}";
+ using var doc = JsonDocument.Parse(json);
+ var rawData = doc.RootElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone());
+ return ToolUseBlock.FromRawUnchecked(rawData);
+ }
+
+ private sealed class FixedClock : ISystemClock
+ {
+ public DateTimeOffset UtcNow => DateTimeOffset.Parse("2026-04-08T00:00:00Z", CultureInfo.InvariantCulture);
+ }
+}
diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/ContextWindowManagerTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/ContextWindowManagerTests.cs
new file mode 100644
index 0000000..8167e3f
--- /dev/null
+++ b/tests/SharpClaw.Code.UnitTests/Runtime/ContextWindowManagerTests.cs
@@ -0,0 +1,117 @@
+using FluentAssertions;
+using SharpClaw.Code.Protocol.Models;
+using SharpClaw.Code.Runtime.Context;
+
+namespace SharpClaw.Code.UnitTests.Runtime;
+
+///
+/// Verifies that correctly trims conversation history
+/// to satisfy a token budget.
+///
+public sealed class ContextWindowManagerTests
+{
+ // ── Helpers ─────────────────────────────────────────────────────────────
+
+ private static ChatMessage UserMessage(string text) =>
+ new("user", [new ContentBlock(ContentBlockKind.Text, text, null, null, null, null)]);
+
+ private static ChatMessage AssistantMessage(string text) =>
+ new("assistant", [new ContentBlock(ContentBlockKind.Text, text, null, null, null, null)]);
+
+ private static ChatMessage SystemMessage(string text) =>
+ new("system", [new ContentBlock(ContentBlockKind.Text, text, null, null, null, null)]);
+
+ // ── Tests ────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Returns_all_when_within_budget()
+ {
+ var messages = new ChatMessage[]
+ {
+ UserMessage("Hello"),
+ AssistantMessage("Hi there"),
+ UserMessage("How are you?"),
+ AssistantMessage("I am well."),
+ };
+
+ // Budget is very generous — all messages should be returned.
+ var result = ContextWindowManager.Truncate(messages, 10_000);
+
+ result.Should().HaveCount(4);
+ }
+
+ [Fact]
+ public void Truncates_oldest_messages_when_over_budget()
+ {
+ // Each message has ~400 chars → ~100 tokens.
+ // 10 messages → ~1000 tokens total. Budget: 500 tokens → expect roughly half.
+ var filler = new string('x', 400);
+ var messages = Enumerable.Range(0, 10)
+ .Select(i => i % 2 == 0 ? UserMessage($"{i}: {filler}") : AssistantMessage($"{i}: {filler}"))
+ .ToArray();
+
+ var result = ContextWindowManager.Truncate(messages, 500);
+
+ // Should keep fewer messages than the original.
+ result.Length.Should().BeLessThan(messages.Length);
+
+ // The most recent message must be present.
+ result[^1].Should().Be(messages[^1]);
+ }
+
+ [Fact]
+ public void Always_keeps_system_message()
+ {
+ var systemMsg = SystemMessage("You are a helpful assistant.");
+ var filler = new string('x', 2000); // ~500 tokens each
+
+ var messages = new ChatMessage[]
+ {
+ systemMsg,
+ UserMessage(filler),
+ AssistantMessage(filler),
+ UserMessage(filler),
+ AssistantMessage(filler),
+ };
+
+ // Very tight budget — only slightly above system message cost.
+ var systemTokens = systemMsg.Content.Sum(b => (b.Text?.Length ?? 0)) / 4;
+ var budget = systemTokens + 10; // small extra room
+
+ var result = ContextWindowManager.Truncate(messages, budget);
+
+ result.Should().Contain(m => m.Role == "system");
+ }
+
+ [Fact]
+ public void Always_keeps_most_recent_message_even_under_extreme_budget()
+ {
+ var messages = new ChatMessage[]
+ {
+ UserMessage(new string('a', 4000)),
+ AssistantMessage(new string('b', 4000)),
+ UserMessage("final question"),
+ };
+
+ // Budget too small for anything substantial.
+ var result = ContextWindowManager.Truncate(messages, 1);
+
+ result.Should().NotBeEmpty();
+ result[^1].Should().Be(messages[^1]);
+ }
+
+ [Fact]
+ public void Returns_empty_for_empty_input()
+ {
+ var result = ContextWindowManager.Truncate([], 1000);
+
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Throws_for_non_positive_budget()
+ {
+ var act = () => ContextWindowManager.Truncate([UserMessage("hi")], 0);
+ act.Should().Throw();
+ }
+}
diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/ConversationHistoryAssemblerTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/ConversationHistoryAssemblerTests.cs
new file mode 100644
index 0000000..efb59f4
--- /dev/null
+++ b/tests/SharpClaw.Code.UnitTests/Runtime/ConversationHistoryAssemblerTests.cs
@@ -0,0 +1,181 @@
+using FluentAssertions;
+using SharpClaw.Code.Protocol.Enums;
+using SharpClaw.Code.Protocol.Events;
+using SharpClaw.Code.Protocol.Models;
+using SharpClaw.Code.Runtime.Context;
+
+namespace SharpClaw.Code.UnitTests.Runtime;
+
+///
+/// Verifies that correctly maps session
+/// runtime events to ordered pairs.
+///
+public sealed class ConversationHistoryAssemblerTests
+{
+ private static readonly DateTimeOffset Now = DateTimeOffset.UtcNow;
+
+ // ── Helpers ─────────────────────────────────────────────────────────────
+
+ private static ConversationTurn MakeTurn(string turnId, string input, string? output = null) =>
+ new(
+ Id: turnId,
+ SessionId: "session-1",
+ SequenceNumber: 1,
+ Input: input,
+ Output: output,
+ StartedAtUtc: Now,
+ CompletedAtUtc: Now,
+ AgentId: null,
+ SlashCommandName: null,
+ Usage: null,
+ Metadata: null);
+
+ private static TurnStartedEvent MakeStarted(string turnId, string input) =>
+ new(
+ EventId: $"evt-started-{turnId}",
+ SessionId: "session-1",
+ TurnId: turnId,
+ OccurredAtUtc: Now,
+ Turn: MakeTurn(turnId, input));
+
+ private static TurnCompletedEvent MakeCompleted(string turnId, string input, string summary) =>
+ new(
+ EventId: $"evt-completed-{turnId}",
+ SessionId: "session-1",
+ TurnId: turnId,
+ OccurredAtUtc: Now,
+ Turn: MakeTurn(turnId, input),
+ Succeeded: true,
+ Summary: summary);
+
+ // ── Tests ────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Returns_empty_for_no_events()
+ {
+ var result = ConversationHistoryAssembler.Assemble([]);
+
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Skips_incomplete_turns()
+ {
+ // Only a started event, no completed event.
+ var events = new RuntimeEvent[]
+ {
+ MakeStarted("turn-1", "Hello"),
+ };
+
+ var result = ConversationHistoryAssembler.Assemble(events);
+
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Assembles_user_assistant_pairs_from_completed_turns()
+ {
+ var events = new RuntimeEvent[]
+ {
+ MakeStarted("turn-1", "What is 2+2?"),
+ MakeCompleted("turn-1", "What is 2+2?", "The answer is 4."),
+ MakeStarted("turn-2", "What about 3+3?"),
+ MakeCompleted("turn-2", "What about 3+3?", "The answer is 6."),
+ };
+
+ var result = ConversationHistoryAssembler.Assemble(events);
+
+ result.Should().HaveCount(4);
+
+ result[0].Role.Should().Be("user");
+ result[0].Content.Should().ContainSingle(b => b.Text == "What is 2+2?");
+
+ result[1].Role.Should().Be("assistant");
+ result[1].Content.Should().ContainSingle(b => b.Text == "The answer is 4.");
+
+ result[2].Role.Should().Be("user");
+ result[2].Content.Should().ContainSingle(b => b.Text == "What about 3+3?");
+
+ result[3].Role.Should().Be("assistant");
+ result[3].Content.Should().ContainSingle(b => b.Text == "The answer is 6.");
+ }
+
+ [Fact]
+ public void Uses_provider_deltas_when_summary_is_null()
+ {
+ var completedNoSummary = new TurnCompletedEvent(
+ EventId: "evt-completed-turn-1",
+ SessionId: "session-1",
+ TurnId: "turn-1",
+ OccurredAtUtc: Now,
+ Turn: MakeTurn("turn-1", "Hello"),
+ Succeeded: true,
+ Summary: null);
+
+ var delta1 = new ProviderDeltaEvent(
+ EventId: "evt-delta-1",
+ SessionId: "session-1",
+ TurnId: "turn-1",
+ OccurredAtUtc: Now,
+ ProviderName: "test-provider",
+ Model: "test-model",
+ ProviderEventId: "p1",
+ Kind: "text",
+ Content: "Hello ");
+
+ var delta2 = new ProviderDeltaEvent(
+ EventId: "evt-delta-2",
+ SessionId: "session-1",
+ TurnId: "turn-1",
+ OccurredAtUtc: Now,
+ ProviderName: "test-provider",
+ Model: "test-model",
+ ProviderEventId: "p2",
+ Kind: "text",
+ Content: "world.");
+
+ var events = new RuntimeEvent[]
+ {
+ MakeStarted("turn-1", "Hello"),
+ delta1,
+ delta2,
+ completedNoSummary,
+ };
+
+ var result = ConversationHistoryAssembler.Assemble(events);
+
+ result.Should().HaveCount(2);
+ result[1].Role.Should().Be("assistant");
+ result[1].Content.Should().ContainSingle(b => b.Text == "Hello world.");
+ }
+
+ [Fact]
+ public void Skips_events_without_turn_id()
+ {
+ // Session-level event with no TurnId should be silently ignored.
+ var sessionCreated = new SessionCreatedEvent(
+ EventId: "evt-session",
+ SessionId: "session-1",
+ TurnId: null,
+ OccurredAtUtc: Now,
+ Session: new ConversationSession(
+ Id: "session-1",
+ Title: "Test",
+ State: SessionLifecycleState.Active,
+ PermissionMode: PermissionMode.WorkspaceWrite,
+ OutputFormat: OutputFormat.Text,
+ WorkingDirectory: ".",
+ RepositoryRoot: null,
+ CreatedAtUtc: Now,
+ UpdatedAtUtc: Now,
+ ActiveTurnId: null,
+ LastCheckpointId: null,
+ Metadata: null));
+
+ var events = new RuntimeEvent[] { sessionCreated };
+
+ var result = ConversationHistoryAssembler.Assemble(events);
+
+ result.Should().BeEmpty();
+ }
+}
diff --git a/tests/SharpClaw.Code.UnitTests/SharpClaw.Code.UnitTests.csproj b/tests/SharpClaw.Code.UnitTests/SharpClaw.Code.UnitTests.csproj
index 9148f5c..0e1e111 100644
--- a/tests/SharpClaw.Code.UnitTests/SharpClaw.Code.UnitTests.csproj
+++ b/tests/SharpClaw.Code.UnitTests/SharpClaw.Code.UnitTests.csproj
@@ -20,6 +20,7 @@
+