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 @@ +