Skip to content
Draft
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,27 @@ pre-commit run --hook-stage manual agentskit-review

The hook reviews the repository diff against `origin/main`; it does not claim to review only staged files. Override `--base` when your integration branch differs. To run on every push, override the hook with `stages: [pre-push]` and install that hook type explicitly, but first choose cost, latency, provider, and blocking policies appropriate for the repository.

### Review locally with Ollama

Use Ollama when repository policy requires model inference to stay on a machine or self-hosted runner. Pull a tool-capable coding model that fits the available memory, start Ollama, and review a small branch diff first:

```sh
ollama pull qwen2.5-coder:7b

npx --yes github:AgentsKit-io/code-review-cli \
--provider ollama \
--model qwen2.5-coder:7b \
--base main \
--base-url http://localhost:11434 \
--max-files 10 \
--concurrency 1 \
--no-fail
```

This reviews committed changes between `main` and `HEAD`; it is not a staged-files-only hook. The selected model must support Ollama tool calling because every review lens submits a structured result. `--no-fail` keeps findings advisory, but connection, source, and execution errors still exit nonzero. No provider key is required. Local inference reduces code disclosure, but logs, SARIF files, caches, optional gateways, and observability exporters still need their own access and retention policy.

See the [operations guide](docs/OPERATIONS.md#local-ollama-review) for model sizing, health checks, failure handling, and self-hosted CI guidance.

## Use the GitHub Action

Add `.github/workflows/code-review.yml` to any repository:
Expand Down
41 changes: 41 additions & 0 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,47 @@ Consumer configuration must select a provider through `args`. Keep credentials i

The default diff base remains `origin/main`. A pre-commit invocation does not mean the input is limited to the Git staging area. Set `--base` explicitly when the repository uses another integration branch.

## Local Ollama review

Ollama serves its local API at `http://localhost:11434` by default. Verify the service without sending repository content:

```sh
curl --fail --silent http://localhost:11434/api/tags >/dev/null
```

Choose a tool-capable model that fits the host; tool calling is required because every lens submits a structured result. `qwen2.5-coder:7b` is a practical starting point for machines that cannot run the larger `qwen3-coder:30b`; model quality, context capacity, latency, and memory requirements vary. Pulling a model downloads several gigabytes and does not start a review:

```sh
ollama pull qwen2.5-coder:7b
```

Start with a bounded, advisory branch review:

```sh
npx --yes github:AgentsKit-io/code-review-cli \
--provider ollama \
--model qwen2.5-coder:7b \
--base main \
--base-url http://localhost:11434 \
--max-files 10 \
--concurrency 1 \
--no-fail
```

The default source is the committed Git diff from `--base` to `HEAD`. It does not mean “only staged files,” even when invoked by a Git hook. Use `--paths` when complete files are the intended source. Avoid piping a unified Git patch through `--stdin`: stdin is treated as one source file rather than parsed into per-file changed ranges.

Seven primary lenses plus adversarial votes can be expensive for a local model, and each structured result can require more than one model turn. Begin with `--max-files 10`, `--concurrency 1`, and the default three votes. Reduce the file set before reducing verification depth. `--no-fail` makes surviving findings advisory; it does not hide an unavailable model, malformed response, unreadable source, or failed lens coverage.

For a self-hosted runner, bind Ollama only to the network interfaces required by the job, isolate the runner per repository trust boundary, and protect job logs and artifacts. Do not set a hosted gateway as `--base-url` and describe the run as local. Any optional telemetry or observability exporter creates a separate network boundary that must be approved explicitly.

Troubleshooting:

- **Connection refused:** start Ollama and repeat the `/api/tags` health check.
- **Model not found:** run `ollama pull <exact-model-id>` and pass the same id to `--model`.
- **Slow or out-of-memory:** choose a smaller model, reduce `--max-files`, and keep `--concurrency 1`.
- **Context overflow:** review narrower paths or a smaller branch diff; unreviewed files must remain visibly outside the result.
- **No findings with exit 0:** inspect the summary and successful/failed lens counts; advisory output is not proof that every file was reviewed.

## GitHub Action permissions

The copy-ready workflow in [`examples/pull-request.yml`](../examples/pull-request.yml) requires:
Expand Down
62 changes: 62 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,27 @@ pre-commit run --hook-stage manual agentskit-review

The hook reviews the repository diff against `origin/main`; it does not claim to review only staged files. Override `--base` when your integration branch differs. To run on every push, override the hook with `stages: [pre-push]` and install that hook type explicitly, but first choose cost, latency, provider, and blocking policies appropriate for the repository.

### Review locally with Ollama

Use Ollama when repository policy requires model inference to stay on a machine or self-hosted runner. Pull a tool-capable coding model that fits the available memory, start Ollama, and review a small branch diff first:

```sh
ollama pull qwen2.5-coder:7b

npx --yes github:AgentsKit-io/code-review-cli \
--provider ollama \
--model qwen2.5-coder:7b \
--base main \
--base-url http://localhost:11434 \
--max-files 10 \
--concurrency 1 \
--no-fail
```

This reviews committed changes between `main` and `HEAD`; it is not a staged-files-only hook. The selected model must support Ollama tool calling because every review lens submits a structured result. `--no-fail` keeps findings advisory, but connection, source, and execution errors still exit nonzero. No provider key is required. Local inference reduces code disclosure, but logs, SARIF files, caches, optional gateways, and observability exporters still need their own access and retention policy.

See the [operations guide](https://github.com/AgentsKit-io/code-review-cli/blob/main/docs/OPERATIONS.md#local-ollama-review) for model sizing, health checks, failure handling, and self-hosted CI guidance.

## Use the GitHub Action

Add `.github/workflows/code-review.yml` to any repository:
Expand Down Expand Up @@ -334,6 +355,47 @@ Consumer configuration must select a provider through `args`. Keep credentials i

The default diff base remains `origin/main`. A pre-commit invocation does not mean the input is limited to the Git staging area. Set `--base` explicitly when the repository uses another integration branch.

## Local Ollama review

Ollama serves its local API at `http://localhost:11434` by default. Verify the service without sending repository content:

```sh
curl --fail --silent http://localhost:11434/api/tags >/dev/null
```

Choose a tool-capable model that fits the host; tool calling is required because every lens submits a structured result. `qwen2.5-coder:7b` is a practical starting point for machines that cannot run the larger `qwen3-coder:30b`; model quality, context capacity, latency, and memory requirements vary. Pulling a model downloads several gigabytes and does not start a review:

```sh
ollama pull qwen2.5-coder:7b
```

Start with a bounded, advisory branch review:

```sh
npx --yes github:AgentsKit-io/code-review-cli \
--provider ollama \
--model qwen2.5-coder:7b \
--base main \
--base-url http://localhost:11434 \
--max-files 10 \
--concurrency 1 \
--no-fail
```

The default source is the committed Git diff from `--base` to `HEAD`. It does not mean “only staged files,” even when invoked by a Git hook. Use `--paths` when complete files are the intended source. Avoid piping a unified Git patch through `--stdin`: stdin is treated as one source file rather than parsed into per-file changed ranges.

Seven primary lenses plus adversarial votes can be expensive for a local model, and each structured result can require more than one model turn. Begin with `--max-files 10`, `--concurrency 1`, and the default three votes. Reduce the file set before reducing verification depth. `--no-fail` makes surviving findings advisory; it does not hide an unavailable model, malformed response, unreadable source, or failed lens coverage.

For a self-hosted runner, bind Ollama only to the network interfaces required by the job, isolate the runner per repository trust boundary, and protect job logs and artifacts. Do not set a hosted gateway as `--base-url` and describe the run as local. Any optional telemetry or observability exporter creates a separate network boundary that must be approved explicitly.

Troubleshooting:

- **Connection refused:** start Ollama and repeat the `/api/tags` health check.
- **Model not found:** run `ollama pull <exact-model-id>` and pass the same id to `--model`.
- **Slow or out-of-memory:** choose a smaller model, reduce `--max-files`, and keep `--concurrency 1`.
- **Context overflow:** review narrower paths or a smaller branch diff; unreviewed files must remain visibly outside the result.
- **No findings with exit 0:** inspect the summary and successful/failed lens counts; advisory output is not proof that every file was reviewed.

## GitHub Action permissions

The copy-ready workflow in [`examples/pull-request.yml`](https://github.com/AgentsKit-io/code-review-cli/blob/main/examples/pull-request.yml) requires:
Expand Down
2 changes: 1 addition & 1 deletion readme-standard-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@
"docs/OPERATIONS.md",
"test/cli-smoke.test.mjs"
],
"sourceHash": "sha256:a5e91e2e53b23297102dfd01e9c203028873abce0fd84c17cb92206204f0df49"
"sourceHash": "sha256:3e2d79342e394c0a269ca942f65bbdbc13b78d7afac25668ea81b8312f883870"
},
"exceptions": []
}
Expand Down
5 changes: 5 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createCodeReviewAgent, type CodeReviewConfig, type Reporter, type Sever
import { githubInlineReporter, githubSummaryReporter, markdownReporter, sarifReporter } from '../agents/code-review/reporters.js'
import { claudeCode } from './claude-code-adapter.js'
import { codexCli } from './codex-adapter.js'
import { ollamaReview } from './ollama-adapter.js'
import type { SourceConfig } from '../agents/code-review/sources.js'

const HELP = `AgentsKit Code Review — deep, low-noise review with your model
Expand Down Expand Up @@ -157,6 +158,10 @@ function buildAdapter(): AdapterFactory {
const model = flag('model') ?? (has('api') ? 'claude-opus-4-8' : undefined)
if (provider === 'claude-cli') return claudeCode({ model })
if (provider === 'codex-cli') return codexCli({ model })
if (provider === 'ollama') {
if (!model) throw new Error('--model is required for provider "ollama"')
return ollamaReview({ model, ...(flag('base-url') ? { baseUrl: flag('base-url') } : {}) })
}

const make = (adapters as Record<string, unknown>)[provider]
if (typeof make !== 'function') {
Expand Down
166 changes: 166 additions & 0 deletions src/ollama-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import type { AdapterFactory, AdapterRequest, StreamChunk, StreamSource } from "@agentskit/core";

export interface OllamaReviewOptions {
model: string;
baseUrl?: string;
}

function parseArguments(args: unknown): unknown {
if (typeof args !== "string") return args ?? {};
try {
return JSON.parse(args) as unknown;
} catch {
return {};
}
}

function providerMessages(request: AdapterRequest): Array<Record<string, unknown>> {
const toolNames = new Map<string, string>();
const messages: Array<Record<string, unknown>> = [];
if (request.context?.systemPrompt) {
messages.push({ role: "system", content: request.context.systemPrompt });
}

for (const message of request.messages) {
if (message.role === "assistant" && message.toolCalls?.length) {
for (const call of message.toolCalls) toolNames.set(call.id, call.name);
messages.push({
role: "assistant",
content: message.content,
tool_calls: message.toolCalls.map((call) => ({
function: { name: call.name, arguments: parseArguments(call.args) },
})),
});
continue;
}
if (message.role === "tool") {
const toolName = message.toolCallId ? toolNames.get(message.toolCallId) : undefined;
const onlyRequestedTool = request.context?.tools?.length === 1 ? request.context.tools[0]?.name : undefined;
const resolvedToolName = toolName ?? onlyRequestedTool;
messages.push({
role: "tool",
content: message.content,
...(resolvedToolName ? { tool_name: resolvedToolName } : {}),
});
continue;
}
messages.push({ role: message.role, content: message.content });
}

return messages;
}

function providerTools(request: AdapterRequest) {
return (request.context?.tools ?? []).map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.schema,
},
}));
}

async function* parseResponse(response: Response): AsyncIterableIterator<StreamChunk> {
if (!response.ok) {
const detail = (await response.text()).trim();
yield { type: "error", content: `Ollama API returned ${response.status}${detail ? `: ${detail.slice(0, 300)}` : ""}` };
return;
}
if (!response.body) {
yield { type: "error", content: "Ollama API returned an empty response body" };
return;
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let toolCallIndex = 0;

try {
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const lines = buffer.split("\n");
buffer = done ? "" : (lines.pop() ?? "");

for (const line of lines) {
if (!line.trim()) continue;
let event: {
message?: { content?: string; tool_calls?: Array<{ id?: string; function?: { name?: string; arguments?: unknown } }> };
done?: boolean;
prompt_eval_count?: number;
eval_count?: number;
};
try {
event = JSON.parse(line) as typeof event;
} catch {
continue;
}

if (event.message?.content) yield { type: "text", content: event.message.content };
for (const call of event.message?.tool_calls ?? []) {
if (!call.function?.name) continue;
yield {
type: "tool_call",
toolCall: {
id: call.id ?? `${call.function.name}-${toolCallIndex++}`,
name: call.function.name,
args: typeof call.function.arguments === "string"
? call.function.arguments
: JSON.stringify(call.function.arguments ?? {}),
},
};
}
if (event.done) {
if (typeof event.prompt_eval_count === "number" || typeof event.eval_count === "number") {
const promptTokens = event.prompt_eval_count ?? 0;
const completionTokens = event.eval_count ?? 0;
yield { type: "usage", usage: { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens } };
}
yield { type: "done" };
return;
}
}
if (done) break;
}
} finally {
reader.releaseLock();
}

yield { type: "done" };
}

export function ollamaReview(options: OllamaReviewOptions): AdapterFactory {
const baseUrl = (options.baseUrl ?? "http://localhost:11434").replace(/\/+$/, "");

return {
capabilities: { streaming: true, tools: true, structuredOutput: true },
createSource: (request: AdapterRequest): StreamSource => {
const controller = new AbortController();
return {
stream: async function* () {
try {
const tools = providerTools(request);
const response = await fetch(`${baseUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: options.model,
stream: true,
messages: providerMessages(request),
...(tools.length > 0 ? { tools } : {}),
}),
signal: controller.signal,
});
yield* parseResponse(response);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
yield { type: "error", content: `Ollama API request failed: ${message}` };
}
},
abort: () => controller.abort(),
};
},
};
}
27 changes: 26 additions & 1 deletion test/documentation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ test('README communicates pipeline, maturity, contribution, and ecosystem role',

test('operations guide covers every required security and release topic', () => {
const operations = read('docs/OPERATIONS.md')
for (const marker of ['## Provider and credential choices', '## pre-commit integration', '## GitHub Action permissions', '## Advisory and blocking behavior', '## Cost and latency controls', '## SARIF', '## Failure scenarios', '## Releases and maturity', '## Contribution and security', 'pull_request_target', 'security-events: write']) {
for (const marker of ['## Provider and credential choices', '## pre-commit integration', '## Local Ollama review', '## GitHub Action permissions', '## Advisory and blocking behavior', '## Cost and latency controls', '## SARIF', '## Failure scenarios', '## Releases and maturity', '## Contribution and security', 'pull_request_target', 'security-events: write']) {
assert.ok(operations.includes(marker), `operations guide missing ${marker}`)
}
})
Expand All @@ -45,6 +45,31 @@ test('pre-commit hook is manual, provider-neutral, and reviews the repository di
assert.doesNotMatch(hook, /api[_-]?key/i)
})

test('Ollama recipe is local, bounded, advisory, and honest about its source boundary', () => {
const readme = read('README.md')
const operations = read('docs/OPERATIONS.md')
const readmeRecipe = readme.split('### Review locally with Ollama')[1]?.split('\n## ')[0] ?? ''
const operationsRecipe = operations.split('## Local Ollama review')[1]?.split('\n## ')[0] ?? ''
for (const marker of [
'--provider ollama',
'--model qwen2.5-coder:7b',
'--base-url http://localhost:11434',
'--max-files 10',
'--concurrency 1',
'--no-fail',
]) {
assert.ok(readme.includes(marker), `README Ollama recipe missing ${marker}`)
assert.ok(operations.includes(marker), `operations Ollama recipe missing ${marker}`)
}
assert.match(readme, /not a staged-files-only hook/i)
assert.match(readme, /model must support Ollama tool calling/i)
assert.match(operations, /tool calling is required/i)
assert.match(operations, /does not mean “only staged files,”/i)
assert.match(operations, /stdin is treated as one source file/i)
assert.match(operations, /Do not set a hosted gateway.*describe the run as local/i)
assert.doesNotMatch(`${readmeRecipe}\n${operationsRecipe}`, /OLLAMA_API_KEY|--api-key\s+\S+/)
})

test('the Action stays least-privilege, secret-safe, and advisory by default', () => {
const action = read('action.yml')
const workflow = read('examples/pull-request.yml')
Expand Down
Loading