diff --git a/CHANGELOG.md b/CHANGELOG.md index b29fc1d..a7c3006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ to make our releases unambiguous against upstream. The CLI binary stays as `clawpatch` for downstream compatibility; `protopatch` is also installed as an alias for explicitness. +## 0.6.0 - Unreleased (protoLabs fork) + +- **provider**: added `proto` — drives the protoCLI agent + (`@protolabsai/proto`) over ACP via acpx's `--agent` escape hatch. Same + JSON-schema mechanics as the existing acpx provider, but uses + `acpx --agent "proto --acp -m "` so we don't need acpx to ship + a `proto` subcommand upstream. Auth flows through protoCLI's + `--openai-base-url` / `--openai-api-key` env (typically set to the + LiteLLM gateway in deployed environments). +- Env knobs: + `CLAWPATCH_PROTO_MODEL` default `protolabs/reasoning` + `CLAWPATCH_PROTO_TIMEOUT_MS` default 300000 (5 min); + `CLAWPATCH_PROVIDER_TIMEOUT_MS` is the + cross-provider fallback if proto-specific + is unset +- `check()` validates both `acpx --version` AND `proto --version` so + `clawpatch doctor` catches missing-CLI bootstrap failures explicitly. + ## 0.5.0 - Unreleased (protoLabs fork) - **fork**: protoLabs took ownership 2026-05-24. Package renamed to diff --git a/docs/providers.md b/docs/providers.md index be53968..f919c58 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -23,6 +23,10 @@ Provider names today: - `gateway`: HTTP POST to any OpenAI-compatible `/chat/completions` endpoint with structured outputs — no CLI dependency. **Added in the protoLabs fork (`@protolabsai/protopatch`).** See [Gateway](#gateway) below. +- `proto`: drives the protoCLI agent (`@protolabsai/proto`) over ACP via + `acpx --agent "proto --acp"`. Tool-use review path complementary to + `gateway`'s stateless LLM path. **Added in `@protolabsai/protopatch@0.6.0`.** + See [Proto](#proto) below. - `mock`: deterministic provider for tests and fixtures - `mock-fail`: failure provider for tests @@ -320,6 +324,62 @@ uses `--force` or `--yolo`. Complete HITL verification before promoting this to default provider support, especially for ambient rules, MCP configuration, temporary prompt file handling, timeout behavior, and any claimed read-only mode. +## Proto + +> Added in the protoLabs fork (`@protolabsai/protopatch`, 0.6.0). Not present +> in upstream `openclaw/clawpatch`. + +Drives the protoCLI agent ([`@protolabsai/proto`](https://github.com/protoLabsAI/protoCLI)) +over the Agent Client Protocol. Built on top of acpx's `--agent` escape +hatch so we don't need acpx to ship a `proto` subcommand upstream — the +provider invokes `acpx --agent "proto --acp -m "` and otherwise +behaves identically to the `acpx` provider (same JSON-schema mechanics, +same prompt/stdin contract). + +### Why use this over `gateway` + +- **`gateway`** sends an already-assembled prompt (with file contents inlined) + to an OpenAI-compatible endpoint. Fast, cheap, stateless. Right for the + common case. +- **`proto`** spawns protoCLI as a live ACP agent. The agent has tool access + while the review is running — it can read additional files, run + `--lsp`-backed code-intel queries, run typecheck/lint via shell access, + etc. Slower + more tokens, but produces deeper structural review. + +Pick `proto` when you want the agent to actively investigate the codebase +during review rather than just react to what's pre-inlined. + +### Configuration + +```bash +clawpatch review --provider proto --model protolabs/reasoning +``` + +Or in `.clawpatch/config.json`: + +```json +{ + "provider": { "name": "proto", "model": "protolabs/reasoning" } +} +``` + +### Environment + +| Variable | Default | Notes | +| --- | --- | --- | +| `OPENAI_BASE_URL` | (proto default) | Forwarded to protoCLI via env inheritance. Typically the LiteLLM gateway: `http://gateway:4000/v1` inside the docker network. | +| `OPENAI_API_KEY` | (proto default) | Bearer token protoCLI uses to authenticate to the model provider. | +| `CLAWPATCH_PROTO_MODEL` | `protolabs/reasoning` | Default model passed to `proto --acp -m `. CLI `--model` flag overrides. | +| `CLAWPATCH_PROTO_TIMEOUT_MS` (or `CLAWPATCH_PROVIDER_TIMEOUT_MS`) | `300000` (5 min) | Proto-specific timeout wins; provider-wide fallback applies if proto-specific is unset. | + +### Requirements + +- `acpx` ≥ 0.10.0 installed and on PATH (the ACP driver) +- `proto` (`@protolabsai/proto`) installed and on PATH (the agent it drives) + +`clawpatch doctor` validates both binaries with `--version` checks before +spending any LLM tokens. + ## Gateway > Added in the protoLabs fork (`@protolabsai/protopatch`, 0.5.0). Not present diff --git a/package.json b/package.json index d0c9a75..2907907 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@protolabsai/protopatch", - "version": "0.5.0", + "version": "0.6.0", "description": "Automated code review that lands fixes — protoLabs fork of clawpatch with a `gateway` provider for OpenAI-compatible endpoints.", "license": "MIT", "repository": { diff --git a/src/provider.test.ts b/src/provider.test.ts index 878fb3b..fd55f33 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -1649,6 +1649,10 @@ describe("providerByName", () => { expect(providerByName("gateway").name).toBe("gateway"); }); + it("returns the proto provider (drives protoCLI via acpx --agent escape hatch)", () => { + expect(providerByName("proto").name).toBe("proto"); + }); + it("still supports codex, mock, and mock-fail", () => { expect(providerByName("codex").name).toBe("codex"); expect(providerByName("mock").name).toBe("mock"); @@ -1656,6 +1660,86 @@ describe("providerByName", () => { }); }); +describe("proto provider helpers", () => { + const ENV_KEYS = [ + "CLAWPATCH_PROTO_MODEL", + "CLAWPATCH_PROTO_TIMEOUT_MS", + "CLAWPATCH_PROVIDER_TIMEOUT_MS", + ] as const; + const snapshot: Record = {}; + + beforeEach(() => { + for (const k of ENV_KEYS) snapshot[k] = process.env[k]; + for (const k of ENV_KEYS) delete process.env[k]; + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (snapshot[k] === undefined) delete process.env[k]; + else process.env[k] = snapshot[k]; + } + }); + + it("protoAgentCommand defaults to protolabs/reasoning when nothing is set", () => { + // eslint-disable-next-line no-underscore-dangle + const cmd = __testing.protoAgentCommand({ model: null, reasoningEffort: null, skipGitRepoCheck: false }); + expect(cmd).toBe("proto --acp -m protolabs/reasoning"); + }); + + it("options.model > CLAWPATCH_PROTO_MODEL > default", () => { + process.env["CLAWPATCH_PROTO_MODEL"] = "env-model"; + // eslint-disable-next-line no-underscore-dangle + const fromOpts = __testing.protoAgentCommand({ model: "opts-model", reasoningEffort: null, skipGitRepoCheck: false }); + expect(fromOpts).toBe("proto --acp -m opts-model"); + // eslint-disable-next-line no-underscore-dangle + const fromEnv = __testing.protoAgentCommand({ model: null, reasoningEffort: null, skipGitRepoCheck: false }); + expect(fromEnv).toBe("proto --acp -m env-model"); + }); + + it("buildProtoAcpxArgs (read mode) uses --agent escape hatch and approve-reads", () => { + // eslint-disable-next-line no-underscore-dangle + const args = __testing.buildProtoAcpxArgs("/some/root", { model: null, reasoningEffort: null, skipGitRepoCheck: false }, "read"); + expect(args[0]).toBe("--agent"); + expect(args[1]).toBe("proto --acp -m protolabs/reasoning"); + expect(args).toContain("--cwd"); + expect(args).toContain("/some/root"); + expect(args).toContain("--approve-reads"); + expect(args).toContain("--format"); + expect(args).toContain("json"); + expect(args).toContain("--json-strict"); + expect(args).toContain("--suppress-reads"); + // tail of args: exec --file - + expect(args.slice(-3)).toEqual(["exec", "--file", "-"]); + }); + + it("buildProtoAcpxArgs (approve mode) uses --approve-all (write-capable)", () => { + // eslint-disable-next-line no-underscore-dangle + const args = __testing.buildProtoAcpxArgs("/r", { model: null, reasoningEffort: null, skipGitRepoCheck: false }, "approve"); + expect(args).toContain("--approve-all"); + expect(args).not.toContain("--approve-reads"); + }); + + it("protoTimeoutMs honors CLAWPATCH_PROTO_TIMEOUT_MS then provider-wide fallback then 5-min default", () => { + // eslint-disable-next-line no-underscore-dangle + expect(__testing.protoTimeoutMs()).toBe(5 * 60 * 1000); + process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"] = "120000"; + // eslint-disable-next-line no-underscore-dangle + expect(__testing.protoTimeoutMs()).toBe(120000); + process.env["CLAWPATCH_PROTO_TIMEOUT_MS"] = "90000"; + // eslint-disable-next-line no-underscore-dangle + expect(__testing.protoTimeoutMs()).toBe(90000); // proto-specific wins + }); + + it("protoTimeoutMs rejects garbage values (falls back to 5-min default)", () => { + process.env["CLAWPATCH_PROTO_TIMEOUT_MS"] = "not-a-number"; + // eslint-disable-next-line no-underscore-dangle + expect(__testing.protoTimeoutMs()).toBe(5 * 60 * 1000); + process.env["CLAWPATCH_PROTO_TIMEOUT_MS"] = "-1"; + // eslint-disable-next-line no-underscore-dangle + expect(__testing.protoTimeoutMs()).toBe(5 * 60 * 1000); + }); +}); + describe("gateway provider config", () => { const ENV_KEYS = [ "GATEWAY_API_KEY", diff --git a/src/provider.ts b/src/provider.ts index ccf0553..5c15634 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -269,6 +269,9 @@ export function providerByName(name: string): Provider { if (name === "acpx") { return acpxProvider; } + if (name === "proto") { + return protoProvider; + } if (name === "grok") { return grokProvider; } @@ -410,6 +413,162 @@ const acpxProvider: Provider = { }, }; +// ── proto provider ─────────────────────────────────────────────────────────── +// +// Routes through `acpx --agent "proto --acp"` to drive protoCLI +// (@protolabsai/proto) as an ACP agent. Same prompt + JSON-schema mechanics +// as the acpx provider, but uses acpx's --agent escape hatch instead of a +// registered subcommand because acpx doesn't ship a `proto` subcommand +// upstream. +// +// Auth + model selection happen on protoCLI's side: +// OPENAI_BASE_URL points protoCLI at the LiteLLM gateway +// (http://gateway:4000/v1 inside the workstacean +// container, https://api.proto-labs.ai/v1 externally) +// OPENAI_API_KEY bearer token for the gateway +// CLAWPATCH_PROTO_MODEL default model passed to `proto --acp -m `; +// options.model on the CLI still wins +// +// CLAWPATCH_PROTO_TIMEOUT_MS overrides the default 5-min timeout. + +const PROTO_DEFAULT_MODEL = "protolabs/reasoning"; +const PROTO_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; + +function protoTimeoutMs(): number { + const raw = + process.env["CLAWPATCH_PROTO_TIMEOUT_MS"] ?? process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; + if (raw === undefined) return PROTO_DEFAULT_TIMEOUT_MS; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : PROTO_DEFAULT_TIMEOUT_MS; +} + +function protoAgentCommand(options: ProviderOptions): string { + // Build the inner `proto --acp` invocation acpx hands off to. protoCLI + // honors `-m ` for model selection. We prefer options.model + // (per-invocation CLI override), then CLAWPATCH_PROTO_MODEL env, then the + // package default. + const model = options.model ?? process.env["CLAWPATCH_PROTO_MODEL"] ?? PROTO_DEFAULT_MODEL; + return `proto --acp -m ${model}`; +} + +export function buildProtoAcpxArgs( + root: string, + options: ProviderOptions, + permission: "read" | "approve", +): string[] { + const permFlag = permission === "read" ? "--approve-reads" : "--approve-all"; + const args = [ + "--agent", + protoAgentCommand(options), + "--cwd", + root, + permFlag, + "--format", + "json", + "--json-strict", + "--suppress-reads", + ]; + const promptRetries = acpxPromptRetries(); + if (permission === "read" && promptRetries > 0) { + args.push("--prompt-retries", String(promptRetries)); + } + args.push("exec", "--file", "-"); + return args; +} + +async function runProtoJson( + root: string, + prompt: string, + options: ProviderOptions, + schema: object, + permission: "read" | "approve", + parseOutput: (output: unknown) => T, +): Promise { + const args = buildProtoAcpxArgs(root, options, permission); + const result = await runCommandArgs( + "acpx", + args, + root, + buildAcpxPrompt(prompt, schema, permission), + { trimOutput: false, timeoutMs: protoTimeoutMs() }, + ); + if (result.exitCode !== 0) { + // Reuse acpxFailureMessage — the underlying CLI is acpx; the failure + // shape (auth, timeout, JSON-parse) is identical. The caller can tell + // from the prefix on the message that this came through the proto + // provider. + const baseMessage = acpxFailureMessage(result.stdout, result.stderr, result.exitCode); + throw new ClawpatchError( + baseMessage.replace(/^acpx provider failed/, "proto provider failed"), + providerExitCode(result.stdout, result.stderr), + "provider-failure", + ); + } + const json = extractAcpxJson(result.stdout); + if (json === null) { + throw new ClawpatchError( + `proto: response was not parseable JSON (preview=${safeProviderPreview(result.stdout)})`, + 4, + "provider-failure", + ); + } + return parseOutput(json); +} + +const protoProvider: Provider = { + name: "proto", + async check(root: string): Promise { + // Confirm both ends of the wire: acpx (the ACP driver) and proto (the + // agent it'll spawn). check() is allowed to spend a few ms but not LLM + // tokens — versions are enough. + const acpxR = await runCommandArgs("acpx", ["--version"], root); + if (acpxR.exitCode !== 0) { + throw new ClawpatchError( + "acpx CLI not available (needed to drive proto via ACP). Install: npm install -g acpx@latest", + 4, + "provider-auth", + ); + } + const protoR = await runCommandArgs("proto", ["--version"], root); + if (protoR.exitCode !== 0) { + throw new ClawpatchError( + "proto CLI not available. Install: npm install -g @protolabsai/proto", + 4, + "provider-auth", + ); + } + return `acpx=${acpxR.stdout.trim()} proto=${protoR.stdout.trim()}`; + }, + async map(root: string, prompt: string, options: ProviderOptions): Promise { + return runProtoJson(root, prompt, options, agentMapJsonSchema, "read", (output) => + parseOrThrow(agentMapOutputSchema, output, "proto agent-map"), + ); + }, + async review( + root: string, + prompt: string, + options: ProviderOptions, + ): Promise { + return runProtoJson(root, prompt, options, reviewJsonSchema, "read", (output) => + parseReviewOutput(output), + ); + }, + async fix(root: string, prompt: string, options: ProviderOptions): Promise { + return runProtoJson(root, prompt, options, fixPlanJsonSchema, "approve", (output) => + parseOrThrow(fixPlanOutputSchema, output, "proto fix-plan"), + ); + }, + async revalidate( + root: string, + prompt: string, + options: ProviderOptions, + ): Promise { + return runProtoJson(root, prompt, options, revalidateJsonSchema, "read", (output) => + parseOrThrow(revalidateOutputSchema, output, "proto revalidate"), + ); + }, +}; + const grokProvider: Provider = { name: "grok", async check(root: string): Promise { @@ -2426,4 +2585,7 @@ export const __testing = { providerExitCode, providerJsonSchema, gatewayConfig, + buildProtoAcpxArgs, + protoAgentCommand, + protoTimeoutMs, };