Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <model>"` 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
Expand Down
60 changes: 60 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <model>"` 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 <model>`. 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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
84 changes: 84 additions & 0 deletions src/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1649,13 +1649,97 @@ 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");
expect(providerByName("mock-fail").name).toBe("mock-fail");
});
});

describe("proto provider helpers", () => {
const ENV_KEYS = [
"CLAWPATCH_PROTO_MODEL",
"CLAWPATCH_PROTO_TIMEOUT_MS",
"CLAWPATCH_PROVIDER_TIMEOUT_MS",
] as const;
const snapshot: Record<string, string | undefined> = {};

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",
Expand Down
162 changes: 162 additions & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 <model>`;
// 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 <model>` 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<T>(
root: string,
prompt: string,
options: ProviderOptions,
schema: object,
permission: "read" | "approve",
parseOutput: (output: unknown) => T,
): Promise<T> {
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<string> {
// 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<AgentMapOutput> {
return runProtoJson(root, prompt, options, agentMapJsonSchema, "read", (output) =>
parseOrThrow(agentMapOutputSchema, output, "proto agent-map"),
);
},
async review(
root: string,
prompt: string,
options: ProviderOptions,
): Promise<PartitionedReviewOutput> {
return runProtoJson(root, prompt, options, reviewJsonSchema, "read", (output) =>
parseReviewOutput(output),
);
},
async fix(root: string, prompt: string, options: ProviderOptions): Promise<FixPlanOutput> {
return runProtoJson(root, prompt, options, fixPlanJsonSchema, "approve", (output) =>
parseOrThrow(fixPlanOutputSchema, output, "proto fix-plan"),
);
},
async revalidate(
root: string,
prompt: string,
options: ProviderOptions,
): Promise<RevalidateOutput> {
return runProtoJson(root, prompt, options, revalidateJsonSchema, "read", (output) =>
parseOrThrow(revalidateOutputSchema, output, "proto revalidate"),
);
},
};

const grokProvider: Provider = {
name: "grok",
async check(root: string): Promise<string> {
Expand Down Expand Up @@ -2426,4 +2585,7 @@ export const __testing = {
providerExitCode,
providerJsonSchema,
gatewayConfig,
buildProtoAcpxArgs,
protoAgentCommand,
protoTimeoutMs,
};
Loading