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/README.md b/README.md index 209f549..4a74792 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ SharpClaw Code is a C# and .NET-native coding agent runtime for teams building AI developer tools, agentic CLIs, and MCP-enabled workflows. -It combines durable sessions, permission-aware tool execution, provider abstraction, structured telemetry, and an automation-friendly command-line surface in a codebase designed for production-quality .NET systems, not toy demos. +It combines durable sessions, permission-aware tool execution, provider abstraction, structured telemetry, and an automation-friendly command-line surface in a runtime shaped for real .NET systems: explicit, testable, and operationally legible. -## What SharpClaw Code Is +## What It Is SharpClaw Code is an open-source runtime for building and operating coding-agent experiences in the .NET ecosystem. @@ -27,7 +27,7 @@ It is designed for: - **Durable runtime model**: sessions, checkpoints, append-only event logs, export/import flows, and recovery-aware orchestration - **Safety by default**: permission modes, approval gates, workspace-boundary enforcement, and mediated tool execution - **Extensible surface**: providers, MCP servers, plugins, skills, ACP hosting, and runtime commands integrate through explicit seams -- **Good fit for automation**: JSON-friendly command output, stable operational commands, and a clean CLI-first workflow +- **Automation-ready interface**: stable JSON output, operational commands, and a clean CLI-first workflow that works for both humans and scripts ## Quick Start @@ -71,6 +71,23 @@ dotnet run --project src/SharpClaw.Code.Cli -- --output-format json doctor Built-in REPL slash commands include `/help`, `/status`, `/doctor`, `/session`, `/commands`, `/mode`, `/editor`, `/export`, `/undo`, `/redo`, and `/version`. Use `/help` to see the active command set, including discovered workspace custom commands. +Parity-oriented commands now include: + +- `models` / `/models` +- `usage` / `/usage` +- `cost` / `/cost` +- `stats` / `/stats` +- `connect` / `/connect` +- `hooks` / `/hooks` +- `skills` / `/skills` +- `agents` / `/agents` +- `todo` / `/todo` +- `share` / `/share` +- `unshare` / `/unshare` +- `compact` / `/compact` +- `serve` / `/serve` +- `/sessions` as a friendlier alias over `/session list` + Primary workflow modes: - `build`: normal coding-agent execution @@ -89,6 +106,10 @@ Primary workflow modes: | Structured telemetry | Emit runtime events and usage signals that support diagnostics, replay, and automation | | JSON-friendly CLI | Use the same runtime through human-readable terminal flows or machine-readable command output | | Spec workflow mode | Turn prompts into structured requirements, technical design, and task documents for feature proposals | +| Embedded local server | Expose prompt, session, status, doctor, and share endpoints for editor or automation clients | +| Config + agent catalog | Layer user/workspace JSONC config with typed agent defaults, tool allowlists, and runtime hooks | +| Session sharing | Create self-hosted share links and durable sanitized share snapshots under `.sharpclaw/` | +| Diagnostics context | Surface configured diagnostics sources into prompt context, status, and machine-readable output | ## Good Fit For @@ -96,7 +117,7 @@ Primary workflow modes: - running a **local or hosted coding-agent CLI** - creating a **.NET MCP client/runtime** - adding **session persistence and auditability** to agent workflows -- experimenting with **Agent Framework-backed orchestration** without pushing core runtime logic into the framework layer +- building on **Microsoft Agent Framework** without pushing your application core into framework-specific code ## Solution Layout @@ -145,8 +166,9 @@ dotnet test SharpClawCode.sln --filter "FullyQualifiedName~ParityScenarioTests" | `--output-format text\|json` | Human-readable or structured output | | `--primary-mode ` | Workflow bias for prompts: `build`, `plan`, or `spec` | | `--session ` | Reuse a specific SharpClaw session id for prompt execution | +| `--agent ` | Select the active agent for prompt execution | -Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `commands`, `mcp`, `plugins`, `acp`, `bridge`, and `version`. +Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `models`, `usage`, `cost`, `stats`, `connect`, `hooks`, `skills`, `agents`, `todo`, `share`, `unshare`, `compact`, `serve`, `commands`, `mcp`, `plugins`, `acp`, `bridge`, and `version`. ## Documentation Map @@ -167,7 +189,17 @@ Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `commands`, ## Configuration -SharpClaw Code uses the standard .NET configuration stack (`appsettings.json`, environment variables, CLI args). Key configuration sections: +SharpClaw Code uses both the standard .NET configuration stack (`appsettings.json`, environment variables, CLI args) and layered SharpClaw JSONC config files: + +- user config: `~/.config/sharpclaw/config.jsonc` on Unix-like systems +- Windows user config: `%AppData%\\SharpClaw\\config.jsonc` +- workspace config: `/sharpclaw.jsonc` + +Precedence is: + +`CLI args > workspace sharpclaw.jsonc > user config.jsonc > appsettings/environment defaults` + +Key runtime configuration sections: | Section | Purpose | |---|---| @@ -177,13 +209,25 @@ SharpClaw Code uses the standard .NET configuration stack (`appsettings.json`, e | `SharpClaw:Web` | Web search provider name, endpoint template, user agent | | `SharpClaw:Telemetry` | Runtime event ring buffer capacity | +Key `sharpclaw.jsonc` capabilities: + +- `shareMode`: `manual`, `auto`, or `disabled` +- `server`: host, port, and optional public base URL for share links +- `defaultAgentId`: default prompt agent +- `agents`: typed agent catalog entries with model defaults, tool allowlists, and instruction appendices +- `lspServers`: configured diagnostics sources +- `hooks`: lifecycle hooks for turn/tool/share/server events +- `connectLinks`: browser entry points for provider or external auth flows + All options are validated at startup via `IValidateOptions` implementations. ## Current Scope - The shared tooling layer is permission-aware across the runtime. -- The current Agent Framework bridge is focused on provider-backed runs rather than a full tool-calling loop inside the framework path. -- Operational commands support stable JSON output via `--output-format json`, which makes them useful in scripts and automation. +- The current runtime includes multi-turn provider-backed tool execution, session-backed prompt replay, and durable conversation history. +- Agent-driven tool calls flow through the same approval and allowlist enforcement path used by direct tool execution, including caller-aware interactive approval behavior. +- Operational commands support stable JSON output via `--output-format json`, which makes them suitable for scripts, editors, and automation. +- The embedded server exposes local JSON and SSE endpoints for prompts, sessions, sharing, status, and doctor flows. ## Contributing 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/architecture.md b/docs/architecture.md index 3507212..180086c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,7 +40,7 @@ Test projects: **UnitTests**, **IntegrationTests**, **MockProvider**, **ParityHa 4. **`SharpClawAgentBase`** delegates to **`AgentFrameworkBridge.RunAsync`**, which drives **`ProviderBackedAgentKernel`** (streaming `IModelProvider`, auth checks, **`ProviderExecutionException`** on hard failures). 5. Turn completion updates session, checkpoints as implemented in **`ConversationRuntime`**, publishes events via **`IRuntimeEventPublisher`**. -**Note:** `AgentRunContext` carries **`IToolExecutor`**, but the current **`AgentFrameworkBridge`** path does not attach SharpClaw tools to the Microsoft Agent Framework chat loop; **`AgentRunResult.ToolResults`** is empty in that bridge. Tools are still fully usable via **`IToolExecutor`** (tests and parity harness call it directly). +**Note:** `AgentRunContext` carries **`IToolExecutor`**, and the current **`AgentFrameworkBridge`** path advertises the resolved tool set to the provider, executes tool calls through the permission-aware executor, and records tool results in the agent run result. Prompt references and tool approvals respect the caller's normalized interactivity mode. ### Operational commands 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/runtime.md b/docs/runtime.md index 191c523..d0a5a93 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -3,7 +3,7 @@ The **runtime** layer is centered on **`SharpClaw.Code.Runtime`** and especially **`ConversationRuntime`**, which implements: - Session surface on **`IConversationRuntime`** — create/get session, **`RunPromptAsync`** -- **`IRuntimeCommandService`** — **`ExecutePromptAsync`**, **`GetStatusAsync`**, **`RunDoctorAsync`**, **`InspectSessionAsync`** +- **`IRuntimeCommandService`** — prompt execution plus status, doctor, session inspection, share/unshare, and compaction commands Registration: `RuntimeServiceCollectionExtensions.AddSharpClawRuntime`. @@ -15,6 +15,13 @@ Registration: `RuntimeServiceCollectionExtensions.AddSharpClawRuntime`. 2. Maps **`RunPromptRequest`** + session into **`AgentRunContext`** (session/turn ids, working directory, permission mode, output format, **`IToolExecutor`**, metadata). 3. Invokes **`PrimaryCodingAgent.RunAsync`**. +Before the agent runs, **`ConversationRuntime`** also layers in: + +- merged SharpClaw JSONC config (`ISharpClawConfigService`) +- resolved agent defaults (`IAgentCatalogService`) +- persisted active-agent metadata from the session, when present +- auto-share policy checks (`ShareMode.Auto`) + The agent stack is described in [agents.md](agents.md). ## Lifecycle and state @@ -24,7 +31,9 @@ The agent stack is described in [agents.md](agents.md). ## Context assembly -**`PromptContextAssembler`** pulls workspace/session-aware data (skills registry, memory hooks, git context as wired today) into the prompt path before the agent runs. +**`PromptContextAssembler`** pulls workspace/session-aware data (skills registry, todo state, memory hooks, git context as wired today) into the prompt path before the agent runs. + +It also includes a compact diagnostics summary from **`IWorkspaceDiagnosticsService`**, which currently surfaces configured diagnostics sources and build-derived findings for .NET workspaces. When the effective **`PrimaryMode`** is **`Spec`**, the assembler appends a structured output contract that requires the model to return machine-readable requirements, design, and task content. @@ -50,6 +59,41 @@ Each spec-mode prompt creates a fresh folder. If the same slug already exists, t Used by **`GetStatusAsync`**, **`RunDoctorAsync`**, and **`InspectSessionAsync`** to build **Protocol** reports (`DoctorReport`, `RuntimeStatusReport`, `SessionInspectionReport`). +`RuntimeStatusReport` now also carries: + +- configured diagnostics source count +- current diagnostic error count +- current diagnostic warning count + +## Config, agents, and hooks + +The parity layer adds several runtime-owned services: + +- **`ISharpClawConfigService`** — loads user/workspace `config.jsonc` + `sharpclaw.jsonc` and merges them by precedence +- **`IAgentCatalogService`** — overlays configured specialist agents on top of built-in agents +- **`IConversationCompactionService`** — creates durable session summaries stored in session metadata +- **`IShareSessionService`** — creates and removes self-hosted share snapshots +- **`IHookDispatcher`** — executes configured hook processes for turn/tool/share/server events and exposes hook inspection/testing +- **`ITodoService`** — persists session and workspace todo items under session metadata and `.sharpclaw/tasks.json` +- **`IWorkspaceInsightsService`** — reconstructs durable usage, cost, and execution stats from persisted event logs + +These services are intentionally small and runtime-owned rather than separate orchestration subsystems. + +## Embedded server + +**`IWorkspaceHttpServer`** / **`WorkspaceHttpServer`** expose a minimal local HTTP surface for editor and automation clients: + +- `POST /v1/prompt` +- `GET /v1/sessions` +- `GET /v1/sessions/{id}` +- `POST /v1/share/{sessionId}` +- `DELETE /v1/share/{sessionId}` +- `GET /v1/status` +- `GET /v1/doctor` +- `GET /s/{shareId}` + +Prompt requests can return JSON or replay the completed runtime event stream as SSE. + ## Hosted service **`RuntimeCoordinatorHostedServiceAdapter`** is registered as **`IHostedService`** and currently logs start/stop only (placeholder for future lifecycle coordination). @@ -62,3 +106,7 @@ Used by **`GetStatusAsync`**, **`RunDoctorAsync`**, and **`InspectSessionAsync`* | `DefaultTurnRunner` | `src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs` | | `OperationalDiagnosticsCoordinator` | `src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs` | | `IRuntimeCommandService` | `src/SharpClaw.Code.Runtime/Abstractions/IRuntimeCommandService.cs` | +| `SharpClawConfigService` | `src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs` | +| `ShareSessionService` | `src/SharpClaw.Code.Runtime/Workflow/ShareSessionService.cs` | +| `ConversationCompactionService` | `src/SharpClaw.Code.Runtime/Workflow/ConversationCompactionService.cs` | +| `WorkspaceHttpServer` | `src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs` | 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/AcpStdioHost.cs b/src/SharpClaw.Code.Acp/AcpStdioHost.cs index 749b0d3..652ee6d 100644 --- a/src/SharpClaw.Code.Acp/AcpStdioHost.cs +++ b/src/SharpClaw.Code.Acp/AcpStdioHost.cs @@ -190,7 +190,8 @@ private async Task HandleSessionPromptAsync( WorkingDirectory: workspace, PermissionMode: PermissionMode.WorkspaceWrite, OutputFormat: OutputFormat.Json, - Metadata: new Dictionary { ["acp"] = "true" }), + Metadata: new Dictionary { ["acp"] = "true" }, + IsInteractive: false), cancellationToken) .ConfigureAwait(false); 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/Agents/SharpClawAgentBase.cs b/src/SharpClaw.Code.Agents/Agents/SharpClawAgentBase.cs index aa91de5..c83e440 100644 --- a/src/SharpClaw.Code.Agents/Agents/SharpClawAgentBase.cs +++ b/src/SharpClaw.Code.Agents/Agents/SharpClawAgentBase.cs @@ -1,5 +1,6 @@ using SharpClaw.Code.Agents.Abstractions; using SharpClaw.Code.Agents.Models; +using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.Agents.Agents; @@ -31,13 +32,23 @@ public abstract class SharpClawAgentBase(IAgentFrameworkBridge agentFrameworkBri /// public virtual Task RunAsync(AgentRunContext context, CancellationToken cancellationToken) - => agentFrameworkBridge.RunAsync( + { + var instructions = Instructions; + if (context.Metadata is not null + && context.Metadata.TryGetValue(SharpClawWorkflowMetadataKeys.AgentInstructionAppendix, out var appendix) + && !string.IsNullOrWhiteSpace(appendix)) + { + instructions = $"{instructions}{Environment.NewLine}{Environment.NewLine}{appendix.Trim()}"; + } + + return agentFrameworkBridge.RunAsync( new AgentFrameworkRequest( AgentId, AgentKind, Name, Description, - Instructions, + instructions, context), cancellationToken); + } } 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..5363022 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,43 +83,184 @@ 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)) + var iteration = 0; + for (; iteration < options.MaxToolIterations; iteration++) { - providerEvents.Add(providerEvent); + UsageSnapshot? iterationUsage = null; + + var providerRequest = providerRequestPreflight.Prepare(new ProviderRequest( + Id: $"provider-request-{Guid.NewGuid():N}", + SessionId: request.Context.SessionId, + TurnId: request.Context.TurnId, + ProviderName: resolvedProviderName, + Model: requestedModel, + Prompt: request.Context.Prompt, + SystemPrompt: request.Instructions, + OutputFormat: request.Context.OutputFormat, + Temperature: 0.1m, + Metadata: baseMetadata, + Messages: messages, + Tools: availableTools, + MaxTokens: options.MaxTokensPerRequest)); + + lastProviderRequest = providerRequest; + + var iterationTextSegments = new List(); + var toolUseEvents = new List(); + + using var providerScope = new ProviderActivityScope(resolvedProviderName, requestedModel, providerRequest.Id); + var providerSw = Stopwatch.StartNew(); + try + { + var stream = await provider.StartStreamAsync(providerRequest, cancellationToken).ConfigureAwait(false); + + await foreach (var providerEvent in stream.Events.WithCancellation(cancellationToken)) + { + allProviderEvents.Add(providerEvent); + + if (!providerEvent.IsTerminal && !string.IsNullOrWhiteSpace(providerEvent.Content)) + { + iterationTextSegments.Add(providerEvent.Content); + } + + if (!string.IsNullOrEmpty(providerEvent.ToolUseId) && !string.IsNullOrEmpty(providerEvent.ToolName)) + { + toolUseEvents.Add(providerEvent); + } + + if (providerEvent.IsTerminal && 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; + } - if (!providerEvent.IsTerminal && !string.IsNullOrWhiteSpace(providerEvent.Content)) + // Build assistant message with text + tool-use content blocks + var assistantBlocks = new List(); + var iterationText = string.Concat(iterationTextSegments); + if (!string.IsNullOrEmpty(iterationText)) { - outputSegments.Add(providerEvent.Content); + assistantBlocks.Add(new ContentBlock(ContentBlockKind.Text, iterationText, null, null, null, null)); } - if (providerEvent.IsTerminal && providerEvent.Usage is not null) + foreach (var toolUseEvent in toolUseEvents) { - terminalUsage = providerEvent.Usage; + assistantBlocks.Add(new ContentBlock( + ContentBlockKind.ToolUse, + null, + toolUseEvent.ToolUseId, + toolUseEvent.ToolName, + toolUseEvent.ToolInputJson, + null)); + } + + messages.Add(new ChatMessage("assistant", assistantBlocks)); + + // Dispatch each tool call and collect results + var toolResultBlocks = new List(); + foreach (var toolUseEvent in toolUseEvents) + { + if (toolExecutionContext is null) + { + // No tool execution context means we cannot dispatch tools + toolResultBlocks.Add(new ContentBlock( + ContentBlockKind.ToolResult, + "Tool execution is not available in this context.", + toolUseEvent.ToolUseId, + null, + null, + true)); + continue; + } + + var (resultBlock, toolResult, events) = await toolCallDispatcher.DispatchAsync( + toolUseEvent, + toolExecutionContext, + cancellationToken).ConfigureAwait(false); + + toolResultBlocks.Add(resultBlock); + allToolResults.Add(toolResult); + allToolEvents.AddRange(events); } + + messages.Add(new ChatMessage("user", toolResultBlocks)); + + // Accumulate partial text from tool-calling iterations + if (!string.IsNullOrEmpty(iterationText)) + { + outputSegments.Add(iterationText); + } + } + + // Detect if the loop was exhausted (provider kept requesting tools every iteration). + if (iteration >= options.MaxToolIterations) + { + logger.LogWarning( + "Tool-calling loop reached maximum iterations ({MaxIterations}) for session {SessionId}; output may be incomplete.", + options.MaxToolIterations, + request.Context.SessionId); + outputSegments.Add($"\n\n[Tool-calling loop reached the maximum of {options.MaxToolIterations} iterations. Output may be incomplete.]"); } var output = string.Concat(outputSegments); @@ -106,9 +268,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 +283,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 +304,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..f2b3d6b --- /dev/null +++ b/src/SharpClaw.Code.Agents/Internal/ToolCallDispatcher.cs @@ -0,0 +1,69 @@ +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +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) +{ + /// + /// 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; + + // Execute the tool — ToolExecutor already publishes ToolStartedEvent and ToolCompletedEvent + // via IRuntimeEventPublisher, so we do NOT re-publish here to avoid duplicates. + var envelope = await toolExecutor.ExecuteAsync(toolName, toolInputJson, context, cancellationToken); + + // 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); + } + + // Events are already published by ToolExecutor; return empty list to avoid duplicates. + return (resultBlock, envelope.Result, []); + } +} diff --git a/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs b/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs index 09b01a1..c1955b0 100644 --- a/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs +++ b/src/SharpClaw.Code.Agents/Models/AgentRunContext.cs @@ -20,6 +20,11 @@ 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. +/// +/// Whether tool approvals can interact with the caller. public sealed record AgentRunContext( string SessionId, string TurnId, @@ -33,4 +38,6 @@ public sealed record AgentRunContext( string? ParentAgentId = null, DelegatedTaskContract? DelegatedTask = null, PrimaryMode PrimaryMode = PrimaryMode.Build, - IToolMutationRecorder? ToolMutationRecorder = null); + IToolMutationRecorder? ToolMutationRecorder = null, + IReadOnlyList? ConversationHistory = null, + bool IsInteractive = true); diff --git a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs index 90a0709..8bb9073 100644 --- a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs +++ b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs @@ -1,12 +1,20 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using System.Text.Json; using SharpClaw.Code.Agents.Abstractions; 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.Protocol.Serialization; +using SharpClaw.Code.Tools.Abstractions; +using SharpClaw.Code.Tools.Models; +using ChatMessage = Microsoft.Extensions.AI.ChatMessage; namespace SharpClaw.Code.Agents.Services; @@ -15,6 +23,7 @@ namespace SharpClaw.Code.Agents.Services; /// public sealed class AgentFrameworkBridge( ProviderBackedAgentKernel providerBackedAgentKernel, + IToolRegistry toolRegistry, ISystemClock systemClock, ILogger logger) : IAgentFrameworkBridge { @@ -22,6 +31,35 @@ public sealed class AgentFrameworkBridge( public async Task RunAsync(AgentFrameworkRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); + var allowedTools = ResolveAllowedTools(request.Context.Metadata); + + // 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: allowedTools, + AllowDangerousBypass: false, + IsInteractive: request.Context.IsInteractive, + 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 = FilterAdvertisedTools(registryTools, allowedTools) + .Select(t => new ProviderToolDefinition(t.Name, t.Description, t.InputSchemaJson)) + .ToList(); ProviderInvocationResult? providerResult = null; var frameworkAgent = new SharpClawFrameworkAgent( @@ -30,7 +68,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 +100,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 +122,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 +136,38 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel Summary: resolvedProviderResult.Summary, ProviderRequest: resolvedProviderResult.ProviderRequest, ProviderEvents: resolvedProviderResult.ProviderEvents, - ToolResults: [], + ToolResults: resolvedProviderResult.ToolResults ?? [], Events: events); } + + private static IReadOnlyCollection? ResolveAllowedTools(IReadOnlyDictionary? metadata) + { + if (metadata is null + || !metadata.TryGetValue(SharpClawWorkflowMetadataKeys.AgentAllowedToolsJson, out var payload) + || string.IsNullOrWhiteSpace(payload)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(payload, ProtocolJsonContext.Default.StringArray); + } + catch (JsonException) + { + return null; + } + } + + private static IEnumerable FilterAdvertisedTools( + IReadOnlyList registryTools, + IReadOnlyCollection? allowedTools) + { + if (allowedTools is null || allowedTools.Count == 0) + { + return registryTools; + } + + return registryTools.Where(tool => allowedTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase)); + } } 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/Composition/CliServiceCollectionExtensions.cs b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs index 528d919..631b7d7 100644 --- a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs @@ -26,12 +26,26 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -41,7 +55,21 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); 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/CliCommandFactory.cs b/src/SharpClaw.Code.Commands/CliCommandFactory.cs index 8f5d69d..6239a93 100644 --- a/src/SharpClaw.Code.Commands/CliCommandFactory.cs +++ b/src/SharpClaw.Code.Commands/CliCommandFactory.cs @@ -110,5 +110,6 @@ private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext co context.PermissionMode, context.OutputFormat, context.PrimaryMode, - context.SessionId); + context.SessionId, + context.AgentId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/AgentsCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/AgentsCommandHandler.cs new file mode 100644 index 0000000..c8aa72e --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/AgentsCommandHandler.cs @@ -0,0 +1,99 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Lists the effective agent catalog and manages the REPL agent override. +/// +public sealed class AgentsCommandHandler( + IAgentCatalogService agentCatalogService, + ReplInteractionState replInteractionState, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "agents"; + + /// + public string Description => "Lists agents and selects the active REPL agent override."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + + var list = new Command("list", "Lists the effective agent catalog."); + list.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(list); + + var use = new Command("use", "Sets the current process REPL agent override."); + var idOption = new Option("--id") { Required = true, Description = "Agent id to activate." }; + use.Options.Add(idOption); + use.SetAction(async (parseResult, cancellationToken) => + { + var context = globalOptions.Resolve(parseResult); + var id = parseResult.GetValue(idOption) ?? throw new InvalidOperationException("--id is required."); + return await ExecuteUseAsync(id, context, cancellationToken).ConfigureAwait(false); + }); + command.Subcommands.Add(use); + + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length >= 2 && string.Equals(command.Arguments[0], "use", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteUseAsync(command.Arguments[1], context, cancellationToken); + } + + return ExecuteListAsync(context, cancellationToken); + } + + private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var agents = await agentCatalogService.ListAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + var message = replInteractionState.AgentIdOverride is null + ? $"{agents.Count} agent(s)." + : $"{agents.Count} agent(s). Active REPL agent override: {replInteractionState.AgentIdOverride}."; + var result = new CommandResult( + true, + 0, + context.OutputFormat, + message, + JsonSerializer.Serialize(agents.ToList(), ProtocolJsonContext.Default.ListAgentCatalogEntry)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteUseAsync(string agentId, CommandExecutionContext context, CancellationToken cancellationToken) + { + var agents = await agentCatalogService.ListAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + if (!agents.Any(agent => string.Equals(agent.Id, agentId, StringComparison.OrdinalIgnoreCase))) + { + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(false, 1, context.OutputFormat, $"Unknown agent '{agentId}'.", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 1; + } + + replInteractionState.AgentIdOverride = agentId; + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"Active REPL agent set to {agentId}.", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs new file mode 100644 index 0000000..83a042a --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs @@ -0,0 +1,59 @@ +using System.CommandLine; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Compacts a session into a durable summary and refreshed title. +/// +public sealed class CompactCommandHandler( + IRuntimeCommandService runtimeCommandService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "compact"; + + /// + public string Description => "Compacts a session into a reusable summary."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + var idOption = new Option("--id") { Description = "Session id; latest when omitted." }; + command.Options.Add(idOption); + command.SetAction(async (parseResult, cancellationToken) => + { + var context = globalOptions.Resolve(parseResult); + var id = parseResult.GetValue(idOption); + var result = await runtimeCommandService.CompactSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + }); + return command; + } + + /// + public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + var id = command.Arguments.Length > 0 ? command.Arguments[0] : null; + var result = await runtimeCommandService.CompactSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + } + + private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) + => new( + context.WorkingDirectory, + context.Model, + context.PermissionMode, + context.OutputFormat, + context.PrimaryMode, + context.SessionId, + context.AgentId); +} diff --git a/src/SharpClaw.Code.Commands/Handlers/ConnectCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ConnectCommandHandler.cs new file mode 100644 index 0000000..ed1429f --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/ConnectCommandHandler.cs @@ -0,0 +1,146 @@ +using System.CommandLine; +using System.Diagnostics; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Lists and opens browser-based provider or external connection entry points. +/// +public sealed class ConnectCommandHandler( + ISharpClawConfigService sharpClawConfigService, + IEnumerable modelProviders, + IAuthFlowService authFlowService, + IPathService pathService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "connect"; + + /// + public string Description => "Lists connection targets and opens configured browser flows."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + + var list = new Command("list", "Lists configured connection targets."); + list.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(list); + + var open = new Command("open", "Opens a configured browser connection target."); + var targetOption = new Option("--target") { Required = true, Description = "Target id to open." }; + open.Options.Add(targetOption); + open.SetAction(async (parseResult, cancellationToken) => + { + var context = globalOptions.Resolve(parseResult); + var target = parseResult.GetValue(targetOption) ?? throw new InvalidOperationException("--target is required."); + return await ExecuteOpenAsync(target, context, cancellationToken).ConfigureAwait(false); + }); + command.Subcommands.Add(open); + + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length >= 2 && string.Equals(command.Arguments[0], "open", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteOpenAsync(command.Arguments[1], context, cancellationToken); + } + + return ExecuteListAsync(context, cancellationToken); + } + + private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var statuses = await BuildStatusesAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + $"{statuses.Count} connection target(s).", + JsonSerializer.Serialize(statuses, ProtocolJsonContext.Default.ListConnectTargetStatus)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteOpenAsync(string target, CommandExecutionContext context, CancellationToken cancellationToken) + { + var statuses = await BuildStatusesAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + var status = statuses.FirstOrDefault(item => string.Equals(item.Target, target, StringComparison.OrdinalIgnoreCase)); + if (status is null || string.IsNullOrWhiteSpace(status.ConnectUrl)) + { + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(false, 1, context.OutputFormat, $"No browser-connectable target '{target}' was found.", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 1; + } + + OpenBrowser(status.ConnectUrl!); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"Opened {status.ConnectUrl}.", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task> BuildStatusesAsync(string workspacePath, CancellationToken cancellationToken) + { + var workspaceRoot = pathService.GetFullPath(workspacePath); + var config = await sharpClawConfigService.GetConfigAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + var results = new List(); + + foreach (var provider in modelProviders.OrderBy(static provider => provider.ProviderName, StringComparer.OrdinalIgnoreCase)) + { + var auth = await authFlowService.GetStatusAsync(provider.ProviderName, cancellationToken).ConfigureAwait(false); + var configuredUrl = config.Document.ConnectLinks?.FirstOrDefault(link => string.Equals(link.Target, provider.ProviderName, StringComparison.OrdinalIgnoreCase))?.Url; + var detail = auth.IsAuthenticated + ? string.IsNullOrWhiteSpace(auth.SubjectId) + ? "authenticated" + : $"authenticated as {auth.SubjectId}" + : auth.ExpiresAtUtc is { } expiresAt && expiresAt <= DateTimeOffset.UtcNow + ? "authentication expired" + : "not authenticated"; + results.Add(new ConnectTargetStatus(provider.ProviderName, provider.ProviderName, "provider", auth.IsAuthenticated, configuredUrl, auth.ExpiresAtUtc, detail)); + } + + foreach (var link in config.Document.ConnectLinks ?? []) + { + if (results.Any(existing => string.Equals(existing.Target, link.Target, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + results.Add(new ConnectTargetStatus(link.Target, link.DisplayName, "external", false, link.Url, null, "manual browser flow")); + } + + return results.OrderBy(static item => item.Target, StringComparer.OrdinalIgnoreCase).ToList(); + } + + private static void OpenBrowser(string url) + { + var startInfo = OperatingSystem.IsWindows() + ? new ProcessStartInfo("cmd", $"/c start \"\" \"{url}\"") { CreateNoWindow = true } + : OperatingSystem.IsMacOS() + ? new ProcessStartInfo("open", url) + : new ProcessStartInfo("xdg-open", url); + startInfo.UseShellExecute = false; + Process.Start(startInfo); + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/CostCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/CostCommandHandler.cs new file mode 100644 index 0000000..3c5a2cb --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/CostCommandHandler.cs @@ -0,0 +1,57 @@ +using System.CommandLine; +using System.Globalization; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Surfaces estimated workspace cost based on persisted usage snapshots. +/// +public sealed class CostCommandHandler( + IWorkspaceInsightsService workspaceInsightsService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "cost"; + + /// + public string Description => "Shows estimated usage cost for the current workspace."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + command.SetAction((parseResult, cancellationToken) => ExecuteAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + => ExecuteAsync(context, cancellationToken); + + private async Task ExecuteAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var report = await workspaceInsightsService + .BuildCostReportAsync(context.WorkingDirectory, context.SessionId, cancellationToken) + .ConfigureAwait(false); + var total = report.WorkspaceEstimatedCostUsd.HasValue + ? report.WorkspaceEstimatedCostUsd.Value.ToString("0.0000", CultureInfo.InvariantCulture) + : "n/a"; + var result = new CommandResult( + true, + 0, + context.OutputFormat, + $"Workspace estimated cost: ${total} across {report.Sessions.Count} session(s).", + JsonSerializer.Serialize(report, ProtocolJsonContext.Default.WorkspaceCostReport)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/HooksCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/HooksCommandHandler.cs new file mode 100644 index 0000000..44321b4 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/HooksCommandHandler.cs @@ -0,0 +1,128 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Lists and tests configured runtime hooks. +/// +public sealed class HooksCommandHandler( + IHookDispatcher hookDispatcher, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "hooks"; + + /// + public string Description => "Lists, inspects, and tests configured runtime hooks."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + command.Subcommands.Add(BuildListCommand(globalOptions)); + command.Subcommands.Add(BuildShowCommand(globalOptions)); + command.Subcommands.Add(BuildTestCommand(globalOptions)); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + => command.Arguments.Length switch + { + 0 => ExecuteListAsync(context, cancellationToken), + _ when string.Equals(command.Arguments[0], "show", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2 + => ExecuteShowAsync(command.Arguments[1], context, cancellationToken), + _ when string.Equals(command.Arguments[0], "test", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2 + => ExecuteTestAsync(command.Arguments[1], context, cancellationToken), + _ when string.Equals(command.Arguments[0], "list", StringComparison.OrdinalIgnoreCase) + => ExecuteListAsync(context, cancellationToken), + _ => ExecuteListAsync(context, cancellationToken) + }; + + private Command BuildListCommand(GlobalCliOptions globalOptions) + { + var command = new Command("list", "Lists configured hooks."); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildShowCommand(GlobalCliOptions globalOptions) + { + var command = new Command("show", "Shows one configured hook."); + var nameOption = new Option("--name") { Required = true, Description = "Hook name." }; + command.Options.Add(nameOption); + command.SetAction((parseResult, cancellationToken) => ExecuteShowAsync(parseResult.GetValue(nameOption)!, globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildTestCommand(GlobalCliOptions globalOptions) + { + var command = new Command("test", "Executes one configured hook with a synthetic payload."); + var nameOption = new Option("--name") { Required = true, Description = "Hook name." }; + command.Options.Add(nameOption); + command.SetAction((parseResult, cancellationToken) => ExecuteTestAsync(parseResult.GetValue(nameOption)!, globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var hooks = await hookDispatcher.ListAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + hooks.Count == 0 ? "No hooks configured." : $"{hooks.Count} hook(s).", + JsonSerializer.Serialize(hooks.ToList(), ProtocolJsonContext.Default.ListHookStatusRecord)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteShowAsync(string name, CommandExecutionContext context, CancellationToken cancellationToken) + { + var hooks = await hookDispatcher.ListAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + var hook = hooks.FirstOrDefault(item => string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase)); + if (hook is null) + { + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(false, 1, context.OutputFormat, $"Hook '{name}' was not found.", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 1; + } + + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"{hook.Name} ({hook.Trigger})", JsonSerializer.Serialize(hook, ProtocolJsonContext.Default.HookStatusRecord)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteTestAsync(string name, CommandExecutionContext context, CancellationToken cancellationToken) + { + var payloadJson = JsonSerializer.Serialize( + new Dictionary + { + ["kind"] = "hook-test", + ["workspaceRoot"] = context.WorkingDirectory, + ["requestedAtUtc"] = DateTimeOffset.UtcNow.ToString("O"), + }, + ProtocolJsonContext.Default.DictionaryStringString); + var test = await hookDispatcher.TestAsync(context.WorkingDirectory, name, payloadJson, cancellationToken).ConfigureAwait(false); + var exitCode = test.Succeeded ? 0 : 1; + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(test.Succeeded, exitCode, context.OutputFormat, test.Message, JsonSerializer.Serialize(test, ProtocolJsonContext.Default.HookTestResult)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return exitCode; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs new file mode 100644 index 0000000..5cdc014 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs @@ -0,0 +1,83 @@ +using System.CommandLine; +using System.Text.Json; +using Microsoft.Extensions.Options; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Providers.Configuration; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; + +namespace SharpClaw.Code.Commands; + +/// +/// Lists the configured provider/model surface available to SharpClaw. +/// +public sealed class ModelsCommandHandler( + IEnumerable modelProviders, + IAuthFlowService authFlowService, + IOptions providerCatalogOptions, + IOptions anthropicOptions, + IOptions openAiCompatibleOptions, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "models"; + + /// + public string Description => "Lists provider defaults, aliases, and authentication status."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + command.SetAction((parseResult, cancellationToken) => ExecuteAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + => ExecuteAsync(context, cancellationToken); + + private async Task ExecuteAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var entries = new List(); + var aliasesByProvider = providerCatalogOptions.Value.ModelAliases + .GroupBy(static pair => pair.Value.ProviderName, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + static group => group.Key, + static group => group.Select(pair => pair.Key).OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase).ToArray(), + StringComparer.OrdinalIgnoreCase); + + foreach (var provider in modelProviders.OrderBy(static provider => provider.ProviderName, StringComparer.OrdinalIgnoreCase)) + { + var auth = await authFlowService.GetStatusAsync(provider.ProviderName, cancellationToken).ConfigureAwait(false); + entries.Add( + new ProviderModelCatalogEntry( + provider.ProviderName, + ResolveDefaultModel(provider.ProviderName), + aliasesByProvider.TryGetValue(provider.ProviderName, out var aliases) ? aliases : [], + auth)); + } + + var result = new CommandResult( + true, + 0, + context.OutputFormat, + $"{entries.Count} provider model surface(s).", + JsonSerializer.Serialize(entries, ProtocolJsonContext.Default.ListProviderModelCatalogEntry)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + + private string ResolveDefaultModel(string providerName) + => string.Equals(providerName, anthropicOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) + ? anthropicOptions.Value.DefaultModel + : string.Equals(providerName, openAiCompatibleOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) + ? openAiCompatibleOptions.Value.DefaultModel + : "default"; +} diff --git a/src/SharpClaw.Code.Commands/Handlers/PluginsCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/PluginsCommandHandler.cs index 422bed8..0ba977c 100644 --- a/src/SharpClaw.Code.Commands/Handlers/PluginsCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/PluginsCommandHandler.cs @@ -14,6 +14,7 @@ namespace SharpClaw.Code.Commands; /// public sealed class PluginsCommandHandler( IPluginManager pluginManager, + IPluginManifestImportService pluginManifestImportService, OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler { /// @@ -29,6 +30,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) command.Subcommands.Add(BuildListCommand(globalOptions)); command.Subcommands.Add(BuildInstallCommand(globalOptions, isUpdate: false)); command.Subcommands.Add(BuildInstallCommand(globalOptions, isUpdate: true)); + command.Subcommands.Add(BuildImportCommand(globalOptions)); command.Subcommands.Add(BuildEnableCommand(globalOptions)); command.Subcommands.Add(BuildDisableCommand(globalOptions)); command.Subcommands.Add(BuildUninstallCommand(globalOptions)); @@ -126,6 +128,53 @@ private Command BuildUninstallCommand(GlobalCliOptions globalOptions) return command; } + private Command BuildImportCommand(GlobalCliOptions globalOptions) + { + var command = new Command("import", "Imports an external manifest into the local SharpClaw plugin format."); + var manifestOption = new Option("--manifest") + { + Required = true, + Description = "The path to the external or SharpClaw plugin manifest JSON file." + }; + var formatOption = new Option("--format") + { + Description = "Manifest format hint: auto, sharpclaw, or external." + }; + var updateOption = new Option("--update") + { + Description = "Updates the plugin when already installed." + }; + + command.Options.Add(manifestOption); + command.Options.Add(formatOption); + command.Options.Add(updateOption); + command.SetAction(async (parseResult, cancellationToken) => + { + var context = globalOptions.Resolve(parseResult); + var manifestPath = parseResult.GetValue(manifestOption) ?? throw new InvalidOperationException("The --manifest option is required."); + var format = parseResult.GetValue(formatOption); + var update = parseResult.GetValue(updateOption); + var (request, importResult) = await pluginManifestImportService.ImportAsync(manifestPath, format, cancellationToken).ConfigureAwait(false); + var plugin = update + ? await pluginManager.UpdateAsync(context.WorkingDirectory, request, cancellationToken).ConfigureAwait(false) + : await pluginManager.InstallAsync(context.WorkingDirectory, request, cancellationToken).ConfigureAwait(false); + + var message = importResult.Warnings.Length == 0 + ? $"Imported plugin '{plugin.Descriptor.Id}' from {importResult.SourceFormat} manifest." + : $"Imported plugin '{plugin.Descriptor.Id}' from {importResult.SourceFormat} manifest with {importResult.Warnings.Length} warning(s)."; + var result = new CommandResult( + true, + 0, + context.OutputFormat, + message, + JsonSerializer.Serialize(importResult, ProtocolJsonContext.Default.ImportedPluginManifestResult)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + }); + + return command; + } + private Command BuildStateCommand( string name, string description, diff --git a/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs index d30f7d3..024d9c2 100644 --- a/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs @@ -61,7 +61,8 @@ private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext co context.PermissionMode, context.OutputFormat, context.PrimaryMode, - context.SessionId); + context.SessionId, + context.AgentId); private static CommandResult CreateProviderFailureResult(ProviderExecutionException exception, OutputFormat outputFormat) => new( diff --git a/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs new file mode 100644 index 0000000..5b17bf3 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs @@ -0,0 +1,82 @@ +using System.CommandLine; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Hosts the embedded SharpClaw HTTP server for local editor and automation clients. +/// +public sealed class ServeCommandHandler( + IWorkspaceHttpServer workspaceHttpServer, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "serve"; + + /// + public string Description => "Runs the embedded SharpClaw HTTP server."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + var hostOption = new Option("--host") { Description = "Optional bind host override." }; + var portOption = new Option("--port") { Description = "Optional bind port override." }; + command.Options.Add(hostOption); + command.Options.Add(portOption); + command.SetAction(async (parseResult, cancellationToken) => + { + var context = globalOptions.Resolve(parseResult); + var host = parseResult.GetValue(hostOption); + var port = parseResult.GetValue(portOption); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, "Starting embedded SharpClaw server. Press Ctrl+C to stop.", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + await workspaceHttpServer + .RunAsync(context.WorkingDirectory, host, port, ToRuntimeContext(context), cancellationToken) + .ConfigureAwait(false); + return 0; + }); + return command; + } + + /// + public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + string? host = null; + int? port = null; + if (command.Arguments.Length >= 1) + { + host = command.Arguments[0]; + } + + if (command.Arguments.Length >= 2 && int.TryParse(command.Arguments[1], out var parsedPort)) + { + port = parsedPort; + } + + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, "Starting embedded SharpClaw server. Press Ctrl+C to stop.", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + await workspaceHttpServer.RunAsync(context.WorkingDirectory, host, port, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + return 0; + } + + private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) + => new( + context.WorkingDirectory, + context.Model, + context.PermissionMode, + context.OutputFormat, + context.PrimaryMode, + context.SessionId, + context.AgentId); +} diff --git a/src/SharpClaw.Code.Commands/Handlers/SessionsSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/SessionsSlashCommandHandler.cs new file mode 100644 index 0000000..e0e264f --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/SessionsSlashCommandHandler.cs @@ -0,0 +1,24 @@ +using SharpClaw.Code.Commands.Models; + +namespace SharpClaw.Code.Commands; + +/// +/// Provides a friendlier plural alias over the session slash command. +/// +public sealed class SessionsSlashCommandHandler(SessionCommandHandler sessionCommandHandler) : ISlashCommandHandler +{ + /// + public string CommandName => "sessions"; + + /// + public string Description => "Alias for /session list and /session show."; + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + var translated = command.Arguments.Length == 0 + ? new SlashCommandParseResult(true, "session", ["list"]) + : new SlashCommandParseResult(true, "session", command.Arguments); + return sessionCommandHandler.ExecuteAsync(translated, context, cancellationToken); + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs new file mode 100644 index 0000000..0b8afe0 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs @@ -0,0 +1,59 @@ +using System.CommandLine; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Creates or refreshes a self-hosted share snapshot for a session. +/// +public sealed class ShareCommandHandler( + IRuntimeCommandService runtimeCommandService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "share"; + + /// + public string Description => "Creates or refreshes a self-hosted share link for a session."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + var idOption = new Option("--id") { Description = "Session id; latest when omitted." }; + command.Options.Add(idOption); + command.SetAction(async (parseResult, cancellationToken) => + { + var context = globalOptions.Resolve(parseResult); + var id = parseResult.GetValue(idOption); + var result = await runtimeCommandService.ShareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + }); + return command; + } + + /// + public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + var id = command.Arguments.Length > 0 ? command.Arguments[0] : null; + var result = await runtimeCommandService.ShareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + } + + private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) + => new( + context.WorkingDirectory, + context.Model, + context.PermissionMode, + context.OutputFormat, + context.PrimaryMode, + context.SessionId, + context.AgentId); +} diff --git a/src/SharpClaw.Code.Commands/Handlers/SkillsCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/SkillsCommandHandler.cs new file mode 100644 index 0000000..4894bd3 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/SkillsCommandHandler.cs @@ -0,0 +1,156 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Skills.Abstractions; +using SharpClaw.Code.Skills.Models; + +namespace SharpClaw.Code.Commands; + +/// +/// Manages workspace-local skills under .sharpclaw/skills. +/// +public sealed class SkillsCommandHandler( + ISkillRegistry skillRegistry, + IFileSystem fileSystem, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "skills"; + + /// + public string Description => "Lists, installs, inspects, and removes local skills."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + command.Subcommands.Add(BuildListCommand(globalOptions)); + command.Subcommands.Add(BuildShowCommand(globalOptions)); + command.Subcommands.Add(BuildInstallCommand(globalOptions)); + command.Subcommands.Add(BuildUninstallCommand(globalOptions)); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + => command.Arguments.Length switch + { + 0 => ExecuteListAsync(context, cancellationToken), + _ when string.Equals(command.Arguments[0], "list", StringComparison.OrdinalIgnoreCase) + => ExecuteListAsync(context, cancellationToken), + _ when string.Equals(command.Arguments[0], "show", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2 + => ExecuteShowAsync(command.Arguments[1], context, cancellationToken), + _ when string.Equals(command.Arguments[0], "install", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2 + => ExecuteInstallAsync(command.Arguments[1], context, cancellationToken), + _ when string.Equals(command.Arguments[0], "uninstall", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 2 + => ExecuteUninstallAsync(command.Arguments[1], context, cancellationToken), + _ => ExecuteListAsync(context, cancellationToken) + }; + + private Command BuildListCommand(GlobalCliOptions globalOptions) + { + var command = new Command("list", "Lists installed skills."); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildShowCommand(GlobalCliOptions globalOptions) + { + var command = new Command("show", "Shows a skill manifest and prompt."); + var idOption = new Option("--id") { Required = true, Description = "Skill id or name." }; + command.Options.Add(idOption); + command.SetAction((parseResult, cancellationToken) => ExecuteShowAsync(parseResult.GetValue(idOption)!, globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildInstallCommand(GlobalCliOptions globalOptions) + { + var command = new Command("install", "Installs a skill from a JSON manifest."); + var manifestOption = new Option("--manifest") { Required = true, Description = "Path to a serialized SkillInstallRequest JSON document." }; + command.Options.Add(manifestOption); + command.SetAction((parseResult, cancellationToken) => ExecuteInstallAsync(parseResult.GetValue(manifestOption)!, globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildUninstallCommand(GlobalCliOptions globalOptions) + { + var command = new Command("uninstall", "Removes an installed skill."); + var idOption = new Option("--id") { Required = true, Description = "Skill id." }; + command.Options.Add(idOption); + command.SetAction((parseResult, cancellationToken) => ExecuteUninstallAsync(parseResult.GetValue(idOption)!, globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var skills = await skillRegistry.ListAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + skills.Count == 0 ? "No skills installed." : $"{skills.Count} skill(s).", + JsonSerializer.Serialize(skills.ToList(), ProtocolJsonContext.Default.ListSkillDefinition)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteShowAsync(string id, CommandExecutionContext context, CancellationToken cancellationToken) + { + var skill = await skillRegistry.ResolveAsync(context.WorkingDirectory, id, cancellationToken).ConfigureAwait(false); + if (skill is null) + { + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(false, 1, context.OutputFormat, $"Skill '{id}' was not found.", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 1; + } + + var payload = new SkillInspectionRecord(skill.Definition, skill.PromptTemplate, skill.Metadata); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"{skill.Definition.Id}: {skill.Definition.Description}", JsonSerializer.Serialize(payload, ProtocolJsonContext.Default.SkillInspectionRecord)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteInstallAsync(string manifestPath, CommandExecutionContext context, CancellationToken cancellationToken) + { + var manifestJson = await fileSystem.ReadAllTextIfExistsAsync(manifestPath, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Skill manifest '{manifestPath}' was not found."); + var request = JsonSerializer.Deserialize(manifestJson, new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new InvalidOperationException($"Skill manifest '{manifestPath}' could not be parsed."); + var installed = await skillRegistry.InstallAsync(context.WorkingDirectory, request, cancellationToken).ConfigureAwait(false); + var payload = new SkillInspectionRecord(installed.Definition, installed.PromptTemplate, installed.Metadata); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"Installed skill '{installed.Definition.Id}'.", JsonSerializer.Serialize(payload, ProtocolJsonContext.Default.SkillInspectionRecord)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteUninstallAsync(string id, CommandExecutionContext context, CancellationToken cancellationToken) + { + var removed = await skillRegistry.UninstallAsync(context.WorkingDirectory, id, cancellationToken).ConfigureAwait(false); + var exitCode = removed ? 0 : 1; + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult( + removed, + exitCode, + context.OutputFormat, + removed ? $"Removed skill '{id}'." : $"Skill '{id}' was not found.", + JsonSerializer.Serialize(new Dictionary { ["id"] = id }, ProtocolJsonContext.Default.DictionaryStringString)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return exitCode; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/StatsCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/StatsCommandHandler.cs new file mode 100644 index 0000000..0b924fe --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/StatsCommandHandler.cs @@ -0,0 +1,53 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Surfaces durable workspace execution counts. +/// +public sealed class StatsCommandHandler( + IWorkspaceInsightsService workspaceInsightsService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "stats"; + + /// + public string Description => "Shows turn, tool, provider, share, and todo counts."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + command.SetAction((parseResult, cancellationToken) => ExecuteAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + => ExecuteAsync(context, cancellationToken); + + private async Task ExecuteAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var report = await workspaceInsightsService + .BuildStatsReportAsync(context.WorkingDirectory, context.SessionId, cancellationToken) + .ConfigureAwait(false); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + $"{report.SessionCount} session(s), {report.TurnCompletedCount} completed turn(s), {report.ToolExecutionCount} tool execution(s), {report.ActiveTodoCount} active todo(s).", + JsonSerializer.Serialize(report, ProtocolJsonContext.Default.WorkspaceStatsReport)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/TodoCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/TodoCommandHandler.cs new file mode 100644 index 0000000..4035678 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/TodoCommandHandler.cs @@ -0,0 +1,300 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Manages durable session and workspace todo items. +/// +public sealed class TodoCommandHandler( + ITodoService todoService, + ISessionCoordinator sessionCoordinator, + ISessionStore sessionStore, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "todo"; + + /// + public string Description => "Lists and mutates session or workspace todo items."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + command.Subcommands.Add(BuildListCommand(globalOptions)); + command.Subcommands.Add(BuildAddCommand(globalOptions)); + command.Subcommands.Add(BuildUpdateCommand(globalOptions)); + command.Subcommands.Add(BuildDoneCommand(globalOptions)); + command.Subcommands.Add(BuildRemoveCommand(globalOptions)); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(null, null, globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length == 0 || string.Equals(command.Arguments[0], "list", StringComparison.OrdinalIgnoreCase)) + { + var scopeText = command.Arguments.Length >= 2 ? command.Arguments[1] : null; + var sessionId = command.Arguments.Length >= 3 ? command.Arguments[2] : null; + return await ExecuteListAsync(scopeText, sessionId, context, cancellationToken).ConfigureAwait(false); + } + + if (string.Equals(command.Arguments[0], "add", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 3) + { + var scope = ParseScope(command.Arguments[1]); + var title = string.Join(' ', command.Arguments.Skip(2)); + return await ExecuteAddAsync(scope, title, null, null, context, cancellationToken).ConfigureAwait(false); + } + + if (string.Equals(command.Arguments[0], "update", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 4) + { + var scope = ParseScope(command.Arguments[1]); + var id = command.Arguments[2]; + var status = TryParseStatus(command.Arguments[3]); + var title = command.Arguments.Length > 4 ? string.Join(' ', command.Arguments.Skip(4)) : null; + return await ExecuteUpdateAsync(scope, id, title, status, null, null, context, cancellationToken).ConfigureAwait(false); + } + + if (string.Equals(command.Arguments[0], "done", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 3) + { + var scope = ParseScope(command.Arguments[1]); + return await ExecuteUpdateAsync(scope, command.Arguments[2], null, TodoStatus.Done, null, null, context, cancellationToken).ConfigureAwait(false); + } + + if (string.Equals(command.Arguments[0], "remove", StringComparison.OrdinalIgnoreCase) && command.Arguments.Length >= 3) + { + var scope = ParseScope(command.Arguments[1]); + return await ExecuteRemoveAsync(scope, command.Arguments[2], null, context, cancellationToken).ConfigureAwait(false); + } + + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(false, 1, context.OutputFormat, "Unsupported todo syntax.", null), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 1; + } + + private Command BuildListCommand(GlobalCliOptions globalOptions) + { + var command = new Command("list", "Lists workspace and session todos."); + var scopeOption = new Option("--scope") { Description = "Scope: session, workspace, or all." }; + var sessionOption = new Option("--session") { Description = "Session id when listing session todos." }; + command.Options.Add(scopeOption); + command.Options.Add(sessionOption); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(parseResult.GetValue(scopeOption), parseResult.GetValue(sessionOption), globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildAddCommand(GlobalCliOptions globalOptions) + { + var command = new Command("add", "Adds a todo item."); + var scopeOption = new Option("--scope") { Required = true, Description = "Scope: session or workspace." }; + var titleOption = new Option("--title") { Required = true, Description = "Todo title." }; + var sessionOption = new Option("--session") { Description = "Session id for session-scoped todos." }; + var ownerOption = new Option("--owner-agent") { Description = "Optional owner agent id." }; + command.Options.Add(scopeOption); + command.Options.Add(titleOption); + command.Options.Add(sessionOption); + command.Options.Add(ownerOption); + command.SetAction((parseResult, cancellationToken) => ExecuteAddAsync(ParseScope(parseResult.GetValue(scopeOption)!), parseResult.GetValue(titleOption)!, parseResult.GetValue(sessionOption), parseResult.GetValue(ownerOption), globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildUpdateCommand(GlobalCliOptions globalOptions) + { + var command = new Command("update", "Updates a todo item."); + var scopeOption = new Option("--scope") { Required = true, Description = "Scope: session or workspace." }; + var idOption = new Option("--id") { Required = true, Description = "Todo id." }; + var titleOption = new Option("--title") { Description = "Optional replacement title." }; + var statusOption = new Option("--status") { Description = "Optional status: open, inProgress, blocked, done." }; + var sessionOption = new Option("--session") { Description = "Session id for session-scoped todos." }; + var ownerOption = new Option("--owner-agent") { Description = "Optional owner agent id." }; + command.Options.Add(scopeOption); + command.Options.Add(idOption); + command.Options.Add(titleOption); + command.Options.Add(statusOption); + command.Options.Add(sessionOption); + command.Options.Add(ownerOption); + command.SetAction((parseResult, cancellationToken) => ExecuteUpdateAsync( + ParseScope(parseResult.GetValue(scopeOption)!), + parseResult.GetValue(idOption)!, + parseResult.GetValue(titleOption), + TryParseStatus(parseResult.GetValue(statusOption)), + parseResult.GetValue(sessionOption), + parseResult.GetValue(ownerOption), + globalOptions.Resolve(parseResult), + cancellationToken)); + return command; + } + + private Command BuildDoneCommand(GlobalCliOptions globalOptions) + { + var command = new Command("done", "Marks a todo as done."); + var scopeOption = new Option("--scope") { Required = true, Description = "Scope: session or workspace." }; + var idOption = new Option("--id") { Required = true, Description = "Todo id." }; + var sessionOption = new Option("--session") { Description = "Session id for session-scoped todos." }; + command.Options.Add(scopeOption); + command.Options.Add(idOption); + command.Options.Add(sessionOption); + command.SetAction((parseResult, cancellationToken) => ExecuteUpdateAsync( + ParseScope(parseResult.GetValue(scopeOption)!), + parseResult.GetValue(idOption)!, + null, + TodoStatus.Done, + parseResult.GetValue(sessionOption), + null, + globalOptions.Resolve(parseResult), + cancellationToken)); + return command; + } + + private Command BuildRemoveCommand(GlobalCliOptions globalOptions) + { + var command = new Command("remove", "Removes a todo item."); + var scopeOption = new Option("--scope") { Required = true, Description = "Scope: session or workspace." }; + var idOption = new Option("--id") { Required = true, Description = "Todo id." }; + var sessionOption = new Option("--session") { Description = "Session id for session-scoped todos." }; + command.Options.Add(scopeOption); + command.Options.Add(idOption); + command.Options.Add(sessionOption); + command.SetAction((parseResult, cancellationToken) => ExecuteRemoveAsync(ParseScope(parseResult.GetValue(scopeOption)!), parseResult.GetValue(idOption)!, parseResult.GetValue(sessionOption), globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private async Task ExecuteListAsync(string? scopeText, string? explicitSessionId, CommandExecutionContext context, CancellationToken cancellationToken) + { + var scope = ParseOptionalScope(scopeText); + var sessionId = explicitSessionId; + if (scope is null or TodoScope.Session) + { + sessionId = await ResolveSessionIdAsync(context.WorkingDirectory, explicitSessionId ?? context.SessionId, cancellationToken).ConfigureAwait(false); + } + + var snapshot = await todoService.GetSnapshotAsync(context.WorkingDirectory, sessionId, cancellationToken).ConfigureAwait(false); + var payload = scope switch + { + TodoScope.Session => snapshot with { WorkspaceTodos = [] }, + TodoScope.Workspace => snapshot with { SessionTodos = [] }, + _ => snapshot + }; + var count = payload.SessionTodos.Count + payload.WorkspaceTodos.Count; + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"{count} todo item(s).", JsonSerializer.Serialize(payload, ProtocolJsonContext.Default.TodoSnapshot)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteAddAsync( + TodoScope scope, + string title, + string? explicitSessionId, + string? ownerAgentId, + CommandExecutionContext context, + CancellationToken cancellationToken) + { + var sessionId = scope == TodoScope.Session + ? await ResolveSessionIdAsync(context.WorkingDirectory, explicitSessionId ?? context.SessionId, cancellationToken).ConfigureAwait(false) + : explicitSessionId ?? context.SessionId; + var todo = await todoService.AddAsync(context.WorkingDirectory, scope, title, sessionId, ownerAgentId, cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"Added todo '{todo.Id}'.", JsonSerializer.Serialize(todo, ProtocolJsonContext.Default.TodoItem)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteUpdateAsync( + TodoScope scope, + string id, + string? title, + TodoStatus? status, + string? explicitSessionId, + string? ownerAgentId, + CommandExecutionContext context, + CancellationToken cancellationToken) + { + var sessionId = scope == TodoScope.Session + ? await ResolveSessionIdAsync(context.WorkingDirectory, explicitSessionId ?? context.SessionId, cancellationToken).ConfigureAwait(false) + : explicitSessionId ?? context.SessionId; + var todo = await todoService.UpdateAsync(context.WorkingDirectory, scope, id, sessionId, title, status, ownerAgentId, cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(true, 0, context.OutputFormat, $"Updated todo '{todo.Id}'.", JsonSerializer.Serialize(todo, ProtocolJsonContext.Default.TodoItem)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteRemoveAsync( + TodoScope scope, + string id, + string? explicitSessionId, + CommandExecutionContext context, + CancellationToken cancellationToken) + { + var sessionId = scope == TodoScope.Session + ? await ResolveSessionIdAsync(context.WorkingDirectory, explicitSessionId ?? context.SessionId, cancellationToken).ConfigureAwait(false) + : explicitSessionId ?? context.SessionId; + var removed = await todoService.RemoveAsync(context.WorkingDirectory, scope, id, sessionId, cancellationToken).ConfigureAwait(false); + var exitCode = removed ? 0 : 1; + await outputRendererDispatcher.RenderCommandResultAsync( + new CommandResult(removed, exitCode, context.OutputFormat, removed ? $"Removed todo '{id}'." : $"Todo '{id}' was not found.", JsonSerializer.Serialize(new Dictionary { ["id"] = id }, ProtocolJsonContext.Default.DictionaryStringString)), + context.OutputFormat, + cancellationToken).ConfigureAwait(false); + return exitCode; + } + + private async Task ResolveSessionIdAsync(string workspaceRoot, string? preferredSessionId, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(preferredSessionId)) + { + return preferredSessionId; + } + + var attached = await sessionCoordinator.GetAttachedSessionIdAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(attached)) + { + return attached; + } + + var latest = await sessionStore.GetLatestAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + return latest?.Id; + } + + private static TodoScope ParseScope(string scope) + => scope.Trim().ToLowerInvariant() switch + { + "session" => TodoScope.Session, + "workspace" => TodoScope.Workspace, + _ => throw new InvalidOperationException($"Unsupported todo scope '{scope}'.") + }; + + private static TodoScope? ParseOptionalScope(string? scope) + => string.IsNullOrWhiteSpace(scope) + ? null + : ParseScope(scope); + + private static TodoStatus? TryParseStatus(string? status) + => string.IsNullOrWhiteSpace(status) + ? null + : status.Trim().ToLowerInvariant() switch + { + "open" => TodoStatus.Open, + "inprogress" or "in-progress" => TodoStatus.InProgress, + "blocked" => TodoStatus.Blocked, + "done" => TodoStatus.Done, + _ => throw new InvalidOperationException($"Unsupported todo status '{status}'.") + }; +} diff --git a/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs new file mode 100644 index 0000000..ec32c45 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs @@ -0,0 +1,59 @@ +using System.CommandLine; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Removes a self-hosted share snapshot for a session. +/// +public sealed class UnshareCommandHandler( + IRuntimeCommandService runtimeCommandService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "unshare"; + + /// + public string Description => "Removes a self-hosted share link for a session."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + var idOption = new Option("--id") { Description = "Session id; latest when omitted." }; + command.Options.Add(idOption); + command.SetAction(async (parseResult, cancellationToken) => + { + var context = globalOptions.Resolve(parseResult); + var id = parseResult.GetValue(idOption); + var result = await runtimeCommandService.UnshareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + }); + return command; + } + + /// + public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + var id = command.Arguments.Length > 0 ? command.Arguments[0] : null; + var result = await runtimeCommandService.UnshareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + } + + private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) + => new( + context.WorkingDirectory, + context.Model, + context.PermissionMode, + context.OutputFormat, + context.PrimaryMode, + context.SessionId, + context.AgentId); +} diff --git a/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs new file mode 100644 index 0000000..f100794 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs @@ -0,0 +1,54 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Surfaces durable usage totals for the current workspace. +/// +public sealed class UsageCommandHandler( + IWorkspaceInsightsService workspaceInsightsService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "usage"; + + /// + public string Description => "Shows session and workspace token usage totals."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + command.SetAction((parseResult, cancellationToken) => ExecuteAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + => ExecuteAsync(context, cancellationToken); + + private async Task ExecuteAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var report = await workspaceInsightsService + .BuildUsageReportAsync(context.WorkingDirectory, context.SessionId, cancellationToken) + .ConfigureAwait(false); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + $"Workspace total: {report.WorkspaceTotal.TotalTokens} tokens across {report.Sessions.Count} session(s).", + JsonSerializer.Serialize(report, ProtocolJsonContext.Default.WorkspaceUsageReport)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } +} diff --git a/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs b/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs index ff3fba0..2e00c4f 100644 --- a/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs +++ b/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs @@ -11,10 +11,12 @@ namespace SharpClaw.Code.Commands.Models; /// The requested output format. /// Build, plan, or spec workflow from global CLI options. /// Optional explicit session id for prompts and session-scoped commands. +/// Optional explicit agent id for prompt execution. public sealed record CommandExecutionContext( string WorkingDirectory, string? Model, PermissionMode PermissionMode, OutputFormat OutputFormat, PrimaryMode PrimaryMode, - string? SessionId = null); + string? SessionId = null, + string? AgentId = null); diff --git a/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs b/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs index 1308c67..4e2e96d 100644 --- a/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs +++ b/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs @@ -53,6 +53,12 @@ public GlobalCliOptions() Description = "Targets a specific SharpClaw session id for prompts.", Recursive = true }; + + AgentOption = new Option("--agent") + { + Description = "Selects the effective agent id for prompt execution.", + Recursive = true + }; } /// @@ -85,10 +91,15 @@ public GlobalCliOptions() /// public Option SessionOption { get; } + /// + /// Gets the optional agent id option. + /// + public Option AgentOption { get; } + /// /// Gets all global options. /// - public IEnumerable