Skip to content

Commit 40a349a

Browse files
committed
feat: add swappable AI provider system (claude, codex, ollama)
Introduce a provider registry so sweeper can use different AI backends instead of being hardcoded to Claude. Well-scoped subagent tasks like lint fixes can now run on smaller/cheaper models via Ollama or use alternative CLI harnesses like Codex. Two provider categories: - CLI (claude, codex): have built-in file tools, sweeper sends prompts - API (ollama): text-in/text-out, sweeper includes file content in prompts and applies returned unified diffs via patch New flags: --provider, --model, --api-base VM isolation validated to only work with CLI providers.
1 parent 33f7b70 commit 40a349a

File tree

22 files changed

+950
-64
lines changed

22 files changed

+950
-64
lines changed

README.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# 🧹 Sweeper Agent
22

3-
Multi-threaded code maintenance with resource isolated subagents.
3+
Multi-threaded code maintenance with resource isolated subagents and swappable AI providers.
44

5-
Sweeper dispatches parallel Claude Code agents to fix lint issues across your codebase, each running in its own isolated environment. It groups issues by file, fans out concurrent fixes, escalates strategy when fixes stall, and records outcomes so it learns what works. With VM isolation enabled, each sub-agent runs inside a dedicated stereOS virtual machine with its own CPU, memory, and secrets boundary, safe to scale to 10+ concurrent agents.
5+
Sweeper dispatches parallel AI agents to fix lint issues across your codebase, each running in its own isolated environment. Providers are swappable: use Claude Code (default), OpenAI Codex, or local models via Ollama. It groups issues by file, fans out concurrent fixes, escalates strategy when fixes stall, and records outcomes so it learns what works. With VM isolation enabled, each sub-agent runs inside a dedicated stereOS virtual machine with its own CPU, memory, and secrets boundary, safe to scale to 10+ concurrent agents.
66

77
```
88
sweeper run --vm -c 5
@@ -65,7 +65,9 @@ The core binary. All integrations below (except Pi) require this.
6565

6666
```bash
6767
go install github.com/papercomputeco/sweeper@latest
68-
sweeper run # default: golangci-lint
68+
sweeper run # default: golangci-lint with claude
69+
sweeper run --provider codex # use OpenAI Codex CLI instead
70+
sweeper run --provider ollama --model qwen2.5-coder:7b # local model via Ollama
6971
sweeper run --vm -c 3 --max-rounds 3 # VM isolation, 3 agents, 3 rounds
7072
sweeper run -- npm run lint # any linter
7173
sweeper observe # review success rates + token spend
@@ -122,6 +124,28 @@ pi install sweeper
122124

123125
This gives you `init_sweep`, `run_linter`, and `log_result` tools plus a dashboard widget. To start a sweep, tell Pi: "Sweep this project for lint issues"
124126

127+
## Providers
128+
129+
Sweeper supports swappable AI providers. Well-scoped tasks like lint fixes can run on smaller, cheaper models.
130+
131+
| Provider | Kind | Requires | Example |
132+
|----------|------|----------|---------|
133+
| `claude` (default) | CLI | `claude` CLI installed | `sweeper run` |
134+
| `codex` | CLI | `codex` CLI installed | `sweeper run --provider codex` |
135+
| `ollama` | API | Ollama running locally | `sweeper run --provider ollama --model qwen2.5-coder:7b` |
136+
137+
**CLI providers** (claude, codex) have built-in file tools. Sweeper sends a prompt and the harness reads/writes files directly.
138+
139+
**API providers** (ollama) are text-in, text-out. Sweeper includes file content in the prompt and applies the returned unified diff via `patch`.
140+
141+
### Provider flags
142+
143+
- `--provider <name>` — AI provider to use (default: `claude`)
144+
- `--model <name>` — Model override for the provider (e.g. `qwen2.5-coder:7b` for ollama)
145+
- `--api-base <url>` — API base URL for API providers (default: `http://localhost:11434` for ollama)
146+
147+
VM isolation (`--vm`) is only compatible with CLI providers.
148+
125149
## Examples
126150

127151
Sweeper works with any command that produces output, not just linters.
@@ -141,6 +165,15 @@ sweeper run -- ./scripts/check-slop.sh
141165

142166
# Higher concurrency with VM isolation
143167
sweeper run --vm -c 5 --max-rounds 3 -- npm run lint
168+
169+
# Use Codex CLI
170+
sweeper run --provider codex -- npm run lint
171+
172+
# Use a local Ollama model
173+
sweeper run --provider ollama --model qwen2.5-coder:7b
174+
175+
# Ollama with a custom API base
176+
sweeper run --provider ollama --model codellama --api-base http://gpu-server:11434
144177
```
145178

146179
When using sweeper as a skill, you can pass arbitrary goals to the agent:

cmd/run.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/papercomputeco/sweeper/pkg/agent"
1313
"github.com/papercomputeco/sweeper/pkg/config"
1414
"github.com/papercomputeco/sweeper/pkg/linter"
15+
"github.com/papercomputeco/sweeper/pkg/provider"
1516
"github.com/papercomputeco/sweeper/pkg/vm"
1617
"github.com/papercomputeco/sweeper/pkg/worker"
1718
"github.com/spf13/cobra"
@@ -21,9 +22,13 @@ func newRunCmd() *cobra.Command {
2122
var dryRun bool
2223
var maxRounds int
2324
var staleThreshold int
25+
var allowedTools []string
2426
var useVM bool
2527
var vmName string
2628
var vmJcard string
29+
var providerName string
30+
var providerModel string
31+
var providerAPI string
2732
cmd := &cobra.Command{
2833
Use: "run [-- command ...]",
2934
Short: "Run sweeper against target directory",
@@ -42,15 +47,23 @@ Examples:
4247
if clamped != concurrency {
4348
fmt.Printf("Concurrency clamped to %d (max %d)\n", clamped, config.MaxConcurrency)
4449
}
50+
tools := append([]string{}, config.DefaultAllowedTools...)
51+
if len(allowedTools) > 0 {
52+
tools = append(tools, allowedTools...)
53+
}
4554
cfg := config.Config{
4655
TargetDir: targetDir,
4756
Concurrency: clamped,
4857
RateLimit: rateLimit,
58+
AllowedTools: tools,
4959
TelemetryDir: ".sweeper/telemetry",
5060
DryRun: dryRun,
5161
NoTapes: noTapes,
5262
MaxRounds: maxRounds,
5363
StaleThreshold: staleThreshold,
64+
Provider: providerName,
65+
ProviderModel: providerModel,
66+
ProviderAPI: providerAPI,
5467
}
5568

5669
piped := isPiped()
@@ -91,6 +104,17 @@ Examples:
91104
cfg.VMName = vmName
92105
cfg.VMJcard = vmJcard
93106

107+
// Validate: --vm is only compatible with CLI providers.
108+
if useVM {
109+
p, err := provider.Get(cfg.Provider)
110+
if err != nil {
111+
return fmt.Errorf("provider %q: %w", cfg.Provider, err)
112+
}
113+
if p.Kind != provider.KindCLI {
114+
return fmt.Errorf("--vm is only compatible with CLI providers (got %q)", cfg.Provider)
115+
}
116+
}
117+
94118
if useVM {
95119
absTarget, _ := filepath.Abs(cfg.TargetDir)
96120
if cfg.VMName != "" {
@@ -127,12 +151,16 @@ Examples:
127151
return nil
128152
},
129153
}
154+
cmd.Flags().StringSliceVar(&allowedTools, "allowed-tools", nil, "additional tools for sub-agents (e.g. 'Bash(npm:*),Bash(cargo:*)')")
130155
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be fixed without making changes")
131156
cmd.Flags().IntVar(&maxRounds, "max-rounds", 1, "maximum retry rounds (1 = single pass)")
132157
cmd.Flags().IntVar(&staleThreshold, "stale-threshold", 2, "consecutive non-improving rounds before exploration mode")
133158
cmd.Flags().BoolVar(&useVM, "vm", false, "boot ephemeral stereOS VM, teardown on exit")
134159
cmd.Flags().StringVar(&vmName, "vm-name", "", "use existing VM by name (no managed lifecycle, implies --vm)")
135160
cmd.Flags().StringVar(&vmJcard, "vm-jcard", "", "custom jcard.toml path (implies --vm)")
161+
cmd.Flags().StringVar(&providerName, "provider", "claude", "AI provider (claude, codex, ollama)")
162+
cmd.Flags().StringVar(&providerModel, "model", "", "model name for the provider (e.g. qwen2.5-coder:7b)")
163+
cmd.Flags().StringVar(&providerAPI, "api-base", "", "API base URL for API providers (e.g. http://localhost:11434)")
136164
return cmd
137165
}
138166

pkg/agent/agent.go

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/papercomputeco/sweeper/pkg/linter"
1212
"github.com/papercomputeco/sweeper/pkg/loop"
1313
"github.com/papercomputeco/sweeper/pkg/planner"
14+
"github.com/papercomputeco/sweeper/pkg/provider"
1415
"github.com/papercomputeco/sweeper/pkg/session"
1516
"github.com/papercomputeco/sweeper/pkg/tapes"
1617
"github.com/papercomputeco/sweeper/pkg/telemetry"
@@ -33,12 +34,13 @@ type Summary struct {
3334
}
3435

3536
type Agent struct {
36-
cfg config.Config
37-
linterFn LinterFunc
38-
executor worker.Executor
39-
pub *telemetry.Publisher
40-
vm VMManager
41-
sessionPath string
37+
cfg config.Config
38+
linterFn LinterFunc
39+
executor worker.Executor
40+
providerKind provider.Kind
41+
pub *telemetry.Publisher
42+
vm VMManager
43+
sessionPath string
4244
}
4345

4446
type Option func(*Agent)
@@ -63,9 +65,26 @@ func New(cfg config.Config, opts ...Option) *Agent {
6365
a := &Agent{
6466
cfg: cfg,
6567
linterFn: defaultLinterFunc,
66-
executor: worker.ClaudeExecutor,
6768
pub: telemetry.NewPublisher(cfg.TelemetryDir),
6869
}
70+
71+
// Resolve provider from registry; fall back to Claude executor if lookup fails.
72+
provName := cfg.Provider
73+
if provName == "" {
74+
provName = "claude"
75+
}
76+
if p, err := provider.Get(provName); err == nil {
77+
a.providerKind = p.Kind
78+
a.executor = p.NewExec(provider.Config{
79+
Model: cfg.ProviderModel,
80+
APIBase: cfg.ProviderAPI,
81+
AllowedTools: cfg.AllowedTools,
82+
})
83+
} else {
84+
a.providerKind = provider.KindCLI
85+
a.executor = worker.NewClaudeExecutor(cfg.AllowedTools)
86+
}
87+
6988
for _, opt := range opts {
7089
opt(a)
7190
}
@@ -171,14 +190,9 @@ func (a *Agent) runParsed(ctx context.Context, result linter.ParseResult, linter
171190
Dir: a.cfg.TargetDir,
172191
Issues: ft.Issues,
173192
}
174-
switch strategy {
175-
case loop.StrategyRetry:
176-
tasks[i].Prompt = worker.BuildRetryPrompt(tasks[i], fh.LastOutput())
177-
case loop.StrategyExploration:
178-
tasks[i].Prompt = worker.BuildExplorationPrompt(tasks[i], fh.LastOutput())
193+
tasks[i].Prompt = a.buildPrompt(tasks[i], strategy, fh.LastOutput())
194+
if strategy == loop.StrategyExploration {
179195
explorationAttempted[ft.File] = true
180-
default:
181-
tasks[i].Prompt = worker.BuildPrompt(tasks[i])
182196
}
183197
}
184198

@@ -399,6 +413,28 @@ func (a *Agent) runRaw(ctx context.Context, result linter.ParseResult, linterNam
399413
return summary, nil
400414
}
401415

416+
// buildPrompt selects the appropriate prompt builder based on provider kind and strategy.
417+
func (a *Agent) buildPrompt(task worker.Task, strategy loop.Strategy, priorOutput string) string {
418+
if a.providerKind == provider.KindAPI {
419+
switch strategy {
420+
case loop.StrategyRetry:
421+
return worker.BuildAPIRetryPrompt(task, priorOutput)
422+
case loop.StrategyExploration:
423+
return worker.BuildAPIExplorationPrompt(task, priorOutput)
424+
default:
425+
return worker.BuildAPIPrompt(task)
426+
}
427+
}
428+
switch strategy {
429+
case loop.StrategyRetry:
430+
return worker.BuildRetryPrompt(task, priorOutput)
431+
case loop.StrategyExploration:
432+
return worker.BuildExplorationPrompt(task, priorOutput)
433+
default:
434+
return worker.BuildPrompt(task)
435+
}
436+
}
437+
402438
func safeHistory(fh *loop.FileHistory) loop.FileHistory {
403439
if fh == nil {
404440
return loop.FileHistory{}

0 commit comments

Comments
 (0)