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/MONETIZATION.md b/docs/MONETIZATION.md new file mode 100644 index 0000000..ba442e6 --- /dev/null +++ b/docs/MONETIZATION.md @@ -0,0 +1,424 @@ +# SharpClaw Code — Monetization Strategy + +## Document Info + +| Field | Value | +|-------|-------| +| Status | Draft | +| Author | telli | +| Date | 2026-04-10 | +| Model | Open Core | + +--- + +## 1. Strategy Summary + +SharpClaw Code follows an **open-core model** with phased revenue introduction: + +| Phase | Focus | Revenue | Timeline | +|-------|-------|---------|----------| +| **Phase 1** | Adoption & category ownership | $0 (investment phase) | Months 0-12 | +| **Phase 2** | Enterprise & ISV monetization | Enterprise licenses + support | Months 6-18 | +| **Phase 3** | Consumer & ecosystem monetization | Pro tier + marketplace | Months 12-24 | + +The core runtime remains MIT-licensed permanently. Revenue comes from proprietary extensions, managed services, and ecosystem fees that serve segments willing to pay for capabilities the open-source core intentionally doesn't include. + +--- + +## 2. What Stays Free (Forever) + +The open-source core includes everything needed to build and run a coding agent: + +| Capability | Package | +|------------|---------| +| All protocol contracts and DTOs | `SharpClaw.Code.Protocol` | +| Full runtime orchestration | `SharpClaw.Code.Runtime` | +| Anthropic and OpenAI-compatible providers | `SharpClaw.Code.Providers.*` | +| Built-in tools (read, write, edit, grep, glob, bash) | `SharpClaw.Code.Tools` | +| Permission policy engine and approval gates | `SharpClaw.Code.Permissions` | +| Session persistence (file-backed) | `SharpClaw.Code.Sessions` | +| MCP client integration | `SharpClaw.Code.Mcp` | +| Plugin system | `SharpClaw.Code.Plugins` | +| Structured telemetry and event publishing | `SharpClaw.Code.Telemetry` | +| CLI with REPL, slash commands, JSON output | `SharpClaw.Code.Cli` | +| Spec workflow mode | `SharpClaw.Code.Runtime` | +| All documentation and examples | `docs/` | + +**Principle:** A single developer or small team should never hit a paywall for core agent functionality. The free tier must be genuinely useful, not a crippled demo. + +--- + +## 3. Phase 1 — Investment Phase (Months 0-12) + +### Revenue: $0 + +### Goal: Category Ownership + +All effort goes into adoption, community, and establishing SharpClaw as the default .NET agent runtime. + +### Investment Activities + +| Activity | Purpose | Cost | +|----------|---------|------| +| NuGet package publishing | Frictionless adoption | CI/CD time | +| Documentation + tutorials | Reduce time-to-value | Author time | +| Conference talks / blog posts | .NET community visibility | Travel + time | +| Discord community | Developer engagement | Moderation time | +| GitHub Sponsors | Signal legitimacy, collect early support | $0 cost | +| "Built with SharpClaw" showcase | Social proof | Curation time | + +### Early Revenue Signals (Not Revenue) + +- **GitHub Sponsors:** Accept individual and corporate sponsorships. Not a business model, but validates willingness to pay and builds a mailing list of engaged users. +- **Consulting:** Offer paid architecture reviews for teams adopting SharpClaw. This generates revenue, but more importantly surfaces enterprise requirements for Phase 2. +- **Training workshops:** Paid half-day workshops ("Building Production Agents with SharpClaw") at .NET conferences. Revenue is modest but builds authority. + +**Target:** $5K-$15K in consulting/workshop revenue. Primary purpose is learning, not profit. + +--- + +## 4. Phase 2 — Enterprise & ISV Monetization (Months 6-18) + +### Revenue Target: $10K-$50K MRR by month 18 + +### 4.1 Tier Structure + +#### Free (Open Source) + +Everything in the MIT-licensed core. No limits, no telemetry, no registration required. + +#### Team — $500/month (per organization) + +For teams embedding SharpClaw in internal tools or products with <50 users. + +| Feature | Description | +|---------|-------------| +| **Priority support** | 48-hour response SLA via dedicated channel | +| **Office hours** | Monthly group call with maintainers | +| **Early access** | Pre-release builds and roadmap input | +| **Logo rights** | "Powered by SharpClaw" badge for marketing | + +**Why teams pay:** Support SLA and early access. These teams have adopted the open-source runtime and need confidence they won't get stuck. + +#### Enterprise — $2,500/month (per organization) + +For organizations running SharpClaw in production with compliance, scale, or multi-team requirements. + +| Feature | Description | +|---------|-------------| +| Everything in Team | — | +| **Multi-tenant session store** | Pluggable session backends (Azure CosmosDB, SQL Server, PostgreSQL) with tenant isolation | +| **Enterprise SSO integration** | Microsoft Entra ID, SAML, and OIDC for approval workflows — tie permission approvals to corporate identity | +| **Audit log export** | Compliance-ready export of all tool executions, approvals, and provider calls | +| **Advanced telemetry sinks** | Azure Monitor, OpenTelemetry, Datadog, and Splunk exporters | +| **Session encryption at rest** | AES-256 encryption for session snapshots and event logs | +| **Role-based access control** | Define who can approve dangerous operations, manage MCP servers, install plugins | +| **Dedicated support** | 8-hour response SLA, named account engineer | +| **Custom SLA** | Uptime and response guarantees | + +**Why enterprises pay:** Compliance (audit logs, encryption, SSO), scale (multi-tenant), and support guarantees. These are table-stakes for enterprise procurement. + +#### ISV / OEM — Custom pricing + +For companies embedding SharpClaw as the runtime inside a commercial product. + +| Feature | Description | +|---------|-------------| +| Everything in Enterprise | — | +| **White-label rights** | Remove SharpClaw branding from end-user surfaces | +| **Embedded runtime SDK** | Optimized for hosting inside ASP.NET Core, worker services, or desktop apps | +| **Usage metering API** | Per-tenant token and tool usage tracking for ISV billing | +| **Custom provider integration** | Assistance building proprietary model provider adapters | +| **Roadmap influence** | Direct input on feature prioritization | +| **Indemnification** | IP indemnity for the proprietary components | + +**Pricing model:** Base platform fee ($5K-$15K/month) + per-seat or per-usage component negotiated per deal. + +### 4.2 What's Proprietary vs. Open Source + +The boundary is drawn at a clear principle: **the open-source core runs a single-user, single-workspace agent with full functionality. Proprietary features serve multi-user, multi-tenant, compliance, and enterprise-scale needs.** + +| Capability | Open Source | Proprietary | +|------------|:-----------:|:-----------:| +| Runtime orchestration | x | | +| File-backed session storage | x | | +| SQL/cloud session backends | | x | +| Permission policy engine | x | | +| SSO-backed approval workflows | | x | +| Structured telemetry (ring buffer) | x | | +| OpenTelemetry/Datadog/Splunk sinks | | x | +| Event log (NDJSON) | x | | +| Compliance audit export | | x | +| Session encryption at rest | | x | +| Single-workspace MCP | x | | +| Multi-tenant MCP orchestration | | x | +| Plugin system | x | | +| Plugin marketplace hosting | | x | +| CLI + REPL | x | | +| Admin REST API | | x | +| Role-based access control | | x | +| Usage metering | | x | + +### 4.3 Packaging the Proprietary Extensions + +Proprietary features ship as separate NuGet packages under a commercial license: + +``` +SharpClaw.Code.Enterprise.Sessions.CosmosDb +SharpClaw.Code.Enterprise.Sessions.SqlServer +SharpClaw.Code.Enterprise.Telemetry.AzureMonitor +SharpClaw.Code.Enterprise.Telemetry.OpenTelemetry +SharpClaw.Code.Enterprise.Telemetry.Datadog +SharpClaw.Code.Enterprise.Auth.EntraId +SharpClaw.Code.Enterprise.Auth.Oidc +SharpClaw.Code.Enterprise.Audit +SharpClaw.Code.Enterprise.Encryption +SharpClaw.Code.Enterprise.Admin +``` + +These packages depend on the open-source core and plug in via the existing DI extension pattern (`services.AddSharpClawEnterpriseSessions(configuration)`). No fork, no separate build — enterprise customers add packages and configure. + +**License enforcement:** Package-level license key validation at startup. No runtime phone-home; offline validation with periodic renewal. + +--- + +## 5. Phase 3 — Consumer & Ecosystem Monetization (Months 12-24) + +### Revenue Target: $50K-$150K MRR by month 24 + +### 5.1 SharpClaw Pro — $20/month (individual) + +A personal tier for developers using SharpClaw as their daily coding agent. + +| Feature | Description | +|---------|-------------| +| **IDE extensions** | VS Code and Rider extensions with rich integration | +| **Cross-session memory** | Persistent project knowledge that survives session boundaries | +| **Workspace indexing** | Semantic code search, symbol navigation, dependency graph | +| **Priority model routing** | Automatic provider selection optimized for cost/quality/speed | +| **Session sync** | Sync sessions across machines via cloud storage | +| **Custom slash commands** | Visual editor for creating and sharing custom commands | +| **Pro badge** | Community recognition in Discord and GitHub | + +**Why individuals pay:** The free CLI is fully functional. Pro adds convenience and power-user features that save time daily. The $20 price point is impulse-buy territory for professional developers. + +### 5.2 Plugin Marketplace + +A curated registry where third parties publish and optionally sell SharpClaw plugins. + +| Revenue Stream | Model | +|----------------|-------| +| **Free plugins** | Listed for free; drives ecosystem growth | +| **Paid plugins** | 70/30 revenue split (developer/SharpClaw) | +| **Verified publisher** | $99/year badge for trust signal | +| **Featured placement** | $500/month for homepage visibility | + +**Marketplace economics:** The marketplace is a flywheel — more plugins attract more users, which attract more plugin developers. The 30% take rate is standard (Apple, Stripe, etc.) and funds curation, security review, and infrastructure. + +### 5.3 Managed Cloud (Optional) + +A hosted SharpClaw runtime for teams that don't want to self-host. + +| Tier | Price | Includes | +|------|-------|----------| +| **Starter** | $99/month | 5 seats, 100K tokens/month, file-backed sessions | +| **Growth** | $499/month | 25 seats, 1M tokens/month, SQL-backed sessions, SSO | +| **Scale** | $1,999/month | Unlimited seats, 10M tokens/month, full enterprise features | + +**Build-or-buy decision:** The managed cloud is the highest-effort revenue stream. It requires infrastructure, ops, and support investment. Consider partnering with a hosting provider (Azure, Railway) rather than building from scratch. Evaluate at month 12 based on demand signals. + +--- + +## 6. Pricing Philosophy + +### Principles + +1. **Free must be genuinely useful.** A developer should be able to build and ship a real product on the free tier. If the free tier feels crippled, adoption dies. + +2. **Paid tiers solve real problems the free tier can't.** Enterprise features (compliance, multi-tenancy, SSO) are genuinely different requirements, not artificial limitations. + +3. **Price on value, not cost.** The Enterprise tier costs $2,500/month but saves an enterprise team months of building session backends, audit infrastructure, and SSO integration. + +4. **No per-seat pricing on the core runtime.** Per-seat pricing on an open-source runtime feels extractive. Charge per organization, not per developer. + +5. **Annual discounts.** 20% discount for annual commitment (Team: $4,800/year, Enterprise: $24,000/year). Improves cash flow predictability. + +### Competitive Pricing Context + +| Competitor | Pricing | SharpClaw Comparison | +|------------|---------|---------------------| +| GitHub Copilot Business | $19/user/month | SharpClaw Pro at $20/month is comparable but includes the full runtime | +| Cursor Business | $40/user/month | SharpClaw is cheaper and open-source at the core | +| Semantic Kernel | Free (framework only) | SharpClaw builds on Agent Framework with production runtime; complementary, not competing | +| LangChain / LangSmith | Free core + $39/user for platform | Similar open-core model; SharpClaw's .NET/Agent Framework foundation is differentiated | + +### Microsoft Partnership Value + +The monetization strategy is designed to be **friendly to Microsoft's ecosystem interests**: + +- **Free tier grows Agent Framework adoption.** Every SharpClaw user is a `Microsoft.Agents.AI` NuGet consumer. Microsoft benefits from SharpClaw's success. +- **Enterprise tier drives Azure alignment.** Multi-tenant session backends (CosmosDB, SQL Server), Azure Monitor telemetry sinks, and Entra ID SSO integration all drive Azure consumption. +- **No competition with Microsoft's own monetization.** SharpClaw monetizes the runtime/operational layer. Microsoft monetizes Azure infrastructure and AI model APIs. The incentives are aligned. +- **Co-marketing reduces CAC.** If Microsoft features SharpClaw in Agent Framework docs or conference talks, customer acquisition cost for all tiers drops significantly. + +--- + +## 7. Revenue Projections + +### Conservative Scenario + +| Month | Phase | Free Users | Team Orgs | Enterprise Orgs | Pro Users | MRR | +|-------|-------|-----------|-----------|-----------------|-----------|-----| +| 6 | 1 | 500 | 0 | 0 | 0 | $0 | +| 12 | 1-2 | 2,000 | 5 | 1 | 0 | $5,000 | +| 18 | 2 | 5,000 | 15 | 5 | 100 | $22,000 | +| 24 | 2-3 | 10,000 | 25 | 10 | 500 | $47,500 | + +### Optimistic Scenario + +| Month | Phase | Free Users | Team Orgs | Enterprise Orgs | Pro Users | MRR | +|-------|-------|-----------|-----------|-----------------|-----------|-----| +| 6 | 1 | 1,500 | 0 | 0 | 0 | $0 | +| 12 | 1-2 | 5,000 | 10 | 3 | 0 | $12,500 | +| 18 | 2 | 15,000 | 30 | 10 | 500 | $50,000 | +| 24 | 2-3 | 30,000 | 50 | 20 | 2,000 | $115,000 | + +### Revenue Mix at Month 24 (Conservative) + +``` +Team: $12,500 (26%) +Enterprise: $25,000 (53%) +Pro: $10,000 (21%) +───────────────────────── +Total MRR: $47,500 +ARR: $570,000 +``` + +--- + +## 8. Go-to-Market Channels + +### Phase 1 (Adoption) + +| Channel | Action | Expected Impact | +|---------|--------|-----------------| +| **NuGet** | Publish packages with clear README and getting-started | Primary discovery channel for .NET developers | +| **GitHub** | Optimize repo (README, badges, examples, issue templates) | Social proof and contribution funnel | +| **Microsoft partnership** | Engage Agent Framework team; offer SharpClaw as reference implementation | Ecosystem credibility, docs listing, co-marketing | +| **.NET blogs** | "Building a production coding agent on Microsoft Agent Framework" | Direct reach to target persona, friendly to Microsoft | +| **Conference talks** | .NET Conf, NDC, Build — joint sessions with Agent Framework team if possible | Authority positioning | +| **Discord** | Developer community with channels for help, showcase, RFC | Engagement and retention | +| **Twitter/X + Bluesky** | Regular updates, demos, Agent Framework integration highlights | Awareness | +| **YouTube** | "Build an agent in 15 minutes with Agent Framework + SharpClaw" | Long-tail discovery | + +### Phase 2 (Enterprise) + +| Channel | Action | +|---------|--------| +| **Direct outreach** | Identify companies using Semantic Kernel for agent work; offer migration assistance | +| **Case studies** | Publish 2-3 production deployment stories | +| **Partner program** | .NET consultancies who recommend SharpClaw to enterprise clients | +| **Enterprise landing page** | Separate page with compliance, security, and ROI messaging | + +### Phase 3 (Consumer) + +| Channel | Action | +|---------|--------| +| **VS Code Marketplace** | IDE extension as primary distribution | +| **Product Hunt launch** | Consumer awareness burst | +| **Developer influencers** | Sponsored reviews and walkthroughs | +| **Plugin marketplace** | Self-reinforcing ecosystem growth | + +--- + +## 9. Cost Structure + +### Phase 1 (Months 0-12) + +| Item | Monthly Cost | Notes | +|------|-------------|-------| +| GitHub Actions CI/CD | $0-50 | Free tier covers most OSS needs | +| NuGet hosting | $0 | Free for public packages | +| Domain + hosting (docs site) | $20 | Static site on Cloudflare/Vercel | +| Discord (Nitro for branding) | $10 | Optional | +| Conference travel (amortized) | $500 | 2-3 conferences per year | +| **Total** | **~$580/month** | | + +### Phase 2 (Months 6-18) + +| Item | Monthly Cost | Notes | +|------|-------------|-------| +| Phase 1 costs | $580 | Continuing | +| License server infrastructure | $100 | Simple key validation service | +| Enterprise package CI/CD | $200 | Private build pipelines | +| Support tooling (Intercom/Linear) | $200 | Customer communication | +| Part-time support engineer | $3,000 | Contractor or part-time hire | +| **Total** | **~$4,080/month** | | + +### Breakeven + +At conservative projections, MRR exceeds costs by **month 14-15** (Enterprise tier covers the burn). + +--- + +## 10. Key Risks to Monetization + +| Risk | Mitigation | +|------|------------| +| Enterprise features built by community (defeating proprietary value) | Keep proprietary features integration-heavy (Azure backends, Entra ID, audit export) — hard to replicate without infrastructure | +| Microsoft builds competing production layer into Agent Framework | Stay close to the team, contribute upstream, position as community complement. If they build it, pivot to hosting/tooling layer. The incentive alignment (SharpClaw drives Agent Framework adoption) makes this unlikely | +| .NET agent market doesn't materialize | Phase 1 positioning as production runtime layer doesn't require a large agent-specific market | +| Free tier is too good, nobody upgrades | Monitor conversion at Phase 2 launch; adjust boundary if needed, but err on the side of generous free tier | +| Price resistance at $2,500/month for Enterprise | Offer quarterly billing and proof-of-value pilots (30-day trial with migration assistance) | +| Microsoft relationship doesn't materialize | Product stands alone regardless; Microsoft alignment is accelerant, not dependency | + +--- + +## 11. Microsoft Partnership Strategy + +SharpClaw's success is accelerated by — but not dependent on — a strong relationship with the Microsoft Agent Framework team. The strategy is to make SharpClaw so useful to Microsoft's ecosystem goals that partnership is a natural outcome. + +### Why Microsoft Benefits + +| Microsoft Goal | How SharpClaw Helps | +|---------------|---------------------| +| Agent Framework adoption | Every SharpClaw user downloads `Microsoft.Agents.AI` from NuGet | +| Azure consumption | Enterprise tier drives CosmosDB, Azure Monitor, Entra ID, and Azure OpenAI usage | +| .NET ecosystem competitiveness | SharpClaw demonstrates .NET is a first-class platform for AI agents (vs. Python/TypeScript narrative) | +| Agent Framework credibility | A production-grade, open-source project validates the framework for real workloads | +| Community engagement | SharpClaw's contributor community feeds Agent Framework issue reports, feature requests, and real-world usage patterns | + +### Partnership Playbook + +| Timeline | Action | Ask | +|----------|--------|-----| +| **Month 1** | File well-crafted Agent Framework issues from real SharpClaw usage | Build visibility with the team | +| **Month 2** | Publish blog post: "Building a Production Agent Runtime on Microsoft Agent Framework" | Ask to be retweeted / shared by .NET team accounts | +| **Month 3** | Submit PR to Agent Framework docs: "Community Projects" section featuring SharpClaw | Get listed in official docs | +| **Month 4** | Propose .NET Conf community talk: "From Agent Framework to Production" | Conference visibility | +| **Month 6** | Request meeting with Agent Framework PM to share usage data and feature requests | Establish direct relationship | +| **Month 8** | Propose joint blog post or case study | Co-marketing | +| **Month 12** | Explore Microsoft for Startups or ISV partnership program | Formal partnership, potential Azure credits | + +### What Not to Do + +- Don't position against Semantic Kernel or AutoGen — they serve different use cases +- Don't ask for special treatment or early access before proving value +- Don't depend on Microsoft for distribution — NuGet and GitHub are the primary channels +- Don't build on unstable Agent Framework APIs — pin to released versions +- Don't gate features behind Microsoft-specific infrastructure — Azure backends are one option, not the only option + +--- + +## 12. Decision Log + +| Decision | Rationale | Date | +|----------|-----------|------| +| MIT license for core | Already shipped; builds trust; maximizes adoption | 2026-04-10 | +| Open-core model | Best fit for MIT base + enterprise upsell | 2026-04-10 | +| No per-seat pricing on core | Feels extractive for OSS runtime; charge per org instead | 2026-04-10 | +| Phase 1 = $0 revenue | Category ownership > early revenue; consulting covers costs | 2026-04-10 | +| Enterprise features as separate NuGet packages | Clean separation; no fork; existing DI patterns | 2026-04-10 | +| Managed cloud as Phase 3 optional | High effort; evaluate based on demand signals at month 12 | 2026-04-10 | +| Position as Agent Framework complement, not competitor | Microsoft partnership accelerates all phases; aligned incentives (SharpClaw drives AF adoption, enterprise tier drives Azure consumption) | 2026-04-10 | +| Azure-first enterprise backends (CosmosDB, Entra ID, Azure Monitor) | Aligns with Microsoft ecosystem; enterprise .NET teams are already on Azure | 2026-04-10 | diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..875e315 --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,323 @@ +# SharpClaw Code — Product Requirements Document + +## Document Info + +| Field | Value | +|-------|-------| +| Status | Draft | +| Author | telli | +| Date | 2026-04-10 | +| Version | 1.0 | + +--- + +## 1. Vision + +SharpClaw Code is the production-grade, open-source .NET runtime for building and operating AI coding agents. Built on Microsoft Agent Framework, it adds the durability, permission, and operational layers that turn agent prototypes into shippable products. + +**One-liner:** The production runtime that makes Microsoft Agent Framework real for coding agent workloads. + +--- + +## 2. Strategic Positioning + +### Phase 1 — The Production .NET Agent Runtime (Months 0-12) + +**Beachhead:** .NET developers adopting Microsoft Agent Framework who need production-grade runtime capabilities beyond what the framework provides out of the box. + +**Positioning:** "SharpClaw Code is the production runtime built on Microsoft Agent Framework. The framework gives you agent abstractions and provider integration — SharpClaw adds durable sessions, permission enforcement, MCP lifecycle management, structured telemetry, and an operational CLI surface. Together, they're what you need to ship a real coding agent." + +**Relationship to the Microsoft ecosystem:** + +SharpClaw Code is a **complement to Microsoft Agent Framework**, not a competitor. The relationship is analogous to how ASP.NET Core provides the web framework and tools like MassTransit or Wolverine add production messaging patterns on top. + +| Layer | Microsoft Provides | SharpClaw Adds | +|-------|-------------------|----------------| +| Agent abstractions | Agent kernel, activity protocol | Coding-agent-specific orchestration (turns, context assembly) | +| Provider integration | Multi-provider interfaces | Provider resilience, auth preflight, streaming adapters | +| Tool execution | Tool invocation primitives | Permission policy engine, approval gates, workspace boundaries | +| Session state | In-memory by default | Durable snapshots, append-only event logs, checkpoints, undo/redo | +| MCP | — | First-class registration, supervision, health checks, lifecycle state | +| Plugin system | — | Manifest-based discovery, trust levels, out-of-process execution | +| Telemetry | Standard .NET logging | Structured event-first ring buffer, JSON export, usage tracking | +| CLI surface | — | Full REPL, slash commands, spec mode, JSON output | +| Testing | — | Deterministic mock provider, parity harness, named scenarios | + +**Why Microsoft should care:** SharpClaw is one of the most complete open-source projects built on Agent Framework. It demonstrates that the framework is production-viable for complex workloads, drives NuGet downloads of `Microsoft.Agents.AI`, and provides a reference architecture that other teams can learn from. + +**Co-marketing opportunities:** +- Featured in Microsoft Agent Framework documentation as a reference implementation +- Joint blog posts: "Building a Production Coding Agent with Microsoft Agent Framework and SharpClaw" +- .NET Conf / Build talk: "From Agent Framework to Production — Lessons from SharpClaw Code" +- Listed in the Agent Framework ecosystem / community projects page +- Collaboration on Agent Framework feature requests informed by real-world SharpClaw usage + +### Phase 2 — Build Your Own Coding Agent (Months 6-18) + +**Expand to:** Startups and ISVs building coding agent products who need an embeddable runtime on the Microsoft stack. + +**Positioning:** "Ship your AI coding assistant in weeks, not months. SharpClaw Code brings Microsoft Agent Framework to production — you provide the experience." + +**New capabilities required:** +- Embeddable runtime SDK (no CLI dependency) +- Multi-tenant session isolation +- Custom tool SDK with packaging and distribution +- White-label provider configuration +- Webhooks and event streaming for external integrations + +**Microsoft alignment:** Position SharpClaw as the go-to path for ISVs adopting Agent Framework. Enterprise customers already on Azure and .NET get a runtime that fits their existing stack, identity, and compliance infrastructure. + +### Phase 3 — The Open-Source Coding Agent for .NET Teams (Months 12-24) + +**Expand to:** Individual .NET developers who want a local coding agent built on familiar Microsoft technologies. + +**Positioning:** "A coding agent built on the Microsoft stack, for the Microsoft stack. Open-source, runs locally, respects your workspace." + +**New capabilities required:** +- Polished interactive CLI experience (auto-complete, rich rendering) +- IDE integrations (VS Code extension, Rider plugin) +- Local model support (Ollama, llama.cpp via OpenAI-compatible provider) +- Workspace indexing and semantic code search +- Conversation memory across sessions + +**Microsoft alignment:** The consumer agent demonstrates that Agent Framework powers real end-user experiences, not just enterprise backends. Opportunity for joint promotion as "the open-source coding agent for the .NET ecosystem." + +--- + +## 3. Target Users + +### Phase 1 Users + +| Persona | Description | Pain Point | +|---------|-------------|------------| +| **Platform Engineer** | Building internal AI developer tools at a mid-to-large .NET shop | Agent Framework provides abstractions but not sessions, permissions, or audit — building that from scratch | +| **.NET Tech Lead** | Evaluating how to take Agent Framework to production | Needs durability, testing, observability — production characteristics beyond the framework primitives | +| **DevTools Startup Founder** | Building an AI-powered code review / generation tool on the Microsoft stack | Needs a runtime on top of Agent Framework so they can focus on their product, not infrastructure | +| **Microsoft Agent Framework Team** | Growing the Agent Framework ecosystem | Needs visible, high-quality projects that demonstrate the framework's production viability | + +### Phase 2 Users + +| Persona | Description | Pain Point | +|---------|-------------|------------| +| **ISV Product Manager** | Shipping a coding agent as part of a larger product | Needs embeddable runtime with multi-tenancy, not a CLI tool | +| **Enterprise Architect** | Standardizing agent infrastructure across teams | Needs governance, audit trails, and permission enforcement at scale | + +### Phase 3 Users + +| Persona | Description | Pain Point | +|---------|-------------|------------| +| **.NET Developer** | Wants a local coding agent that works well with C# / .NET projects | Existing agents (Claude Code, Cursor) are JS/Python-centric; poor .NET experience | +| **Open-Source Contributor** | Wants to build and extend a coding agent they can understand and modify | Closed-source agents can't be customized; Python agents aren't their stack | + +--- + +## 4. Phase 1 Requirements + +### 4.1 Core Runtime (Exists) + +The following are implemented and tested: + +- [x] Durable sessions with append-only event logs and JSON snapshots +- [x] Permission policy engine with workspace boundary enforcement +- [x] Anthropic and OpenAI-compatible provider abstraction +- [x] MCP server registration, supervision, and lifecycle management +- [x] Plugin discovery, manifest validation, and trust-based execution +- [x] Structured telemetry with ring buffer and JSON export +- [x] CLI with REPL, slash commands, and JSON output mode +- [x] Checkpoint-based undo/redo with mutation tracking +- [x] Spec workflow mode for structured requirements generation +- [x] Cross-platform support with Windows-safe behavior + +### 4.2 Phase 1 Gaps (Must Build) + +#### 4.2.1 Tool-Calling Loop in Agent Framework Bridge + +**Priority:** P0 +**Why:** The current agent bridge streams provider responses but does not execute tools within the agent loop. This is the single biggest functional gap — without it, SharpClaw Code is a streaming wrapper, not a coding agent. + +**Requirements:** +- Agent receives tool-use requests from the provider response +- Agent dispatches tool calls through the existing IToolExecutor (inheriting permission checks) +- Tool results are fed back to the provider for the next turn iteration +- Multi-turn tool loops terminate on provider completion or configurable max iterations +- Each tool call is recorded as a runtime event (ToolStartedEvent, ToolCompletedEvent) + +#### 4.2.2 Conversation History + +**Priority:** P0 +**Why:** Multi-turn conversations require prior context. Currently each prompt is stateless within the provider call. + +**Requirements:** +- Session-scoped conversation history assembled from persisted events +- Configurable context window management (truncation, summarization) +- System prompt injection from workspace context (CLAUDE.md equivalent) +- History survives session resume + +#### 4.2.3 NuGet Package Distribution + +**Priority:** P1 +**Why:** Adoption requires `dotnet add package`, not `git clone`. + +**Requirements:** +- Publish core packages to NuGet.org: + - `SharpClaw.Code.Protocol` — contracts only, zero dependencies + - `SharpClaw.Code.Runtime` — full runtime with DI extensions + - `SharpClaw.Code.Providers.Anthropic` — Anthropic provider + - `SharpClaw.Code.Providers.OpenAi` — OpenAI-compatible provider + - `SharpClaw.Code.Tools` — built-in tools and tool SDK + - `SharpClaw.Code.Mcp` — MCP client integration +- Stable API surface with semantic versioning +- XML documentation included in packages + +#### 4.2.4 Documentation and Getting Started + +**Priority:** P1 +**Why:** Framework adoption lives or dies on docs. + +**Requirements:** +- Getting started guide: "Build your first agent in 15 minutes" +- Integration guide: "Using SharpClaw with Microsoft Agent Framework" +- Architecture deep-dive for contributors +- API reference generated from XML docs +- Example projects: + - Minimal console agent + - Web API agent with session persistence + - MCP-enabled agent with custom tools + +#### 4.2.5 CI/CD Pipeline + +**Priority:** P1 +**Why:** No CI currently exists. Contributors need confidence their PRs don't break things. + +**Requirements:** +- GitHub Actions workflow: build + test on push/PR +- Matrix: ubuntu-latest, windows-latest, macos-latest +- NuGet package publishing on release tags +- Code coverage reporting + +#### 4.2.6 Provider Resilience + +**Priority:** P2 +**Why:** Production workloads need retry logic, rate limiting, and graceful degradation. + +**Requirements:** +- Configurable retry with exponential backoff for transient HTTP failures +- Rate limit detection and backoff (429 handling) +- Request timeout configuration per provider +- Circuit breaker pattern for repeated failures +- Fallback provider chain (try Anthropic, fall back to OpenAI) + +#### 4.2.7 Observability + +**Priority:** P2 +**Why:** Production deployments need more than a ring buffer. + +**Requirements:** +- OpenTelemetry activity/span integration for distributed tracing +- Structured log correlation IDs across turn execution +- Metrics: token usage, tool execution duration, provider latency +- Optional NDJSON trace file sink for offline analysis + +### 4.3 Phase 1 Non-Goals + +- IDE integrations (Phase 3) +- Multi-tenant session isolation (Phase 2) +- Local model hosting (Phase 3) +- Marketplace or plugin store (Phase 2) +- Billing or usage metering (Phase 2) +- GUI or web dashboard (Phase 2+) + +--- + +## 5. Phase 2 Requirements (Summary) + +| Capability | Description | +|------------|-------------| +| Embeddable Runtime SDK | Host SharpClaw runtime in ASP.NET Core, worker services, or custom hosts without the CLI | +| Multi-Tenant Sessions | Workspace and session isolation per tenant with configurable storage backends | +| Custom Tool SDK | Package, version, and distribute custom tools as NuGet packages with manifest metadata | +| Event Streaming | Webhooks or message bus integration for real-time event forwarding | +| Admin API | REST API for session management, provider configuration, and runtime health | +| Usage Metering | Token and tool usage tracking with per-tenant attribution | +| SSO / Auth Integration | Enterprise identity provider support for approval workflows | + +--- + +## 6. Phase 3 Requirements (Summary) + +| Capability | Description | +|------------|-------------| +| Polished CLI UX | Auto-complete, syntax highlighting, rich diff rendering, progress indicators | +| IDE Extensions | VS Code and JetBrains Rider extensions using ACP protocol | +| Local Models | Ollama and llama.cpp support via OpenAI-compatible provider | +| Workspace Indexing | Semantic code search, symbol navigation, dependency graph | +| Cross-Session Memory | Persistent memory that survives session boundaries (project knowledge, user preferences) | +| Community Plugin Registry | Discoverable, installable plugins from a public registry | + +--- + +## 7. Success Metrics + +### Phase 1 (Months 0-12) + +| Metric | Target | Rationale | +|--------|--------|-----------| +| GitHub stars | 2,000 | Validates .NET community interest | +| NuGet downloads (monthly) | 5,000 | Measures actual adoption | +| Contributors (unique) | 25 | Healthy contributor ecosystem | +| Discord / community members | 500 | Engaged developer community | +| Production deployments (known) | 10 | Real-world validation | +| Documentation pages | 30+ | Comprehensive getting-started and reference | + +### Phase 2 (Months 6-18) + +| Metric | Target | +|--------|--------| +| Companies using embedded SDK | 5 | +| Enterprise pilot conversations | 10 | +| Monthly recurring revenue | $10K (from enterprise licenses) | + +### Phase 3 (Months 12-24) + +| Metric | Target | +|--------|--------| +| Monthly active CLI users | 5,000 | +| IDE extension installs | 2,000 | +| Community plugins published | 20 | + +--- + +## 8. Technical Constraints + +- **.NET 10 / C# 13** — non-negotiable; this is a .NET-native product +- **System.Text.Json only** — no Newtonsoft.Json; source-generated serialization +- **Cross-platform** — must work on Windows, macOS, and Linux +- **MIT license for core** — open-core model; proprietary extensions for enterprise +- **Protocol-first** — all contracts go through SharpClaw.Code.Protocol; no leaking provider types +- **Async end-to-end** — no sync-over-async or fire-and-forget patterns +- **Permission-aware by default** — tools must route through the policy engine + +--- + +## 9. Risks + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| .NET coding agent market is too small | Medium | High | Phase 1 positions as production runtime layer (broader market), not just coding agent | +| Microsoft builds competing runtime layer into Agent Framework | Low-Medium | High | Stay close to the team; contribute upstream; position as community-driven complement rather than competitor. If Microsoft builds it, pivot to tooling/hosting layer on top | +| Agent Framework breaks API compatibility | Medium | Medium | Abstraction layer (AgentFrameworkBridge) already isolates framework types; pin to stable versions | +| Provider APIs change frequently | High | Low | Abstraction layer already isolates provider details | +| Contributor burnout (small team) | Medium | High | Keep scope tight per phase; automate CI/CD; accept contributions early | +| Enterprise sales cycle too long for Phase 2 | Medium | Medium | Offer self-serve enterprise tier alongside sales-led motion | +| Microsoft relationship doesn't materialize | Medium | Medium | Product stands alone regardless; Microsoft alignment is accelerant, not dependency | + +--- + +## 10. Open Questions + +1. Should the CLI be distributed as a `dotnet tool` (global install) or standalone binary? +2. What's the naming/branding strategy for paid tiers? ("SharpClaw Pro"? "SharpClaw Enterprise"?) +3. Should Phase 2 include a hosted/managed offering, or stay self-hosted only? +4. How deep should the Agent Framework integration go — should SharpClaw contribute features upstream, or keep the runtime layer cleanly separated? +5. Is there an opportunity to join Microsoft's Agent Framework partner/early-adopter program? +6. Should SharpClaw target inclusion in .NET project templates (e.g., `dotnet new sharpclaw-agent`)? diff --git a/docs/agent-framework-integration.md b/docs/agent-framework-integration.md new file mode 100644 index 0000000..29376c3 --- /dev/null +++ b/docs/agent-framework-integration.md @@ -0,0 +1,480 @@ +# 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 25) +- `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 +{ + ToolDefinition Definition { get; } + PluginToolSource? PluginSource { get; } + Task ExecuteAsync(ToolExecutionContext context, ToolExecutionRequest request, CancellationToken cancellationToken); +} +``` + +**Implementation:** Extend `SharpClawToolBase` for the common pattern: + +```csharp +public sealed class YourCustomTool(IPathService pathService) : SharpClawToolBase +{ + public override ToolDefinition Definition { get; } = new( + Name: "your-tool", + Description: "Does something useful", + ApprovalScope: ApprovalScope.ToolExecution, + IsDestructive: false, + RequiresApproval: false, + InputTypeName: "YourToolArguments", + InputDescription: "JSON object with tool parameters.", + Tags: ["custom"]); + + public override async Task ExecuteAsync( + ToolExecutionContext context, + ToolExecutionRequest request, + CancellationToken cancellationToken) + { + var arguments = DeserializeArguments(request); + // Perform work respecting context.WorkspaceRoot, context.PermissionMode, etc. + return CreateSuccessResult(context, request, "output text", null); + } +} +``` + +**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..6fa98af --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,320 @@ +# 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 +# .NET configuration uses double-underscore for nested keys in env vars +export SharpClaw__Providers__Anthropic__ApiKey=sk-ant-... +dotnet run --project src/SharpClaw.Code.Cli +``` + +Supported environment variables (using .NET configuration path format): + +- `SharpClaw__Providers__Anthropic__ApiKey` – Anthropic API key +- `SharpClaw__Providers__OpenAiCompatible__ApiKey` – OpenAI-compatible API key +- `SharpClaw__Providers__Catalog__DefaultProvider` – Default provider name + +### Configuration File + +Alternatively, configure providers in `appsettings.json`: + +```json +{ + "SharpClaw": { + "Providers": { + "Catalog": { + "DefaultProvider": "Anthropic" + }, + "Anthropic": { + "ApiKey": "sk-ant-...", + "DefaultModel": "claude-sonnet-4-5" + }, + "OpenAiCompatible": { + "ApiKey": "sk-...", + "DefaultModel": "gpt-4-turbo" + } + } + } +} +``` + +The runtime loads from standard .NET configuration sources: +1. Environment variables (highest priority, double-underscore path format) +2. `appsettings.{Environment}.json` +3. `appsettings.json` (default) +4. Command-line arguments + +## 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/docs/superpowers/plans/2026-04-10-phase1-gaps.md b/docs/superpowers/plans/2026-04-10-phase1-gaps.md new file mode 100644 index 0000000..c8b602f --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-phase1-gaps.md @@ -0,0 +1,1156 @@ +# Phase 1 Gaps Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close all 7 Phase 1 gaps from the PRD: tool-calling loop (P0), conversation history (P0), NuGet packages (P1), documentation (P1), CI/CD (P1), provider resilience (P2), and observability (P2). + +**Architecture:** The plan builds bottom-up — Protocol models first, then provider/adapter changes, then agent loop, then runtime/history integration. Infrastructure tasks (CI, NuGet, docs) are independent and can run in parallel with the P2 work. + +**Tech Stack:** .NET 10, C# 13, System.Text.Json, Microsoft Agent Framework, Anthropic SDK, Microsoft.Extensions.AI, OpenTelemetry, GitHub Actions + +--- + +## File Map + +### Protocol Layer (new/modified models) + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/SharpClaw.Code.Protocol/Models/ChatMessage.cs` | Create | Role + content blocks model for conversation history | +| `src/SharpClaw.Code.Protocol/Models/ContentBlock.cs` | Create | Discriminated content: text, tool-use, tool-result | +| `src/SharpClaw.Code.Protocol/Models/ToolDefinition.cs` | Modify | Add `InputSchema` property for provider tool definitions | +| `src/SharpClaw.Code.Protocol/Models/ProviderRequest.cs` | Modify | Add `Messages`, `Tools`, `MaxTokens` fields | +| `src/SharpClaw.Code.Protocol/Models/ProviderEvent.cs` | Modify | Add `BlockType`, `ToolUseId`, `ToolName`, `ToolInputJson` fields | +| `src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs` | Modify | Register new types for source-generated serialization | + +### Provider Layer (tool-use streaming) + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/SharpClaw.Code.Providers/Internal/ProviderStreamEventFactory.cs` | Modify | Add `ToolUse` and `ToolResult` event factory methods | +| `src/SharpClaw.Code.Providers/Internal/AnthropicSdkStreamAdapter.cs` | Modify | Extract `tool_use` blocks from Anthropic stream | +| `src/SharpClaw.Code.Providers/Internal/OpenAiMeaiStreamAdapter.cs` | Modify | Extract `tool_calls` from OpenAI stream | +| `src/SharpClaw.Code.Providers/Internal/AnthropicMessageBuilder.cs` | Create | Map `ChatMessage[]` + tool defs to Anthropic SDK params | +| `src/SharpClaw.Code.Providers/Internal/OpenAiMessageBuilder.cs` | Create | Map `ChatMessage[]` + tool defs to MEAI ChatMessage list | +| `src/SharpClaw.Code.Providers/AnthropicProvider.cs` | Modify | Accept messages array and tool definitions | +| `src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs` | Modify | Accept messages array and tool definitions | + +### Agent Layer (tool-calling loop) + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs` | Modify | Implement tool-calling loop: stream → detect tool-use → execute → resume | +| `src/SharpClaw.Code.Agents/Internal/ToolCallDispatcher.cs` | Create | Bridges tool-use events to IToolExecutor, builds tool-result content blocks | +| `src/SharpClaw.Code.Agents/Configuration/AgentLoopOptions.cs` | Create | `MaxToolIterations`, `MaxTokensPerTurn` | +| `src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs` | Modify | Pass tool definitions and messages to kernel | + +### Runtime Layer (conversation history) + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/SharpClaw.Code.Runtime/Context/ConversationHistoryAssembler.cs` | Create | Builds `ChatMessage[]` from session event log | +| `src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs` | Modify | Include conversation history in assembled context | +| `src/SharpClaw.Code.Runtime/Context/ContextWindowManager.cs` | Create | Token-aware truncation of message history | +| `src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs` | Modify | Pass messages + tool definitions to agent | + +### CI/CD + +| File | Action | Responsibility | +|------|--------|----------------| +| `.github/workflows/ci.yml` | Create | Build + test on push/PR, cross-platform matrix | +| `.github/workflows/release.yml` | Create | NuGet publish on release tags | + +### NuGet Packaging + +| File | Action | Responsibility | +|------|--------|----------------| +| `Directory.Build.props` | Modify | Add NuGet metadata (Authors, PackageLicenseExpression, etc.) | +| Per-project `.csproj` files | Modify | Add `PackageId`, `Description`, `PackageTags` | + +### Provider Resilience + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/SharpClaw.Code.Providers/Resilience/RetryHandler.cs` | Create | Exponential backoff with jitter for transient failures | +| `src/SharpClaw.Code.Providers/Resilience/RateLimitHandler.cs` | Create | 429 detection and Retry-After backoff | +| `src/SharpClaw.Code.Providers/Resilience/CircuitBreakerHandler.cs` | Create | Half-open/open/closed circuit breaker | +| `src/SharpClaw.Code.Providers/Configuration/ProviderResilienceOptions.cs` | Create | Options for retry, timeout, circuit breaker | +| `src/SharpClaw.Code.Providers/Services/ResilientProviderDecorator.cs` | Create | Decorates IModelProvider with resilience pipeline | + +### Observability + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/SharpClaw.Code.Telemetry/Diagnostics/SharpClawActivitySource.cs` | Create | OpenTelemetry ActivitySource for spans | +| `src/SharpClaw.Code.Telemetry/Diagnostics/TurnActivityScope.cs` | Create | Wraps turn execution in an Activity span | +| `src/SharpClaw.Code.Telemetry/Diagnostics/ProviderActivityScope.cs` | Create | Wraps provider calls in an Activity span | +| `src/SharpClaw.Code.Telemetry/Metrics/SharpClawMeterSource.cs` | Create | Counters and histograms for tokens, duration, tools | +| `src/SharpClaw.Code.Telemetry/Export/NdjsonTraceFileSink.cs` | Create | Optional NDJSON file sink for offline analysis | + +### Documentation & Examples + +| File | Action | Responsibility | +|------|--------|----------------| +| `docs/getting-started.md` | Create | 15-minute guide | +| `docs/agent-framework-integration.md` | Create | How SharpClaw builds on Microsoft Agent Framework | +| `examples/MinimalConsoleAgent/` | Create | Simplest possible agent | +| `examples/WebApiAgent/` | Create | ASP.NET Core hosted agent with session persistence | +| `examples/McpToolAgent/` | Create | Agent with custom MCP tools | + +### Tests + +| File | Action | Responsibility | +|------|--------|----------------| +| `tests/SharpClaw.Code.UnitTests/Protocol/ChatMessageSerializationTests.cs` | Create | Roundtrip for new message models | +| `tests/SharpClaw.Code.UnitTests/Providers/ToolUseStreamAdapterTests.cs` | Create | Verify tool-use block extraction | +| `tests/SharpClaw.Code.UnitTests/Agents/ToolCallDispatcherTests.cs` | Create | Verify tool dispatch and result building | +| `tests/SharpClaw.Code.UnitTests/Runtime/ConversationHistoryAssemblerTests.cs` | Create | History assembly from events | +| `tests/SharpClaw.Code.UnitTests/Runtime/ContextWindowManagerTests.cs` | Create | Truncation logic | +| `tests/SharpClaw.Code.MockProvider/DeterministicMockModelProvider.cs` | Modify | Add tool-use scenario | +| `tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs` | Modify | Add tool-calling loop scenario | +| `tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs` | Create | Retry, circuit breaker, rate limit tests | + +--- + +## P0 — Tool-Calling Loop + Conversation History + +These are tightly coupled: the tool-calling loop requires conversation messages to feed tool results back, and conversation history requires the message model to persist multi-turn exchanges. Build together, bottom-up. + +### Task 1: Protocol — ChatMessage and ContentBlock models + +**Files:** +- Create: `src/SharpClaw.Code.Protocol/Models/ChatMessage.cs` +- Create: `src/SharpClaw.Code.Protocol/Models/ContentBlock.cs` +- Modify: `src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs` +- Test: `tests/SharpClaw.Code.UnitTests/Protocol/ChatMessageSerializationTests.cs` + +- [ ] **Step 1: Write failing test for ChatMessage JSON roundtrip** + +```csharp +// tests/SharpClaw.Code.UnitTests/Protocol/ChatMessageSerializationTests.cs +using System.Text.Json; +using FluentAssertions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; + +namespace SharpClaw.Code.UnitTests.Protocol; + +public sealed class ChatMessageSerializationTests +{ + [Fact] + public void ChatMessage_with_text_block_roundtrips() + { + var message = new ChatMessage("user", [new ContentBlock(ContentBlockKind.Text, "Hello", null, null, null, 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().ContainSingle(); + deserialized.Content[0].Kind.Should().Be(ContentBlockKind.Text); + deserialized.Content[0].Text.Should().Be("Hello"); + } + + [Fact] + public void ChatMessage_with_tool_use_block_roundtrips() + { + var message = new ChatMessage("assistant", [ + new ContentBlock(ContentBlockKind.ToolUse, null, "call-1", "read_file", "{\"path\":\"a.cs\"}", null) + ]); + var json = JsonSerializer.Serialize(message, ProtocolJsonContext.Default.ChatMessage); + var deserialized = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.ChatMessage); + + deserialized!.Content[0].Kind.Should().Be(ContentBlockKind.ToolUse); + deserialized.Content[0].ToolUseId.Should().Be("call-1"); + deserialized.Content[0].ToolName.Should().Be("read_file"); + deserialized.Content[0].ToolInputJson.Should().Be("{\"path\":\"a.cs\"}"); + } + + [Fact] + public void ChatMessage_with_tool_result_block_roundtrips() + { + var message = new ChatMessage("user", [ + new ContentBlock(ContentBlockKind.ToolResult, "file contents here", "call-1", null, null, null) + ]); + var json = JsonSerializer.Serialize(message, ProtocolJsonContext.Default.ChatMessage); + var deserialized = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.ChatMessage); + + deserialized!.Content[0].Kind.Should().Be(ContentBlockKind.ToolResult); + deserialized.Content[0].ToolUseId.Should().Be("call-1"); + deserialized.Content[0].Text.Should().Be("file contents here"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test SharpClawCode.sln --filter "FullyQualifiedName~ChatMessageSerializationTests" -v minimal` +Expected: Build failure — `ChatMessage`, `ContentBlock`, `ContentBlockKind` do not exist. + +- [ ] **Step 3: Create ContentBlock and ChatMessage models** + +```csharp +// src/SharpClaw.Code.Protocol/Models/ContentBlock.cs +using System.Text.Json.Serialization; + +namespace SharpClaw.Code.Protocol.Models; + +/// +/// Describes the kind of content within a message block. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ContentBlockKind +{ + /// Plain text content. + [JsonStringEnumMemberName("text")] + Text, + + /// A request from the model to invoke a tool. + [JsonStringEnumMemberName("tool_use")] + ToolUse, + + /// The result of a tool invocation, returned to the model. + [JsonStringEnumMemberName("tool_result")] + ToolResult, +} + +/// +/// A single content block within a . +/// +/// The block type discriminator. +/// Text content (for and ). +/// The tool invocation identifier (for and ). +/// The tool name (for ). +/// The tool input as a JSON string (for ). +/// Whether the tool result represents an error (for ). +public sealed record ContentBlock( + ContentBlockKind Kind, + string? Text, + string? ToolUseId, + string? ToolName, + string? ToolInputJson, + bool? IsError); +``` + +```csharp +// src/SharpClaw.Code.Protocol/Models/ChatMessage.cs +namespace SharpClaw.Code.Protocol.Models; + +/// +/// A single message in a conversation history, containing one or more content blocks. +/// +/// The message role: "user", "assistant", or "system". +/// The content blocks within this message. +public sealed record ChatMessage( + string Role, + IReadOnlyList Content); +``` + +- [ ] **Step 4: Register types in ProtocolJsonContext** + +Add to the `[JsonSerializable]` attributes in `src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs`: + +```csharp +[JsonSerializable(typeof(ChatMessage))] +[JsonSerializable(typeof(ChatMessage[]))] +[JsonSerializable(typeof(ContentBlock))] +[JsonSerializable(typeof(ContentBlock[]))] +[JsonSerializable(typeof(ContentBlockKind))] +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `dotnet test SharpClawCode.sln --filter "FullyQualifiedName~ChatMessageSerializationTests" -v minimal` +Expected: All 3 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/SharpClaw.Code.Protocol/Models/ChatMessage.cs \ + src/SharpClaw.Code.Protocol/Models/ContentBlock.cs \ + src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs \ + tests/SharpClaw.Code.UnitTests/Protocol/ChatMessageSerializationTests.cs +git commit -m "feat(protocol): add ChatMessage and ContentBlock models for conversation history" +``` + +--- + +### Task 2: Protocol — Extend ProviderRequest and ProviderEvent + +**Files:** +- Modify: `src/SharpClaw.Code.Protocol/Models/ProviderRequest.cs` +- Modify: `src/SharpClaw.Code.Protocol/Models/ProviderEvent.cs` +- Modify: `src/SharpClaw.Code.Protocol/Models/ToolDefinition.cs` + +- [ ] **Step 1: Read current ProviderRequest** + +Read: `src/SharpClaw.Code.Protocol/Models/ProviderRequest.cs` + +- [ ] **Step 2: Add Messages, Tools, and MaxTokens fields to ProviderRequest** + +Add these parameters to the existing `ProviderRequest` record: + +```csharp +IReadOnlyList? Messages, +IReadOnlyList? Tools, +int? MaxTokens +``` + +Keep `Prompt` and `SystemPrompt` for backward compatibility. When `Messages` is non-null, providers use it; when null, they fall back to constructing a single-user-message from `Prompt`. + +- [ ] **Step 3: Add tool-use fields to ProviderEvent** + +Add to `ProviderEvent` record: + +```csharp +string? BlockType, // "text", "tool_use", "tool_result" +string? ToolUseId, // tool call identifier +string? ToolName, // tool name for tool_use events +string? ToolInputJson // tool input JSON for tool_use events +``` + +- [ ] **Step 4: Add InputSchema to ToolDefinition** + +Read current `ToolDefinition` and add: + +```csharp +string? InputSchemaJson // JSON Schema for tool input parameters +``` + +- [ ] **Step 5: Build to verify compilation** + +Run: `dotnet build SharpClawCode.sln` +Expected: Build may fail in callers that construct these records positionally. Fix by adding default parameter values or updating call sites. + +- [ ] **Step 6: Fix all compilation errors from record changes** + +Update all call sites that construct `ProviderRequest`, `ProviderEvent`, and `ToolDefinition` with the new parameters. New fields should default to `null` where not yet used. + +- [ ] **Step 7: Run all tests** + +Run: `dotnet test SharpClawCode.sln` +Expected: All existing tests PASS. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "feat(protocol): extend ProviderRequest with Messages/Tools, ProviderEvent with tool-use fields" +``` + +--- + +### Task 3: Provider Adapters — Anthropic tool-use extraction + +**Files:** +- Modify: `src/SharpClaw.Code.Providers/Internal/ProviderStreamEventFactory.cs` +- Modify: `src/SharpClaw.Code.Providers/Internal/AnthropicSdkStreamAdapter.cs` +- Create: `src/SharpClaw.Code.Providers/Internal/AnthropicMessageBuilder.cs` +- Modify: `src/SharpClaw.Code.Providers/AnthropicProvider.cs` +- Test: `tests/SharpClaw.Code.UnitTests/Providers/ToolUseStreamAdapterTests.cs` + +- [ ] **Step 1: Write failing test for ProviderStreamEventFactory.ToolUse** + +```csharp +// tests/SharpClaw.Code.UnitTests/Providers/ToolUseStreamAdapterTests.cs +using FluentAssertions; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Providers.Internal; + +namespace SharpClaw.Code.UnitTests.Providers; + +public sealed class ToolUseStreamAdapterTests +{ + [Fact] + public void ToolUse_event_contains_tool_metadata() + { + var clock = new SystemClock(); + 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(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Expected: `ProviderStreamEventFactory.ToolUse` does not exist. + +- [ ] **Step 3: Add ToolUse factory method to ProviderStreamEventFactory** + +```csharp +/// +/// 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); +``` + +- [ ] **Step 4: Run test to verify it passes** + +- [ ] **Step 5: Create AnthropicMessageBuilder** + +This class maps `ChatMessage[]` and `ToolDefinition[]` to Anthropic SDK `MessageCreateParams`: + +```csharp +// src/SharpClaw.Code.Providers/Internal/AnthropicMessageBuilder.cs +// - Converts ChatMessage[] to MessageParam[] (mapping roles and content blocks) +// - Converts ToolDefinition[] to Anthropic Tool[] (mapping name, description, input schema) +// - Handles tool_result content blocks as ToolResultBlockParam +``` + +- [ ] **Step 6: Update AnthropicSdkStreamAdapter to extract tool_use blocks** + +In the stream consumption loop, handle `TryPickContentBlockStart` for tool-use blocks. Accumulate tool input JSON from deltas. On `ContentBlockStop`, yield a `ProviderStreamEventFactory.ToolUse(...)` event. + +- [ ] **Step 7: Update AnthropicProvider.StartStreamAsync to accept Messages and Tools** + +When `request.Messages` is non-null, use `AnthropicMessageBuilder` to construct params instead of building a single-message request. + +- [ ] **Step 8: Run all tests** + +Run: `dotnet test SharpClawCode.sln` +Expected: All tests PASS including new tool-use adapter tests. + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "feat(providers): Anthropic adapter extracts tool-use blocks and accepts message history" +``` + +--- + +### Task 4: Provider Adapters — OpenAI tool-use extraction + +**Files:** +- Modify: `src/SharpClaw.Code.Providers/Internal/OpenAiMeaiStreamAdapter.cs` +- Create: `src/SharpClaw.Code.Providers/Internal/OpenAiMessageBuilder.cs` +- Modify: `src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs` +- Test: `tests/SharpClaw.Code.UnitTests/Providers/ToolUseStreamAdapterTests.cs` (add tests) + +- [ ] **Step 1: Write failing test for OpenAI tool-call extraction** + +Add to `ToolUseStreamAdapterTests`: + +```csharp +[Fact] +public async Task OpenAi_adapter_yields_tool_use_from_function_call() +{ + // Create a mock IAsyncEnumerable that contains a FunctionCallContent + // Verify the adapter emits a ProviderEvent with Kind="tool_use" +} +``` + +- [ ] **Step 2: Create OpenAiMessageBuilder** + +Maps `ChatMessage[]` and `ToolDefinition[]` to `Microsoft.Extensions.AI.ChatMessage` list and `ChatOptions.Tools`. + +- [ ] **Step 3: Update OpenAiMeaiStreamAdapter to extract function calls** + +When a `ChatResponseUpdate` contains `FunctionCallContent` in its `Contents`, yield a `ToolUse` event. + +- [ ] **Step 4: Update OpenAiCompatibleProvider.StreamEventsAsync to accept Messages and Tools** + +When `request.Messages` is non-null, use `OpenAiMessageBuilder`. + +- [ ] **Step 5: Run all tests** + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat(providers): OpenAI adapter extracts tool calls and accepts message history" +``` + +--- + +### Task 5: Agent — ToolCallDispatcher + +**Files:** +- Create: `src/SharpClaw.Code.Agents/Internal/ToolCallDispatcher.cs` +- Test: `tests/SharpClaw.Code.UnitTests/Agents/ToolCallDispatcherTests.cs` + +- [ ] **Step 1: Write failing test for ToolCallDispatcher** + +```csharp +// tests/SharpClaw.Code.UnitTests/Agents/ToolCallDispatcherTests.cs +using FluentAssertions; +using SharpClaw.Code.Agents.Internal; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.UnitTests.Agents; + +public sealed class ToolCallDispatcherTests +{ + [Fact] + public async Task DispatchAsync_executes_tool_and_returns_result_block() + { + // Arrange: create a mock IToolExecutor that returns a success ToolResult + // Create a ToolCallDispatcher with the mock executor + // Create a ProviderEvent with Kind="tool_use", ToolName="read_file" + // + // Act: call dispatcher.DispatchAsync(event, context, cancellationToken) + // + // Assert: result is a ContentBlock with Kind=ToolResult, ToolUseId matches, Text contains output + } + + [Fact] + public async Task DispatchAsync_returns_error_block_on_tool_failure() + { + // Arrange: mock IToolExecutor that returns Succeeded=false + // Act + Assert: result ContentBlock has IsError=true and contains error message + } +} +``` + +- [ ] **Step 2: Implement ToolCallDispatcher** + +```csharp +// src/SharpClaw.Code.Agents/Internal/ToolCallDispatcher.cs +// Responsibility: +// - Takes a ProviderEvent with Kind="tool_use" +// - Builds a ToolExecutionRequest from the event's ToolName and ToolInputJson +// - Calls IToolExecutor.ExecuteAsync(...) +// - Publishes ToolStartedEvent and ToolCompletedEvent via IRuntimeEventPublisher +// - Returns a ContentBlock(ToolResult, output, toolUseId, ...) for feeding back to the provider +``` + +- [ ] **Step 3: Run tests** + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "feat(agents): add ToolCallDispatcher to bridge tool-use events to IToolExecutor" +``` + +--- + +### Task 6: Agent — Tool-Calling Loop in ProviderBackedAgentKernel + +**Files:** +- Modify: `src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs` +- Create: `src/SharpClaw.Code.Agents/Configuration/AgentLoopOptions.cs` +- Modify: `src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs` +- Modify: `tests/SharpClaw.Code.MockProvider/DeterministicMockModelProvider.cs` (add tool-use scenario) +- Modify: `tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs` (add tool-calling test) + +- [ ] **Step 1: Create AgentLoopOptions** + +```csharp +// src/SharpClaw.Code.Agents/Configuration/AgentLoopOptions.cs +namespace SharpClaw.Code.Agents.Configuration; + +/// +/// Configuration for the agent tool-calling loop. +/// +public sealed class AgentLoopOptions +{ + /// Maximum number of tool-calling iterations before forcing termination. + public int MaxToolIterations { get; set; } = 25; + + /// Maximum tokens per provider request within a turn. + public int MaxTokensPerRequest { get; set; } = 16_384; +} +``` + +- [ ] **Step 2: Implement the tool-calling loop in ProviderBackedAgentKernel** + +Modify `ExecuteAsync` to: + +1. Build initial `ChatMessage[]` from the request (system prompt + user message + history) +2. Collect available `ToolDefinition[]` from the tool registry +3. **Loop:** + a. Call `provider.StartStreamAsync(request)` with messages and tools + b. Consume stream events, accumulating text deltas and collecting tool-use events + c. If no tool-use events: break (model is done) + d. If tool-use events present: + - Build an assistant message with the tool-use content blocks + - For each tool-use: call `ToolCallDispatcher.DispatchAsync(...)` to execute + - Build a user message with tool-result content blocks + - Append both messages to the conversation + - Increment iteration counter; if >= `MaxToolIterations`, break with warning + e. Continue loop +4. Return the accumulated text output and collected events + +- [ ] **Step 3: Update AgentFrameworkBridge to pass tool definitions** + +The bridge should: +- Resolve available tools via `IToolRegistry` +- Map `ToolDefinition` instances to include `InputSchemaJson` for provider consumption +- Pass tools and any existing conversation messages to the kernel + +- [ ] **Step 4: Add tool-use scenario to DeterministicMockModelProvider** + +Add a new scenario `tool_call_roundtrip` that: +1. First call: yields a tool-use event for `read_file` with `{"path":"test.txt"}` +2. Second call (after receiving tool result): yields text delta "File content is: {result}" + completion + +- [ ] **Step 5: Add parity test for tool-calling loop** + +Add to `ParityScenarioTests`: + +```csharp +[Fact] +public async Task Tool_call_roundtrip_executes_tool_and_resumes() +{ + // Uses tool_call_roundtrip scenario + // Verifies: tool-use event emitted, tool executed, result fed back, final text output assembled +} +``` + +- [ ] **Step 6: Run all tests** + +Run: `dotnet test SharpClawCode.sln` +Expected: All tests PASS including new tool-calling parity test. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat(agents): implement tool-calling loop in ProviderBackedAgentKernel" +``` + +--- + +### Task 7: Runtime — Conversation History Assembly + +**Files:** +- Create: `src/SharpClaw.Code.Runtime/Context/ConversationHistoryAssembler.cs` +- Create: `src/SharpClaw.Code.Runtime/Context/ContextWindowManager.cs` +- Modify: `src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs` +- Modify: `src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs` +- Test: `tests/SharpClaw.Code.UnitTests/Runtime/ConversationHistoryAssemblerTests.cs` +- Test: `tests/SharpClaw.Code.UnitTests/Runtime/ContextWindowManagerTests.cs` + +- [ ] **Step 1: Write failing test for ConversationHistoryAssembler** + +```csharp +// tests/SharpClaw.Code.UnitTests/Runtime/ConversationHistoryAssemblerTests.cs +using FluentAssertions; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Context; + +namespace SharpClaw.Code.UnitTests.Runtime; + +public sealed class ConversationHistoryAssemblerTests +{ + [Fact] + public void Assembles_user_assistant_pairs_from_turn_events() + { + // Given: a list of RuntimeEvents containing TurnStartedEvent (with Input) + // and TurnCompletedEvent (with Output) for 2 completed turns + // When: ConversationHistoryAssembler.Assemble(events) + // Then: returns ChatMessage[] with [user, assistant, user, assistant] pattern + } + + [Fact] + public void Includes_tool_use_and_tool_result_from_events() + { + // Given: events containing ToolStartedEvent and ToolCompletedEvent within a turn + // When: assembled + // Then: assistant message contains tool_use blocks, user message contains tool_result blocks + } + + [Fact] + public void Skips_incomplete_turns() + { + // Given: a TurnStartedEvent with no matching TurnCompletedEvent + // When: assembled + // Then: incomplete turn is excluded from history + } +} +``` + +- [ ] **Step 2: Implement ConversationHistoryAssembler** + +```csharp +// src/SharpClaw.Code.Runtime/Context/ConversationHistoryAssembler.cs +// Responsibility: +// - Takes IReadOnlyList (from IEventStore.ReadAllAsync) +// - Groups events by TurnId +// - For each completed turn: builds user message (from input) and assistant message (from output + tool blocks) +// - Returns ChatMessage[] +``` + +- [ ] **Step 3: Write failing test for ContextWindowManager** + +```csharp +// tests/SharpClaw.Code.UnitTests/Runtime/ContextWindowManagerTests.cs +public sealed class ContextWindowManagerTests +{ + [Fact] + public void Truncates_oldest_messages_when_over_token_budget() + { + // Given: 10 messages totaling ~5000 tokens, budget of 2000 + // When: ContextWindowManager.Truncate(messages, maxTokens: 2000) + // Then: returns the most recent messages that fit within budget + // system message (if present) is always retained + } +} +``` + +- [ ] **Step 4: Implement ContextWindowManager** + +```csharp +// src/SharpClaw.Code.Runtime/Context/ContextWindowManager.cs +// Responsibility: +// - Takes ChatMessage[] and a max token budget +// - Estimates token count per message (simple word-based heuristic: words * 1.3) +// - Always keeps system messages +// - Drops oldest non-system messages until total fits within budget +// - Returns truncated ChatMessage[] +``` + +- [ ] **Step 5: Integrate into PromptContextAssembler and DefaultTurnRunner** + +Update `PromptContextAssembler` to: +- Call `IEventStore.ReadAllAsync` for the current session +- Pass events to `ConversationHistoryAssembler.Assemble()` +- Apply `ContextWindowManager.Truncate()` with configured max tokens +- Return messages as part of the assembled context + +Update `DefaultTurnRunner` to: +- Pass the assembled `ChatMessage[]` through to the agent's `AgentRunContext` + +- [ ] **Step 6: Run all tests** + +Run: `dotnet test SharpClawCode.sln` +Expected: All tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat(runtime): assemble conversation history from session events with context window management" +``` + +--- + +## P1 — CI/CD, NuGet Packages, Documentation + +These are independent of each other and of the P0 work. Can be parallelized. + +### Task 8: CI/CD Pipeline + +**Files:** +- Create: `.github/workflows/ci.yml` +- Create: `.github/workflows/release.yml` + +- [ ] **Step 1: Create CI workflow** + +```yaml +# .github/workflows/ci.yml +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 +``` + +- [ ] **Step 2: Create release workflow** + +```yaml +# .github/workflows/release.yml +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 +``` + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml .github/workflows/release.yml +git commit -m "ci: add GitHub Actions workflows for CI and NuGet release" +``` + +--- + +### Task 9: NuGet Package Metadata + +**Files:** +- Modify: `Directory.Build.props` +- Modify: Per-project `.csproj` files that should be packaged + +- [ ] **Step 1: Add shared NuGet metadata to Directory.Build.props** + +```xml + +clawdotnet +clawdotnet +MIT +https://github.com/clawdotnet/SharpClawCode +https://github.com/clawdotnet/SharpClawCode +git +README.md +icon.png +Copyright (c) 2025 clawdotnet +``` + +- [ ] **Step 2: Add per-project PackageId and Description to each publishable csproj** + +For each of these projects, add `` and ``: + +- `SharpClaw.Code.Protocol` — "Core contracts and models for the SharpClaw Code agent runtime." +- `SharpClaw.Code.Runtime` — "Production runtime orchestration for SharpClaw Code agents." +- `SharpClaw.Code.Providers` — "Anthropic and OpenAI-compatible provider integration for SharpClaw Code." +- `SharpClaw.Code.Tools` — "Built-in tools and tool execution framework for SharpClaw Code." +- `SharpClaw.Code.Mcp` — "Model Context Protocol client integration for SharpClaw Code." +- `SharpClaw.Code.Sessions` — "Durable session persistence for SharpClaw Code." +- `SharpClaw.Code.Permissions` — "Permission policy engine for SharpClaw Code tool execution." +- `SharpClaw.Code.Telemetry` — "Structured telemetry and event publishing for SharpClaw Code." +- `SharpClaw.Code.Agents` — "Microsoft Agent Framework integration for SharpClaw Code." +- `SharpClaw.Code.Infrastructure` — "Shared infrastructure services for SharpClaw Code." + +Mark test projects with `false` (most already have this). + +- [ ] **Step 3: Verify pack works** + +Run: `dotnet pack SharpClawCode.sln --configuration Release --output ./nupkg` +Expected: `.nupkg` files created for each publishable project. + +- [ ] **Step 4: Commit** + +```bash +git add Directory.Build.props src/**/*.csproj +git commit -m "build: add NuGet package metadata for publishing" +``` + +--- + +### Task 10: Documentation — Getting Started Guide + +**Files:** +- Create: `docs/getting-started.md` + +- [ ] **Step 1: Write the getting started guide** + +Structure: +1. Prerequisites (.NET 10 SDK) +2. Install via NuGet (`dotnet add package SharpClaw.Code.Runtime`) +3. Minimal console agent (15 lines: create host, add runtime, run prompt) +4. Add a custom tool +5. Enable session persistence +6. Next steps (links to architecture, tools, providers docs) + +The guide should be self-contained and copy-pasteable. + +- [ ] **Step 2: Commit** + +```bash +git add docs/getting-started.md +git commit -m "docs: add getting started guide" +``` + +--- + +### Task 11: Documentation — Agent Framework Integration Guide + +**Files:** +- Create: `docs/agent-framework-integration.md` + +- [ ] **Step 1: Write the integration guide** + +Structure: +1. How SharpClaw builds on Microsoft Agent Framework +2. Architecture diagram: Agent Framework → SharpClaw Runtime → Providers/Tools/Sessions +3. Key integration points: `ProviderBackedAgentKernel`, `AgentFrameworkBridge`, `SharpClawFrameworkAgent` +4. How to extend: adding a custom agent type, custom provider, custom tools +5. Comparison table: what Agent Framework provides vs. what SharpClaw adds + +- [ ] **Step 2: Commit** + +```bash +git add docs/agent-framework-integration.md +git commit -m "docs: add Microsoft Agent Framework integration guide" +``` + +--- + +### Task 12: Example Projects + +**Files:** +- Create: `examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj` +- Create: `examples/MinimalConsoleAgent/Program.cs` +- Create: `examples/WebApiAgent/WebApiAgent.csproj` +- Create: `examples/WebApiAgent/Program.cs` +- Create: `examples/McpToolAgent/McpToolAgent.csproj` +- Create: `examples/McpToolAgent/Program.cs` + +- [ ] **Step 1: Create MinimalConsoleAgent** + +A ~30-line console app that: +- Creates a host with `AddSharpClawRuntime(configuration)` +- Reads a prompt from args or stdin +- Calls `IConversationRuntime.RunPromptAsync()` +- Prints the output + +- [ ] **Step 2: Create WebApiAgent** + +An ASP.NET Core minimal API that: +- Exposes `POST /chat` accepting `{ "prompt": "..." }` +- Returns `{ "response": "...", "sessionId": "..." }` +- Persists sessions across requests + +- [ ] **Step 3: Create McpToolAgent** + +A console app that: +- Registers a custom MCP server +- Registers a custom built-in tool +- Runs a prompt that exercises both + +- [ ] **Step 4: Verify examples build** + +Run: `dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj` (and others) + +- [ ] **Step 5: Commit** + +```bash +git add examples/ +git commit -m "docs: add example projects (minimal console, web API, MCP tools)" +``` + +--- + +## P2 — Provider Resilience and Observability + +### Task 13: Provider Resilience + +**Files:** +- Create: `src/SharpClaw.Code.Providers/Resilience/RetryHandler.cs` +- Create: `src/SharpClaw.Code.Providers/Resilience/RateLimitHandler.cs` +- Create: `src/SharpClaw.Code.Providers/Resilience/CircuitBreakerHandler.cs` +- Create: `src/SharpClaw.Code.Providers/Configuration/ProviderResilienceOptions.cs` +- Create: `src/SharpClaw.Code.Providers/Services/ResilientProviderDecorator.cs` +- Modify: `src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs` +- Test: `tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs` + +- [ ] **Step 1: Create ProviderResilienceOptions** + +```csharp +public sealed class ProviderResilienceOptions +{ + public int MaxRetries { get; set; } = 3; + public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(500); + public TimeSpan MaxRetryDelay { get; set; } = TimeSpan.FromSeconds(30); + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromMinutes(5); + public int CircuitBreakerFailureThreshold { get; set; } = 5; + public TimeSpan CircuitBreakerBreakDuration { get; set; } = TimeSpan.FromSeconds(30); +} +``` + +- [ ] **Step 2: Write failing tests for RetryHandler** + +```csharp +// tests/SharpClaw.Code.UnitTests/Providers/ResilienceTests.cs +public sealed class ResilienceTests +{ + [Fact] + public async Task RetryHandler_retries_on_transient_failure() + { + // Mock provider that fails twice then succeeds + // Verify: 3 total attempts, final result is success + } + + [Fact] + public async Task RetryHandler_respects_429_retry_after_header() + { + // Mock provider that returns 429 with Retry-After: 1 + // Verify: waits at least 1 second before retry + } + + [Fact] + public async Task CircuitBreaker_opens_after_threshold_failures() + { + // Mock provider that always fails + // Call 5 times (threshold) + // 6th call should fail immediately without calling provider + } +} +``` + +- [ ] **Step 3: Implement RetryHandler** + +Exponential backoff with jitter. Catches `HttpRequestException` with 5xx status codes and `TaskCanceledException` (timeout). Respects `Retry-After` header for 429s. + +- [ ] **Step 4: Implement CircuitBreakerHandler** + +Three states: Closed (normal), Open (reject fast), Half-Open (allow one probe). Transitions based on failure count and time elapsed. + +- [ ] **Step 5: Implement ResilientProviderDecorator** + +Wraps `IModelProvider` with retry → circuit breaker → timeout pipeline. Registered as a decorator in DI. + +- [ ] **Step 6: Register in ProvidersServiceCollectionExtensions** + +Wrap each `IModelProvider` registration with `ResilientProviderDecorator` when `ProviderResilienceOptions` is configured. + +- [ ] **Step 7: Run all tests** + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "feat(providers): add retry, rate-limit, and circuit-breaker resilience" +``` + +--- + +### Task 14: Observability — OpenTelemetry Integration + +**Files:** +- Create: `src/SharpClaw.Code.Telemetry/Diagnostics/SharpClawActivitySource.cs` +- Create: `src/SharpClaw.Code.Telemetry/Diagnostics/TurnActivityScope.cs` +- Create: `src/SharpClaw.Code.Telemetry/Diagnostics/ProviderActivityScope.cs` +- Create: `src/SharpClaw.Code.Telemetry/Metrics/SharpClawMeterSource.cs` +- Create: `src/SharpClaw.Code.Telemetry/Export/NdjsonTraceFileSink.cs` +- Modify: `src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs` +- Modify: `src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs` + +- [ ] **Step 1: Create SharpClawActivitySource** + +```csharp +// src/SharpClaw.Code.Telemetry/Diagnostics/SharpClawActivitySource.cs +using System.Diagnostics; + +namespace SharpClaw.Code.Telemetry.Diagnostics; + +/// +/// Central ActivitySource for OpenTelemetry distributed tracing. +/// +public static class SharpClawActivitySource +{ + public const string SourceName = "SharpClaw.Code"; + public static readonly ActivitySource Instance = new(SourceName, "1.0.0"); +} +``` + +- [ ] **Step 2: Create TurnActivityScope and ProviderActivityScope** + +Thin wrappers that start an `Activity` with appropriate tags (session ID, turn ID, provider name, model, token counts). + +- [ ] **Step 3: Create SharpClawMeterSource** + +```csharp +// Counters: sharpclaw.tokens.input, sharpclaw.tokens.output +// Histograms: sharpclaw.turn.duration_ms, sharpclaw.provider.duration_ms, sharpclaw.tool.duration_ms +``` + +- [ ] **Step 4: Integrate spans into DefaultTurnRunner and ProviderBackedAgentKernel** + +Wrap turn execution and provider calls in Activity scopes. Record token usage in meters. + +- [ ] **Step 5: Create NdjsonTraceFileSink** + +Optional file-based exporter that writes Activity spans as NDJSON for offline analysis. + +- [ ] **Step 6: Run all tests** + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat(telemetry): add OpenTelemetry spans, metrics, and NDJSON trace sink" +``` + +--- + +## Execution Order Summary + +``` +P0 (sequential, bottom-up): + Task 1: Protocol models (ChatMessage, ContentBlock) + Task 2: Protocol extensions (ProviderRequest, ProviderEvent) + Task 3: Anthropic tool-use adapter + Task 4: OpenAI tool-use adapter + Task 5: ToolCallDispatcher + Task 6: Tool-calling loop in agent kernel + Task 7: Conversation history assembly + +P1 (parallel with each other, parallel with P0 after Task 2): + Task 8: CI/CD pipeline + Task 9: NuGet package metadata + Task 10: Getting started guide + Task 11: Agent Framework integration guide + Task 12: Example projects + +P2 (after P0, parallel with each other): + Task 13: Provider resilience + Task 14: Observability +``` + +**Estimated total: 14 tasks, ~40-60 hours of implementation work.** 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..0cff851 --- /dev/null +++ b/examples/McpToolAgent/appsettings.json @@ -0,0 +1,19 @@ +{ + "SharpClaw": { + "Providers": { + "Catalog": { + "DefaultProvider": "Anthropic" + }, + "Anthropic": { + "ApiKey": "", + "DefaultModel": "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..0cff851 --- /dev/null +++ b/examples/MinimalConsoleAgent/appsettings.json @@ -0,0 +1,19 @@ +{ + "SharpClaw": { + "Providers": { + "Catalog": { + "DefaultProvider": "Anthropic" + }, + "Anthropic": { + "ApiKey": "", + "DefaultModel": "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..93325ed --- /dev/null +++ b/examples/WebApiAgent/appsettings.json @@ -0,0 +1,21 @@ +{ + "SharpClaw": { + "Providers": { + "Catalog": { + "DefaultProvider": "Anthropic" + }, + "Anthropic": { + "ApiKey": "", + "DefaultModel": "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..8df6343 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,172 @@ 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 --- + // Do not add request.Instructions as a shared "system" chat message here. + // Provider adapters apply system instructions via ProviderRequest.SystemPrompt + // so providers that do not support a native "system" role (e.g. Anthropic) + // do not receive duplicated or remapped instruction turns. + var messages = new List(); + + // 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); + UsageSnapshot? iterationUsage = null; - if (!providerEvent.IsTerminal && !string.IsNullOrWhiteSpace(providerEvent.Content)) + 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 { - outputSegments.Add(providerEvent.Content); + 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 && providerEvent.Usage is not null) + { + iterationUsage = providerEvent.Usage; + terminalUsage = providerEvent.Usage; + } + } + + providerSw.Stop(); + providerScope.SetCompleted(iterationUsage?.InputTokens, iterationUsage?.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.AddRange(iterationTextSegments); + break; + } + + // Build assistant message with text + tool-use content blocks + var assistantBlocks = new List(); + var iterationText = string.Concat(iterationTextSegments); + if (!string.IsNullOrEmpty(iterationText)) + { + 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)); - if (providerEvent.IsTerminal && providerEvent.Usage is not null) + // Dispatch each tool call and collect results + var toolResultBlocks = new List(); + foreach (var toolUseEvent in toolUseEvents) { - terminalUsage = providerEvent.Usage; + 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 +257,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 +272,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 +293,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..6ed25b3 --- /dev/null +++ b/src/SharpClaw.Code.Agents/Internal/ToolCallDispatcher.cs @@ -0,0 +1,94 @@ +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) + { + if (string.IsNullOrWhiteSpace(toolUseEvent.ToolName)) + { + return (new ContentBlock(ContentBlockKind.ToolResult, "Tool call missing required tool name.", toolUseEvent.ToolUseId, null, null, true), + new ToolResult("unknown", "unknown", false, Protocol.Enums.OutputFormat.Text, null, "Tool call missing required tool name.", 1, null, null), []); + } + + if (string.IsNullOrWhiteSpace(toolUseEvent.ToolUseId)) + { + return (new ContentBlock(ContentBlockKind.ToolResult, "Tool call missing required tool use ID.", null, null, null, true), + new ToolResult("unknown", toolUseEvent.ToolName, false, Protocol.Enums.OutputFormat.Text, null, "Tool call missing required tool use ID.", 1, null, null), []); + } + + var toolName = toolUseEvent.ToolName; + var toolInputJson = toolUseEvent.ToolInputJson ?? "{}"; + var toolUseId = toolUseEvent.ToolUseId; + + var collectedEvents = new List(); + + // 1. Execute the tool (this builds the real ToolExecutionRequest internally with correct approval/destructive metadata) + var envelope = await toolExecutor.ExecuteAsync(toolName, toolInputJson, context, cancellationToken); + + // 2. Publish ToolStartedEvent using the real request from the executor (has correct approval scope, destructive flag, etc.) + var startedEvent = new ToolStartedEvent( + EventId: $"event-{Guid.NewGuid():N}", + SessionId: context.SessionId, + TurnId: context.TurnId, + OccurredAtUtc: DateTimeOffset.UtcNow, + Request: envelope.Request); + + await eventPublisher.PublishAsync(startedEvent, cancellationToken: cancellationToken); + collectedEvents.Add(startedEvent); + + // 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..ccce59c --- /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 non-system 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..7e01f15 --- /dev/null +++ b/src/SharpClaw.Code.Telemetry/Export/NdjsonTraceFileSink.cs @@ -0,0 +1,75 @@ +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; + private readonly object _writeLock = new(); + private bool _disposed; + + /// + /// Initializes a new instance targeting the specified file path. + /// + /// The NDJSON output file path. + 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), + }; + var line = JsonSerializer.Serialize(entry); + + lock (_writeLock) + { + if (!_disposed) + { + _writer.WriteLine(line); + } + } + } + + /// + public void Dispose() + { + lock (_writeLock) + { + _disposed = true; + } + + _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 @@ +